From 370731e58507e7702d59f196406282940c99b803 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 7 Aug 2026 16:47:36 -0700 Subject: [PATCH 1/9] improvement(files): unify rename authorization --- .../app/api/v2/files/[fileId]/route.test.ts | 113 +++++++--- apps/sim/app/api/v2/files/[fileId]/route.ts | 45 ++-- apps/sim/app/api/v2/lib/gate.ts | 10 +- .../[id]/files/[fileId]/route.test.ts | 146 ++++++++++++ .../workspaces/[id]/files/[fileId]/route.ts | 101 +++------ apps/sim/lib/api/contracts/v2/files.ts | 2 + apps/sim/lib/api/contracts/v2/shared.ts | 2 + apps/sim/lib/api/contracts/workspace-files.ts | 7 + .../lib/api/server/routes/definition.test.ts | 72 ++++++ apps/sim/lib/api/server/routes/definition.ts | 31 +++ apps/sim/lib/api/server/routes/index.ts | 12 + .../api/server/routes/internal-json-route.ts | 140 ++++++++++++ apps/sim/lib/api/server/routes/types.ts | 47 ++++ .../api/server/routes/v2-api-key-auth.test.ts | 134 +++++++++++ .../lib/api/server/routes/v2-api-key-auth.ts | 129 +++++++++++ .../lib/api/server/routes/v2-json-route.ts | 211 ++++++++++++++++++ apps/sim/lib/auth/principal.test.ts | 78 +++++++ .../lib/copilot/auth/file-delegation.test.ts | 71 ++++++ apps/sim/lib/copilot/auth/file-delegation.ts | 53 +++++ .../copilot/tools/handlers/vfs-mutate.test.ts | 61 ++++- .../lib/copilot/tools/handlers/vfs-mutate.ts | 30 +++ .../registry/server-tool-adapter.test.ts | 45 ++++ .../tools/registry/server-tool-adapter.ts | 6 +- .../sim/lib/copilot/tools/server/base-tool.ts | 5 + .../copilot/tools/server/files/rename-file.ts | 24 +- .../tools/server/files/workspace-file.ts | 33 +-- .../core/rate-limiter/rate-limiter.test.ts | 31 +++ .../sim/lib/core/rate-limiter/rate-limiter.ts | 97 +++++--- .../workspace-file-manager-errors.test.ts | 34 ++- .../workspace/workspace-file-manager.ts | 42 +++- .../application/authorization.test.ts | 138 ++++++++++++ .../application/authorization.ts | 93 ++++++++ .../workspace-files/application/operations.ts | 28 +++ .../application/rename-workspace-file.test.ts | 145 ++++++++++++ .../application/rename-workspace-file.ts | 86 +++++++ packages/auth/package.json | 4 + packages/auth/src/principal.ts | 103 +++++++++ packages/platform-authz/src/workspace.ts | 15 +- scripts/check-api-validation-contracts.ts | 12 +- 39 files changed, 2244 insertions(+), 192 deletions(-) create mode 100644 apps/sim/app/api/workspaces/[id]/files/[fileId]/route.test.ts create mode 100644 apps/sim/lib/api/server/routes/definition.test.ts create mode 100644 apps/sim/lib/api/server/routes/definition.ts create mode 100644 apps/sim/lib/api/server/routes/index.ts create mode 100644 apps/sim/lib/api/server/routes/internal-json-route.ts create mode 100644 apps/sim/lib/api/server/routes/types.ts create mode 100644 apps/sim/lib/api/server/routes/v2-api-key-auth.test.ts create mode 100644 apps/sim/lib/api/server/routes/v2-api-key-auth.ts create mode 100644 apps/sim/lib/api/server/routes/v2-json-route.ts create mode 100644 apps/sim/lib/auth/principal.test.ts create mode 100644 apps/sim/lib/copilot/auth/file-delegation.test.ts create mode 100644 apps/sim/lib/copilot/auth/file-delegation.ts create mode 100644 apps/sim/lib/copilot/tools/registry/server-tool-adapter.test.ts create mode 100644 apps/sim/lib/workspace-files/application/authorization.test.ts create mode 100644 apps/sim/lib/workspace-files/application/authorization.ts create mode 100644 apps/sim/lib/workspace-files/application/operations.ts create mode 100644 apps/sim/lib/workspace-files/application/rename-workspace-file.test.ts create mode 100644 apps/sim/lib/workspace-files/application/rename-workspace-file.ts create mode 100644 packages/auth/src/principal.ts diff --git a/apps/sim/app/api/v2/files/[fileId]/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/route.test.ts index 9707809e2af..04fc7712f27 100644 --- a/apps/sim/app/api/v2/files/[fileId]/route.test.ts +++ b/apps/sim/app/api/v2/files/[fileId]/route.test.ts @@ -15,6 +15,9 @@ const { mockPerformRename, mockPerformDelete, mockGetUserEmailsByIds, + mockAuthenticateV2ApiKey, + mockCheckRateLimitDirect, + mockCheckRateLimitDirectOrThrow, } = vi.hoisted(() => ({ mockCheckRateLimit: vi.fn(), mockResolveWorkspaceAccess: vi.fn(), @@ -23,6 +26,9 @@ const { mockPerformRename: vi.fn(), mockPerformDelete: vi.fn(), mockGetUserEmailsByIds: vi.fn(), + mockAuthenticateV2ApiKey: vi.fn(), + mockCheckRateLimitDirect: vi.fn(), + mockCheckRateLimitDirectOrThrow: vi.fn(), })) vi.mock('@/lib/users/queries', () => ({ @@ -45,10 +51,29 @@ vi.mock('@/lib/uploads/contexts/workspace', () => ({ })) vi.mock('@/lib/workspace-files/orchestration', () => ({ - performRenameWorkspaceFile: mockPerformRename, performDeleteWorkspaceFileItems: mockPerformDelete, })) +vi.mock('@/lib/workspace-files/application/rename-workspace-file', () => ({ + renameWorkspaceFile: { + operation: { id: 'files.rename', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mockPerformRename, + }, +})) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mockAuthenticateV2ApiKey, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, +})) + +vi.mock('@/lib/core/rate-limiter', () => ({ + getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }), + RateLimiter: class RateLimiter { + checkRateLimitDirect = mockCheckRateLimitDirect + checkRateLimitDirectOrThrow = mockCheckRateLimitDirectOrThrow + }, +})) + import { DELETE, GET, PATCH } from '@/app/api/v2/files/[fileId]/route' const WS = 'workspace-1' @@ -71,6 +96,14 @@ const RATE_LIMIT_DENIED = { retryAfterMs: 1000, } +const V2_AUTH = { + principal: { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'key-1' }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:key-1', 'user:user-1'] as const, + rateLimitSubscription: null, + keyType: 'personal' as const, +} + function buildRecord(overrides: Record = {}) { return { id: FILE_ID, @@ -165,11 +198,19 @@ describe('PATCH /api/v2/files/[fileId]', () => { beforeEach(() => { vi.clearAllMocks() mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockPerformRename.mockResolvedValue({ - success: true, - file: buildRecord({ name: 'renamed.csv' }), + mockAuthenticateV2ApiKey.mockResolvedValue(V2_AUTH) + mockCheckRateLimitDirect.mockResolvedValue({ + allowed: true, + remaining: 599, + resetAt: new Date('2024-01-01T01:00:00Z'), + }) + mockCheckRateLimitDirectOrThrow.mockResolvedValue({ + allowed: true, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), }) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockPerformRename.mockResolvedValue({ file: buildRecord({ name: 'renamed.csv' }) }) }) it('returns 404 when the v2 API surface flag is off', async () => { @@ -196,19 +237,23 @@ describe('PATCH /api/v2/files/[fileId]', () => { expect(mockPerformRename).not.toHaveBeenCalled() }) - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', - }) + it('conceals an access-denied canonical file as not found', async () => { + const { OrchestrationError } = await import('@/lib/core/orchestration/types') + mockPerformRename.mockRejectedValue( + new OrchestrationError('forbidden', 'Insufficient workspace permissions') + ) const res = await callRename({ workspaceId: WS, name: 'renamed.csv' }) - expect(res.status).toBe(403) - expect(mockPerformRename).not.toHaveBeenCalled() + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') }) it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + mockCheckRateLimitDirectOrThrow.mockResolvedValueOnce({ + allowed: false, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, + }) const res = await callRename({ workspaceId: WS, name: 'renamed.csv' }) expect(res.status).toBe(429) expect((await res.json()).error.code).toBe('RATE_LIMITED') @@ -231,19 +276,33 @@ describe('PATCH /api/v2/files/[fileId]', () => { updatedAt: '2024-01-02T00:00:00.000Z', }) expect(mockPerformRename).toHaveBeenCalledWith({ - workspaceId: WS, - fileId: FILE_ID, - name: 'renamed.csv', - userId: 'user-1', + principal: V2_AUTH.principal, + input: { + fileId: FILE_ID, + assertedWorkspaceId: WS, + name: 'renamed.csv', + }, + request: expect.anything(), }) + expect(mockCheckRateLimitDirectOrThrow).toHaveBeenCalledTimes(2) + expect(mockCheckRateLimitDirectOrThrow).toHaveBeenCalledWith( + 'v2:files.rename:api-key:key-1', + expect.anything() + ) + expect(mockCheckRateLimitDirectOrThrow).toHaveBeenCalledWith( + 'v2:files.rename:user:user-1', + expect.anything() + ) }) - it('maps a conflict errorCode to 409 through the shared mapper', async () => { - mockPerformRename.mockResolvedValue({ - success: false, - error: 'A file named "renamed.csv" already exists in this workspace', - errorCode: 'conflict', - }) + it('maps a typed conflict to 409 through the shared mapper', async () => { + const { OrchestrationError } = await import('@/lib/core/orchestration/types') + mockPerformRename.mockRejectedValue( + new OrchestrationError( + 'conflict', + 'A file named "renamed.csv" already exists in this workspace' + ) + ) const res = await callRename({ workspaceId: WS, name: 'renamed.csv' }) const body = await res.json() @@ -254,11 +313,7 @@ describe('PATCH /api/v2/files/[fileId]', () => { }) it('hides an unclassified failure behind a generic 500', async () => { - mockPerformRename.mockResolvedValue({ - success: false, - error: 'update "workspace_files" set ... failed', - errorCode: 'internal', - }) + mockPerformRename.mockRejectedValue(new Error('update "workspace_files" set ... failed')) const res = await callRename({ workspaceId: WS, name: 'renamed.csv' }) const body = await res.json() diff --git a/apps/sim/app/api/v2/files/[fileId]/route.ts b/apps/sim/app/api/v2/files/[fileId]/route.ts index 53dec1d5470..f0efdf5084a 100644 --- a/apps/sim/app/api/v2/files/[fileId]/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/route.ts @@ -4,12 +4,17 @@ import { v2DownloadFileContract, v2RenameFileContract, } from '@/lib/api/contracts/v2/files' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2FileErrorPolicies, + v2RateLimits, +} from '@/lib/api/server/routes' import { messageForOrchestrationError } from '@/lib/core/orchestration/types' import { fetchWorkspaceFileBuffer, getWorkspaceFile } from '@/lib/uploads/contexts/workspace' -import { - performDeleteWorkspaceFileItems, - performRenameWorkspaceFile, -} from '@/lib/workspace-files/orchestration' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { renameWorkspaceFile } from '@/lib/workspace-files/application/rename-workspace-file' +import { performDeleteWorkspaceFileItems } from '@/lib/workspace-files/orchestration' import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { toV2File } from '@/app/api/v2/files/utils' @@ -67,27 +72,19 @@ export const GET = withPublicApiRouteHandler({ * Names that collide within the destination folder are rejected as `CONFLICT` — * unlike upload, which auto-suffixes on the internal surface. */ -export const PATCH = withPublicApiRouteHandler({ +export const PATCH = defineV2JsonRoute({ contract: v2RenameFileContract, - rateLimitEndpoint: 'file-detail', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { fileId } = input.params - const { workspaceId, name } = input.body - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - const result = await performRenameWorkspaceFile({ workspaceId, fileId, name, userId }) - - if (!result.success || !result.file) { - return v2ErrorForOrchestration( - result.errorCode, - messageForOrchestrationError(result, 'Failed to rename file') - ) - } - - return v2Data(await toV2File(result.file), { rateLimit }) - }, + auth: v2ApiKeyAuth, + operation: fileOperations.rename, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, + mapInput: ({ params, body }) => ({ + fileId: params.fileId, + assertedWorkspaceId: body.workspaceId, + name: body.name, + }), + useCase: renameWorkspaceFile, + present: async ({ file }) => ({ data: await toV2File(file) }), }) /** diff --git a/apps/sim/app/api/v2/lib/gate.ts b/apps/sim/app/api/v2/lib/gate.ts index d9bf214eece..8f14be2f6c1 100644 --- a/apps/sim/app/api/v2/lib/gate.ts +++ b/apps/sim/app/api/v2/lib/gate.ts @@ -10,12 +10,10 @@ import { v2Error } from '@/app/api/v2/lib/response' * answers 404 as if it did not exist, so an ungated caller cannot distinguish * "not in the rollout cohort" from "no such endpoint". * - * Deliberately keyed on `userId` only. A workspace- or org-keyed gate would - * have to read membership for a caller-supplied id before authorization has - * run, and its 404-vs-403 split would then leak whether that workspace's org - * is in the cohort — the trap the per-domain table gate has to work around by - * running late. Keyed on the authenticated user, the check is safe to run - * first and is uniform across every v2 route. + * Deliberately keyed on a server-resolved user rollout subject, never a + * caller-supplied workspace. Personal keys use their authenticated user; + * workspace keys use the canonical workspace billing owner as rollout-only + * context. That billing owner is not an authorization principal. */ export async function v2ApiGateError(userId: string): Promise { if (await isFeatureEnabled('v2-api', { userId })) return null diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.test.ts new file mode 100644 index 00000000000..da7a2a60614 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.test.ts @@ -0,0 +1,146 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getSession: vi.fn(), + rename: vi.fn(), + deleteItems: vi.fn(), + getUserEntityPermissions: vi.fn(), + captureServerEvent: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ getSession: mocks.getSession })) + +vi.mock('@/lib/workspace-files/application/rename-workspace-file', () => ({ + renameWorkspaceFile: { + operation: { id: 'files.rename', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.rename, + }, +})) + +vi.mock('@/lib/workspace-files/orchestration', () => ({ + performDeleteWorkspaceFileItems: mocks.deleteItems, +})) + +vi.mock('@/lib/workspaces/permissions/utils', () => ({ + getUserEntityPermissions: mocks.getUserEntityPermissions, +})) + +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.captureServerEvent })) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { PATCH } from '@/app/api/workspaces/[id]/files/[fileId]/route' + +const WORKSPACE_ID = 'workspace-1' +const FILE_ID = 'wf_1' +const context = { params: Promise.resolve({ id: WORKSPACE_ID, fileId: FILE_ID }) } + +function callRename(body: unknown) { + return PATCH( + new NextRequest(`http://localhost:3000/api/workspaces/${WORKSPACE_ID}/files/${FILE_ID}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + context + ) +} + +function fileRecord() { + return { + id: FILE_ID, + workspaceId: WORKSPACE_ID, + name: 'renamed.csv', + key: 'workspace/ws/file.csv', + path: '/api/files/serve/file.csv', + size: 42, + type: 'text/csv', + uploadedBy: 'user-1', + folderId: undefined, + uploadedAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), + } +} + +describe('PATCH /api/workspaces/[id]/files/[fileId]', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getSession.mockResolvedValue({ + user: { id: 'user-1' }, + session: { id: 'session-1' }, + }) + mocks.rename.mockResolvedValue({ file: fileRecord() }) + }) + + it('authenticates before parsing the request', async () => { + mocks.getSession.mockResolvedValue(null) + + const response = await callRename({ name: 'nested/invalid.csv' }) + + expect(response.status).toBe(401) + expect(await response.json()).toEqual({ error: 'Unauthorized' }) + expect(mocks.rename).not.toHaveBeenCalled() + }) + + it('rejects invalid rename input before the use case', async () => { + const response = await callRename({ name: 'nested/invalid.csv' }) + + expect(response.status).toBe(400) + expect(mocks.rename).not.toHaveBeenCalled() + }) + + it('passes a session principal and canonical assertion to the shared use case', async () => { + const response = await callRename({ name: 'renamed.csv' }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + file: expect.objectContaining({ id: FILE_ID, name: 'renamed.csv', folderId: null }), + }) + expect(mocks.rename).toHaveBeenCalledWith({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + fileId: FILE_ID, + assertedWorkspaceId: WORKSPACE_ID, + name: 'renamed.csv', + }, + request: expect.anything(), + }) + expect(mocks.captureServerEvent).toHaveBeenCalledWith( + 'user-1', + 'file_renamed', + { workspace_id: WORKSPACE_ID }, + { groups: { workspace: WORKSPACE_ID } } + ) + }) + + it('renders typed authorization errors in the internal envelope', async () => { + mocks.rename.mockRejectedValue( + new OrchestrationError('forbidden', 'Insufficient workspace permissions') + ) + + const response = await callRename({ name: 'renamed.csv' }) + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ + success: false, + error: 'Insufficient workspace permissions', + }) + expect(mocks.captureServerEvent).not.toHaveBeenCalled() + }) + + it('hides unexpected failures behind the internal 500 envelope', async () => { + mocks.rename.mockRejectedValue(new Error('update workspace_files failed')) + + const response = await callRename({ name: 'renamed.csv' }) + + expect(response.status).toBe(500) + expect(await response.json()).toEqual({ + success: false, + error: 'Internal server error', + }) + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts index 1826988ea08..8039b5ced00 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts @@ -5,15 +5,20 @@ import { renameWorkspaceFileContract, workspaceFileParamsSchema, } from '@/lib/api/contracts/workspace-files' -import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' +import { getValidationErrorMessage } from '@/lib/api/server' +import { + defineInternalJsonRoute, + internalFileErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' import { getSession } from '@/lib/auth' import { generateRequestId } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' -import { - performDeleteWorkspaceFileItems, - performRenameWorkspaceFile, -} from '@/lib/workspace-files/orchestration' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { renameWorkspaceFile } from '@/lib/workspace-files/application/rename-workspace-file' +import { performDeleteWorkspaceFileItems } from '@/lib/workspace-files/orchestration' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' export const dynamic = 'force-dynamic' @@ -24,70 +29,28 @@ const logger = createLogger('WorkspaceFileAPI') * PATCH /api/workspaces/[id]/files/[fileId] * Rename a workspace file (requires write permission) */ -export const PATCH = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => { - const requestId = generateRequestId() - - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(renameWorkspaceFileContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId, fileId } = parsed.data.params - const { name } = parsed.data.body - - const userPermission = await getUserEntityPermissions( - session.user.id, - 'workspace', - workspaceId - ) - if (userPermission !== 'admin' && userPermission !== 'write') { - logger.warn( - `[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}` - ) - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - const result = await performRenameWorkspaceFile({ - workspaceId, - fileId, - name, - userId: session.user.id, - }) - if (!result.success || !result.file) { - return NextResponse.json( - { success: false, error: result.error }, - { status: result.errorCode === 'conflict' ? 409 : 500 } - ) - } - - logger.info(`[${requestId}] Renamed workspace file: ${fileId} to "${result.file.name}"`) - - captureServerEvent( - session.user.id, - 'file_renamed', - { workspace_id: workspaceId }, - { groups: { workspace: workspaceId } } - ) - return NextResponse.json({ - success: true, - file: result.file, - }) - } catch (error) { - logger.error(`[${requestId}] Error renaming workspace file:`, error) - return NextResponse.json( - { - success: false, - error: getErrorMessage(error, 'Failed to rename file'), - }, - { status: 500 } - ) - } - } -) +export const PATCH = defineInternalJsonRoute({ + contract: renameWorkspaceFileContract, + auth: internalSessionAuth, + operation: fileOperations.rename, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal rename behavior' }), + errorPolicy: internalFileErrorPolicy, + mapInput: ({ params, body }) => ({ + fileId: params.fileId, + assertedWorkspaceId: params.id, + name: body.name, + }), + useCase: renameWorkspaceFile, + onSuccess: ({ principal, result }) => { + captureServerEvent( + principal.userId, + 'file_renamed', + { workspace_id: result.file.workspaceId }, + { groups: { workspace: result.file.workspaceId } } + ) + }, + present: ({ file }) => ({ success: true, file: { ...file, folderId: file.folderId ?? null } }), +}) /** * DELETE /api/workspaces/[id]/files/[fileId] diff --git a/apps/sim/lib/api/contracts/v2/files.ts b/apps/sim/lib/api/contracts/v2/files.ts index 7305fc9690e..c8bd351ec34 100644 --- a/apps/sim/lib/api/contracts/v2/files.ts +++ b/apps/sim/lib/api/contracts/v2/files.ts @@ -11,6 +11,7 @@ import { v2CursorListResponse, v2DataResponse, v2DeleteFolderQuerySchema, + v2ErrorResponseSchema, v2FolderPathInputSchema, v2FolderPathSchema, v2FolderSchema, @@ -428,6 +429,7 @@ export const v2RenameFileContract = defineRouteContract({ mode: 'json', schema: v2DataResponse(v2FileSchema), }, + error: v2ErrorResponseSchema, }) export const v2DeleteFileContract = defineRouteContract({ diff --git a/apps/sim/lib/api/contracts/v2/shared.ts b/apps/sim/lib/api/contracts/v2/shared.ts index fbd33c2f396..653ef97c0bc 100644 --- a/apps/sim/lib/api/contracts/v2/shared.ts +++ b/apps/sim/lib/api/contracts/v2/shared.ts @@ -67,6 +67,8 @@ export const v2ErrorResponseSchema = z.object({ }), }) +export type V2ErrorResponse = z.output + /** `{ data: T }` */ export const v2DataResponse = (dataSchema: T) => z.object({ data: dataSchema }) diff --git a/apps/sim/lib/api/contracts/workspace-files.ts b/apps/sim/lib/api/contracts/workspace-files.ts index 625fc161eb4..74a60303a4f 100644 --- a/apps/sim/lib/api/contracts/workspace-files.ts +++ b/apps/sim/lib/api/contracts/workspace-files.ts @@ -57,6 +57,12 @@ export const renameWorkspaceFileBodySchema = z.object({ name: workspaceFileNameSchema, }) +export const renameWorkspaceFileErrorSchema = z.union([ + z.object({ error: z.string() }), + z.object({ error: z.string(), details: z.array(z.unknown()) }), + z.object({ success: z.literal(false), error: z.string() }), +]) + export const updateWorkspaceFileContentBodySchema = z .object({ content: z.string().max(70_000_000, 'Content is too large'), @@ -183,6 +189,7 @@ export const renameWorkspaceFileContract = defineRouteContract({ file: workspaceFileRecordSchema, }), }, + error: renameWorkspaceFileErrorSchema, }) export const updateWorkspaceFileDimensionsContract = defineRouteContract({ diff --git a/apps/sim/lib/api/server/routes/definition.test.ts b/apps/sim/lib/api/server/routes/definition.test.ts new file mode 100644 index 00000000000..559fcc0e673 --- /dev/null +++ b/apps/sim/lib/api/server/routes/definition.test.ts @@ -0,0 +1,72 @@ +/** + * @vitest-environment node + */ + +import { describe, expect, it } from 'vitest' +import { z } from 'zod' +import { defineRouteContract } from '@/lib/api/contracts' +import { requireJsonRouteDefinition } from '@/lib/api/server/routes/definition' + +const renameOperation = { + id: 'files.rename', + minimumRole: 'write', + workspaceApiKey: 'allow', +} as const + +describe('declarative route definition invariants', () => { + it('accepts one successful JSON response status', () => { + const contract = defineRouteContract({ + method: 'PATCH', + path: '/files/[fileId]', + response: { mode: 'json', schema: z.object({ ok: z.literal(true) }), status: 202 }, + }) + + expect(requireJsonRouteDefinition(contract, renameOperation, renameOperation)).toEqual({ + successStatus: 202, + }) + }) + + it('fails immediately when route and use-case operations differ', () => { + expect(() => + requireJsonRouteDefinition( + defineRouteContract({ + method: 'PATCH', + path: '/files/[fileId]', + response: { mode: 'json', schema: z.object({ ok: z.literal(true) }) }, + }), + renameOperation, + { ...renameOperation, id: 'files.delete' } + ) + ).toThrow('does not match') + }) + + it('fails immediately for binary mode or ambiguous success statuses', () => { + expect(() => + requireJsonRouteDefinition( + defineRouteContract({ + method: 'GET', + path: '/files/[fileId]', + response: { mode: 'binary' }, + }), + renameOperation, + renameOperation + ) + ).toThrow('requires a JSON response contract') + + expect(() => + requireJsonRouteDefinition( + defineRouteContract({ + method: 'PATCH', + path: '/files/[fileId]', + response: { + mode: 'json', + schema: z.object({ ok: z.literal(true) }), + status: [200, 202], + }, + }), + renameOperation, + renameOperation + ) + ).toThrow('must declare one success status') + }) +}) diff --git a/apps/sim/lib/api/server/routes/definition.ts b/apps/sim/lib/api/server/routes/definition.ts new file mode 100644 index 00000000000..261057bd8f6 --- /dev/null +++ b/apps/sim/lib/api/server/routes/definition.ts @@ -0,0 +1,31 @@ +import type { AnyApiRouteContract } from '@/lib/api/contracts' +import type { WorkspaceOperation } from '@/lib/workspace-files/application/operations' + +export interface JsonRouteDefinitionMetadata { + successStatus: number +} + +export function requireJsonRouteDefinition( + contract: AnyApiRouteContract, + declaredOperation: WorkspaceOperation, + useCaseOperation: WorkspaceOperation +): JsonRouteDefinitionMetadata { + if (contract.response.mode !== 'json') { + throw new Error(`${contract.method} ${contract.path} requires a JSON response contract`) + } + if (declaredOperation.id !== useCaseOperation.id) { + throw new Error( + `Route operation ${declaredOperation.id} does not match use case ${useCaseOperation.id}` + ) + } + + const configuredStatus = contract.response.status + if (configuredStatus !== undefined && typeof configuredStatus !== 'number') { + throw new Error(`${contract.method} ${contract.path} must declare one success status`) + } + const successStatus = configuredStatus ?? 200 + if (successStatus < 200 || successStatus >= 300) { + throw new Error(`${contract.method} ${contract.path} has a non-success response status`) + } + return { successStatus } +} diff --git a/apps/sim/lib/api/server/routes/index.ts b/apps/sim/lib/api/server/routes/index.ts new file mode 100644 index 00000000000..74ce1101a0f --- /dev/null +++ b/apps/sim/lib/api/server/routes/index.ts @@ -0,0 +1,12 @@ +export { + defineInternalJsonRoute, + internalFileErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes/internal-json-route' +export { + defineV2JsonRoute, + v2ApiKeyAuth, + v2FileErrorPolicies, + v2RateLimits, +} from '@/lib/api/server/routes/v2-json-route' diff --git a/apps/sim/lib/api/server/routes/internal-json-route.ts b/apps/sim/lib/api/server/routes/internal-json-route.ts new file mode 100644 index 00000000000..c96646f4e8e --- /dev/null +++ b/apps/sim/lib/api/server/routes/internal-json-route.ts @@ -0,0 +1,140 @@ +import type { Principal, SessionPrincipal } from '@sim/auth/principal' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { requireJsonRouteDefinition } from '@/lib/api/server/routes/definition' +import type { + JsonApiRouteContract, + JsonNextRouteHandler, + JsonRouteContext, + JsonRouteDefinition, +} from '@/lib/api/server/routes/types' +import { parseRequest } from '@/lib/api/server/validation' +import { getSession } from '@/lib/auth' +import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import type { WorkspaceOperation } from '@/lib/workspace-files/application/operations' + +class InternalUnauthenticatedError extends Error { + constructor() { + super('Unauthorized') + this.name = 'InternalUnauthenticatedError' + } +} + +export const internalSessionAuth = { + async authenticate(): Promise { + const session = await getSession() + if (!session?.user?.id) throw new InternalUnauthenticatedError() + const sessionId = session.session?.id + if (!sessionId) throw new Error('Authenticated session is missing its session ID') + return { kind: 'session', userId: session.user.id, sessionId } + }, +} as const + +interface InternalRateLimitPolicy { + readonly kind: 'none' + readonly reason: string + enforce(request: NextRequest, principal: Principal): Promise +} + +export const internalRateLimits = { + none({ reason }: { reason: string }): InternalRateLimitPolicy { + if (!reason.trim()) throw new Error('A rate-limit exemption reason is required') + return { + kind: 'none', + reason, + async enforce() {}, + } + }, +} as const + +export interface InternalErrorPolicy { + render(error: unknown): NextResponse | null +} + +export const internalFileErrorPolicy: InternalErrorPolicy = { + render(error) { + const classified = asOrchestrationError(error) + if (!classified) return null + return NextResponse.json( + { success: false, error: classified.message }, + { status: statusForOrchestrationError(classified.code) } + ) + }, +} + +interface InternalJsonRouteOptions< + C extends JsonApiRouteContract, + O extends WorkspaceOperation, + I, + R, +> extends JsonRouteDefinition { + auth: typeof internalSessionAuth + rateLimit: InternalRateLimitPolicy + errorPolicy: InternalErrorPolicy + onSuccess?(args: { principal: SessionPrincipal; input: I; result: R }): void | Promise +} + +export function defineInternalJsonRoute< + C extends JsonApiRouteContract, + O extends WorkspaceOperation, + I, + R, +>(options: InternalJsonRouteOptions): JsonNextRouteHandler { + const { successStatus } = requireJsonRouteDefinition( + options.contract, + options.operation, + options.useCase.operation + ) + + const wrapped = withRouteHandler( + async (request, context) => { + if (request.method !== options.contract.method) { + throw new Error( + `Route received ${request.method} for ${options.contract.method} contract ${options.contract.path}` + ) + } + + let principal: SessionPrincipal + try { + principal = await options.auth.authenticate() + } catch (error) { + if (error instanceof InternalUnauthenticatedError) { + return NextResponse.json({ error: error.message }, { status: 401 }) + } + throw error + } + + await options.rateLimit.enforce(request, principal) + const parsed = await parseRequest(options.contract, request, context ?? {}) + if (!parsed.success) return parsed.response + + try { + const input = options.mapInput(parsed.data) + const result = await options.useCase.execute({ + principal, + input, + request, + }) + await options.onSuccess?.({ principal, input, result }) + const body = await options.present(result) + const responseSchema = options.contract.response + if (responseSchema.mode !== 'json') { + throw new Error('Internal JSON route response mode changed after initialization') + } + const validatedBody = responseSchema.schema.parse(body) + return NextResponse.json(validatedBody, { status: successStatus }) + } catch (error) { + const response = options.errorPolicy.render(error) + if (response) return response + throw error + } + }, + { + unhandledErrorResponse: () => + NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }), + } + ) + + return async (request, context) => wrapped(request, context) +} diff --git a/apps/sim/lib/api/server/routes/types.ts b/apps/sim/lib/api/server/routes/types.ts new file mode 100644 index 00000000000..e1964766380 --- /dev/null +++ b/apps/sim/lib/api/server/routes/types.ts @@ -0,0 +1,47 @@ +import type { Principal } from '@sim/auth/principal' +import type { NextRequest } from 'next/server' +import type { + AnyApiRouteContract, + ContractJsonResponse, + JsonResponseMode, +} from '@/lib/api/contracts' +import type { ParsedRequest } from '@/lib/api/server/validation' +import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' +import type { WorkspaceOperation } from '@/lib/workspace-files/application/operations' + +export interface JsonRouteContext { + params?: + | Promise> + | Record +} + +export interface OperationUseCase { + readonly operation: O + execute(args: { + principal: Principal + input: I + request?: OrchestrationRequestContext + }): Promise +} + +export type JsonApiRouteContract = AnyApiRouteContract & { + response: JsonResponseMode +} + +export interface JsonRouteDefinition< + C extends JsonApiRouteContract, + O extends WorkspaceOperation, + I, + R, +> { + contract: C + operation: O + mapInput(input: ParsedRequest): I + useCase: OperationUseCase, I, R> + present(result: R): ContractJsonResponse | Promise> +} + +export type JsonNextRouteHandler = ( + request: NextRequest, + context?: JsonRouteContext +) => Promise diff --git a/apps/sim/lib/api/server/routes/v2-api-key-auth.test.ts b/apps/sim/lib/api/server/routes/v2-api-key-auth.test.ts new file mode 100644 index 00000000000..dad793c02cf --- /dev/null +++ b/apps/sim/lib/api/server/routes/v2-api-key-auth.test.ts @@ -0,0 +1,134 @@ +/** + * @vitest-environment node + */ +import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + updateLastUsed: vi.fn(), + resolveWorkspaceBillingPayer: vi.fn(), + getHighestPrioritySubscription: vi.fn(), +})) + +vi.mock('@/lib/core/config/env-flags', () => ({ isAuthDisabled: false })) +vi.mock('@/lib/api-key/crypto', () => ({ hashApiKey: (value: string) => `hash:${value}` })) +vi.mock('@/lib/api-key/service', () => ({ updateApiKeyLastUsed: mocks.updateLastUsed })) +vi.mock('@/lib/billing/core/billing-attribution', () => ({ + resolveWorkspaceBillingPayer: mocks.resolveWorkspaceBillingPayer, +})) +vi.mock('@/lib/billing/core/subscription', () => ({ + getHighestPrioritySubscription: mocks.getHighestPrioritySubscription, +})) + +import { + authenticateV2ApiKey, + V2ApiKeyUnauthenticatedError, +} from '@/lib/api/server/routes/v2-api-key-auth' + +describe('v2 API key authentication', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + mocks.updateLastUsed.mockResolvedValue(undefined) + mocks.getHighestPrioritySubscription.mockResolvedValue(null) + }) + + it('normalizes a personal key without exposing loose optional identity fields', async () => { + queueTableRows(schemaMock.apiKey, [ + { + id: 'key-1', + userId: 'user-1', + workspaceId: null, + type: 'personal', + expiresAt: null, + userBanned: false, + }, + ]) + + const result = await authenticateV2ApiKey('secret') + + expect(result).toEqual({ + principal: { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + rolloutUserId: 'user-1', + rateLimitSubjectIds: ['api-key:key-1', 'user:user-1'], + rateLimitSubscription: null, + keyType: 'personal', + }) + expect(mocks.getHighestPrioritySubscription).toHaveBeenCalledWith('user-1', { + onError: 'throw', + }) + }) + + it('normalizes a workspace key as the workspace, not its creator', async () => { + queueTableRows(schemaMock.apiKey, [ + { + id: 'key-1', + userId: 'creator-1', + workspaceId: 'workspace-1', + type: 'workspace', + expiresAt: null, + userBanned: false, + }, + ]) + mocks.resolveWorkspaceBillingPayer.mockResolvedValue({ + billedAccountUserId: 'billing-owner-1', + organizationId: 'organization-1', + payerSubscription: { + plan: 'team', + referenceId: 'organization-1', + }, + }) + + const result = await authenticateV2ApiKey('secret') + + expect(result).toEqual({ + principal: { kind: 'workspace_api_key', workspaceId: 'workspace-1', keyId: 'key-1' }, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:key-1', 'workspace:workspace-1'], + rateLimitSubscription: { plan: 'team', referenceId: 'organization-1' }, + keyType: 'workspace', + }) + expect(mocks.getHighestPrioritySubscription).not.toHaveBeenCalled() + }) + + it('treats missing, banned, and expired credentials as unauthenticated', async () => { + await expect(authenticateV2ApiKey('missing')).rejects.toBeInstanceOf( + V2ApiKeyUnauthenticatedError + ) + + queueTableRows(schemaMock.apiKey, [ + { + id: 'key-1', + userId: 'user-1', + workspaceId: null, + type: 'personal', + expiresAt: null, + userBanned: true, + }, + ]) + await expect(authenticateV2ApiKey('banned')).rejects.toBeInstanceOf( + V2ApiKeyUnauthenticatedError + ) + + queueTableRows(schemaMock.apiKey, [ + { + id: 'key-2', + userId: 'user-1', + workspaceId: null, + type: 'personal', + expiresAt: new Date(Date.now() - 1), + userBanned: false, + }, + ]) + await expect(authenticateV2ApiKey('expired')).rejects.toBeInstanceOf( + V2ApiKeyUnauthenticatedError + ) + }) + + it('propagates auth-store failures instead of converting them to invalid credentials', async () => { + const failure = new Error('database unavailable') + dbChainMockFns.limit.mockRejectedValueOnce(failure) + + await expect(authenticateV2ApiKey('secret')).rejects.toBe(failure) + }) +}) diff --git a/apps/sim/lib/api/server/routes/v2-api-key-auth.ts b/apps/sim/lib/api/server/routes/v2-api-key-auth.ts new file mode 100644 index 00000000000..c04b7b37946 --- /dev/null +++ b/apps/sim/lib/api/server/routes/v2-api-key-auth.ts @@ -0,0 +1,129 @@ +import type { PersonalApiKeyPrincipal, WorkspaceApiKeyPrincipal } from '@sim/auth/principal' +import { db } from '@sim/db' +import { apiKey, user } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' +import { hashApiKey } from '@/lib/api-key/crypto' +import { updateApiKeyLastUsed } from '@/lib/api-key/service' +import { ANONYMOUS_USER_ID } from '@/lib/auth/constants' +import { resolveWorkspaceBillingPayer } from '@/lib/billing/core/billing-attribution' +import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' +import { isAuthDisabled } from '@/lib/core/config/env-flags' + +const logger = createLogger('V2ApiKeyAuth') + +export type V2ApiKeyPrincipal = PersonalApiKeyPrincipal | WorkspaceApiKeyPrincipal + +interface RateLimitSubscription { + plan: string + referenceId: string +} + +export interface V2ApiKeyAuthContext { + principal: V2ApiKeyPrincipal + rolloutUserId: string + rateLimitSubjectIds: readonly [string, ...string[]] + rateLimitSubscription: RateLimitSubscription | null + keyType: 'personal' | 'workspace' +} + +export class V2ApiKeyUnauthenticatedError extends Error { + constructor(message = 'Invalid API key') { + super(message) + this.name = 'V2ApiKeyUnauthenticatedError' + } +} + +interface ApiKeyRow { + id: string + userId: string + workspaceId: string | null + type: string + expiresAt: Date | null + userBanned: boolean | null +} + +function requireValidRow(row: ApiKeyRow | undefined): ApiKeyRow { + if (!row || row.userBanned || (row.expiresAt && row.expiresAt < new Date())) { + throw new V2ApiKeyUnauthenticatedError() + } + if (row.userBanned === null) { + throw new Error(`API key ${row.id} is missing its credential owner`) + } + if (row.type === 'personal' && row.workspaceId === null) return row + if (row.type === 'workspace' && row.workspaceId) return row + throw new Error(`API key ${row.id} has an invalid persisted type/workspace combination`) +} + +export async function authenticateV2ApiKey( + apiKeyHeader: string | null +): Promise { + if (isAuthDisabled) { + return { + principal: { + kind: 'personal_api_key', + userId: ANONYMOUS_USER_ID, + keyId: 'auth-disabled', + }, + rolloutUserId: ANONYMOUS_USER_ID, + rateLimitSubjectIds: [`user:${ANONYMOUS_USER_ID}`], + rateLimitSubscription: null, + keyType: 'personal', + } + } + if (!apiKeyHeader) { + throw new V2ApiKeyUnauthenticatedError('API key required') + } + + const [candidate] = await db + .select({ + id: apiKey.id, + userId: apiKey.userId, + workspaceId: apiKey.workspaceId, + type: apiKey.type, + expiresAt: apiKey.expiresAt, + userBanned: user.banned, + }) + .from(apiKey) + .leftJoin(user, eq(apiKey.userId, user.id)) + .where(eq(apiKey.keyHash, hashApiKey(apiKeyHeader))) + .limit(1) + const row = requireValidRow(candidate) + + await updateApiKeyLastUsed(row.id) + logger.debug('Authenticated v2 API key', { keyId: row.id, keyType: row.type }) + + if (row.type === 'personal') { + const subscription = await getHighestPrioritySubscription(row.userId, { onError: 'throw' }) + return { + principal: { kind: 'personal_api_key', userId: row.userId, keyId: row.id }, + rolloutUserId: row.userId, + rateLimitSubjectIds: [`api-key:${row.id}`, `user:${row.userId}`], + rateLimitSubscription: subscription + ? { plan: subscription.plan, referenceId: subscription.referenceId } + : null, + keyType: 'personal', + } + } + + const workspaceId = row.workspaceId + if (!workspaceId) { + throw new Error(`Workspace API key ${row.id} is missing its workspace scope`) + } + const payer = await resolveWorkspaceBillingPayer(workspaceId) + if (!payer) { + throw new Error(`Workspace ${workspaceId} is missing its billing owner`) + } + return { + principal: { kind: 'workspace_api_key', workspaceId, keyId: row.id }, + rolloutUserId: payer.billedAccountUserId, + rateLimitSubjectIds: [`api-key:${row.id}`, `workspace:${workspaceId}`], + rateLimitSubscription: payer.payerSubscription + ? { + plan: payer.payerSubscription.plan, + referenceId: payer.payerSubscription.referenceId, + } + : null, + keyType: 'workspace', + } +} diff --git a/apps/sim/lib/api/server/routes/v2-json-route.ts b/apps/sim/lib/api/server/routes/v2-json-route.ts new file mode 100644 index 00000000000..aa9169e20dd --- /dev/null +++ b/apps/sim/lib/api/server/routes/v2-json-route.ts @@ -0,0 +1,211 @@ +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { recordRateLimitSnapshot } from '@/lib/api/server/rate-limit-context' +import { requireJsonRouteDefinition } from '@/lib/api/server/routes/definition' +import type { + JsonApiRouteContract, + JsonNextRouteHandler, + JsonRouteContext, + JsonRouteDefinition, +} from '@/lib/api/server/routes/types' +import { + authenticateV2ApiKey, + type V2ApiKeyAuthContext, + V2ApiKeyUnauthenticatedError, +} from '@/lib/api/server/routes/v2-api-key-auth' +import { parseRequest } from '@/lib/api/server/validation' +import { getRateLimit, RateLimiter, type SubscriptionPlan } from '@/lib/core/rate-limiter' +import { getClientIp } from '@/lib/core/utils/request' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import type { WorkspaceOperation } from '@/lib/workspace-files/application/operations' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CaughtOrchestrationError, + v2Error, + v2RateLimitError, + v2ValidationError, +} from '@/app/api/v2/lib/response' + +const rateLimiter = new RateLimiter() +const V2_PREAUTH_IP_LIMIT = { + maxTokens: 600, + refillRate: 300, + refillIntervalMs: 60_000, +} as const + +class V2RouteInfrastructureError extends Error { + constructor(stage: 'authentication' | 'rollout_gate' | 'rate_limit', cause: unknown) { + super(`V2 ${stage} infrastructure failed`, { cause }) + this.name = 'V2RouteInfrastructureError' + } +} + +export const v2ApiKeyAuth = { + authenticate(request: NextRequest) { + return authenticateV2ApiKey(request.headers.get('x-api-key')) + }, +} as const + +interface V2RateLimitPolicy { + readonly kind: 'public_api' + enforce( + request: NextRequest, + auth: V2ApiKeyAuthContext, + operation: WorkspaceOperation + ): Promise +} + +export const v2RateLimits = { + publicApi: { + kind: 'public_api', + async enforce(request, auth, operation) { + const plan = (auth.rateLimitSubscription?.plan ?? 'free') as SubscriptionPlan + const config = getRateLimit(plan, 'api-endpoint') + const buckets = await Promise.all( + auth.rateLimitSubjectIds.map(async (subjectId) => { + try { + return await rateLimiter.checkRateLimitDirectOrThrow( + `v2:${operation.id}:${subjectId}`, + config + ) + } catch (error) { + throw new V2RouteInfrastructureError('rate_limit', error) + } + }) + ) + const rateLimit = buckets.reduce((mostRestrictive, candidate) => { + if (!candidate.allowed && mostRestrictive.allowed) return candidate + if (candidate.allowed === mostRestrictive.allowed) { + if (candidate.remaining < mostRestrictive.remaining) return candidate + if ( + candidate.remaining === mostRestrictive.remaining && + candidate.resetAt > mostRestrictive.resetAt + ) { + return candidate + } + } + return mostRestrictive + }) + const snapshot = { + allowed: rateLimit.allowed, + limit: config.maxTokens, + remaining: rateLimit.remaining, + resetAt: rateLimit.resetAt, + retryAfterMs: rateLimit.retryAfterMs, + keyType: auth.keyType, + } + recordRateLimitSnapshot(request, snapshot) + return rateLimit.allowed ? null : v2RateLimitError(snapshot) + }, + } satisfies V2RateLimitPolicy, +} as const + +export interface V2ErrorPolicy { + render(error: unknown): NextResponse | null +} + +export const v2FileErrorPolicies = { + concealResourceAuthorization: { + render(error) { + const response = v2CaughtOrchestrationError(error) + if (!response) return null + if (response.status === 403) return v2Error('NOT_FOUND', 'File not found') + return response + }, + } satisfies V2ErrorPolicy, +} as const + +interface V2JsonRouteOptions + extends JsonRouteDefinition { + auth: typeof v2ApiKeyAuth + rateLimit: V2RateLimitPolicy + errorPolicy: V2ErrorPolicy +} + +export function defineV2JsonRoute< + C extends JsonApiRouteContract, + O extends WorkspaceOperation, + I, + R, +>(options: V2JsonRouteOptions): JsonNextRouteHandler { + const { successStatus } = requireJsonRouteDefinition( + options.contract, + options.operation, + options.useCase.operation + ) + + const wrapped = withRouteHandler( + async (request, context) => { + if (request.method !== options.contract.method) { + throw new Error( + `Route received ${request.method} for ${options.contract.method} contract ${options.contract.path}` + ) + } + + const ip = getClientIp(request) + const abuseLimit = await rateLimiter.checkRateLimitDirect( + `v2:preauth:ip:${ip}`, + V2_PREAUTH_IP_LIMIT, + { failClosed: true } + ) + if (!abuseLimit.allowed) { + return v2RateLimitError({ ...abuseLimit, limit: V2_PREAUTH_IP_LIMIT.maxTokens }) + } + + let auth: V2ApiKeyAuthContext + try { + auth = await options.auth.authenticate(request) + } catch (error) { + if (error instanceof V2ApiKeyUnauthenticatedError) { + return v2Error('UNAUTHORIZED', error.message) + } + throw new V2RouteInfrastructureError('authentication', error) + } + + let gate + try { + gate = await v2ApiGateError(auth.rolloutUserId) + } catch (error) { + throw new V2RouteInfrastructureError('rollout_gate', error) + } + if (gate) return gate + const limited = await options.rateLimit.enforce(request, auth, options.operation) + if (limited) return limited + + const parsed = await parseRequest(options.contract, request, context ?? {}, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + try { + const result = await options.useCase.execute({ + principal: auth.principal, + input: options.mapInput(parsed.data), + request, + }) + const body = await options.present(result) + const responseSchema = options.contract.response + if (responseSchema.mode !== 'json') { + throw new Error('V2 JSON route response mode changed after initialization') + } + const validatedBody = responseSchema.schema.parse(body) + return NextResponse.json(validatedBody, { + status: successStatus, + headers: { 'Cache-Control': 'private, no-store' }, + }) + } catch (error) { + const response = options.errorPolicy.render(error) + if (response) return response + throw error + } + }, + { + unhandledErrorResponse: ({ error }) => + error instanceof V2RouteInfrastructureError + ? v2Error('SERVICE_UNAVAILABLE', 'Service temporarily unavailable') + : v2Error('INTERNAL_ERROR', 'Internal server error'), + } + ) + + return async (request, context) => wrapped(request, context) +} diff --git a/apps/sim/lib/auth/principal.test.ts b/apps/sim/lib/auth/principal.test.ts new file mode 100644 index 00000000000..0e05aaa4eba --- /dev/null +++ b/apps/sim/lib/auth/principal.test.ts @@ -0,0 +1,78 @@ +/** + * @vitest-environment node + */ +import { resolvePrincipalAttribution, toPrincipalActor } from '@sim/auth/principal' +import { describe, expect, it } from 'vitest' + +describe('principal actors', () => { + it('projects principals into their shared actor identity', () => { + expect( + toPrincipalActor({ kind: 'personal_api_key', keyId: 'key-1', userId: 'user-1' }) + ).toEqual({ kind: 'personal_api_key', keyId: 'key-1', userId: 'user-1' }) + + expect( + toPrincipalActor({ + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:workspace-files', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2026-01-01T00:05:00Z'), + }) + ).toEqual({ + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + delegationId: 'delegation-1', + }) + }) + + it('uses the workspace billing owner for workspace-key attribution', () => { + expect( + resolvePrincipalAttribution( + { kind: 'workspace_api_key', keyId: 'key-1', workspaceId: 'workspace-1' }, + { workspaceBillingOwnerUserId: 'billing-owner-1' } + ) + ).toEqual({ + actor: { kind: 'workspace_api_key', keyId: 'key-1', workspaceId: 'workspace-1' }, + attributedUserId: 'billing-owner-1', + }) + }) + + it('attributes user-backed principals to their human subject', () => { + expect( + resolvePrincipalAttribution({ kind: 'session', userId: 'user-1', sessionId: 'session-1' }) + ).toMatchObject({ attributedUserId: 'user-1' }) + expect( + resolvePrincipalAttribution({ + kind: 'personal_api_key', + keyId: 'key-1', + userId: 'user-2', + }) + ).toMatchObject({ attributedUserId: 'user-2' }) + expect( + resolvePrincipalAttribution({ + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-3', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:workspace-files', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2026-01-01T00:05:00Z'), + }) + ).toMatchObject({ attributedUserId: 'user-3' }) + }) + + it('fails fast when workspace-key attribution has no billing owner', () => { + expect(() => + resolvePrincipalAttribution({ + kind: 'workspace_api_key', + keyId: 'key-1', + workspaceId: 'workspace-1', + }) + ).toThrow('Workspace API key attribution requires a workspace billing owner') + }) +}) diff --git a/apps/sim/lib/copilot/auth/file-delegation.test.ts b/apps/sim/lib/copilot/auth/file-delegation.test.ts new file mode 100644 index 00000000000..739b7675ba6 --- /dev/null +++ b/apps/sim/lib/copilot/auth/file-delegation.test.ts @@ -0,0 +1,71 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + createCopilotFilePrincipal, + messageForCopilotFileError, +} from '@/lib/copilot/auth/file-delegation' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +const trustedContext = { + userId: 'user-1', + workspaceId: 'workspace-1', + chatId: 'chat-1', + executionId: 'execution-1', + toolCallId: 'tool-call-1', + copilotToolExecution: true, +} + +describe('Copilot file delegation', () => { + it('creates a short-lived principal scoped to the trusted workspace and file', () => { + const principal = createCopilotFilePrincipal(trustedContext, 'workspace-1', 'file-1') + + expect(principal).toMatchObject({ + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'copilot-tool:tool-call-1', + audience: 'sim:workspace-files', + resourceScope: { + fileId: 'file-1', + chatId: 'chat-1', + executionId: 'execution-1', + }, + }) + expect(principal.expiresAt.getTime()).toBeGreaterThan(principal.issuedAt.getTime()) + }) + + it('rejects contexts that were not issued by the Copilot execution pipeline', () => { + expect(() => + createCopilotFilePrincipal( + { ...trustedContext, copilotToolExecution: false }, + 'workspace-1', + 'file-1' + ) + ).toThrow('trusted Copilot execution context') + expect(() => + createCopilotFilePrincipal( + { ...trustedContext, toolCallId: undefined }, + 'workspace-1', + 'file-1' + ) + ).toThrow('tool call ID') + }) + + it('rejects a workspace that differs from the server context', () => { + expect(() => createCopilotFilePrincipal(trustedContext, 'workspace-2', 'file-1')).toThrow( + 'workspace does not match' + ) + }) + + it('projects only typed domain messages to Copilot', () => { + expect(messageForCopilotFileError(new OrchestrationError('conflict', 'Name exists'))).toBe( + 'Name exists' + ) + expect(messageForCopilotFileError(new Error('update workspace_files set ...'))).toBe( + 'Failed to rename file' + ) + }) +}) diff --git a/apps/sim/lib/copilot/auth/file-delegation.ts b/apps/sim/lib/copilot/auth/file-delegation.ts new file mode 100644 index 00000000000..97ac8225647 --- /dev/null +++ b/apps/sim/lib/copilot/auth/file-delegation.ts @@ -0,0 +1,53 @@ +import type { DelegatedPrincipal } from '@sim/auth/principal' +import { asOrchestrationError } from '@/lib/core/orchestration/types' +import { WORKSPACE_FILES_DELEGATION_AUDIENCE } from '@/lib/workspace-files/application/authorization' + +const FILE_DELEGATION_TTL_MS = 5 * 60 * 1000 + +export interface CopilotFileDelegationContext { + userId: string + workspaceId?: string + chatId?: string + executionId?: string + toolCallId?: string + copilotToolExecution?: boolean +} + +export function createCopilotFilePrincipal( + context: CopilotFileDelegationContext, + workspaceId: string, + fileId: string +): DelegatedPrincipal { + if (!context.copilotToolExecution) { + throw new Error('File delegation requires a trusted Copilot execution context') + } + if (!context.toolCallId) { + throw new Error('File delegation requires a tool call ID') + } + if (context.workspaceId !== workspaceId) { + throw new Error('File delegation workspace does not match the execution context') + } + + const issuedAt = new Date() + return { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: context.userId, + workspaceId, + delegationId: `copilot-tool:${context.toolCallId}`, + audience: WORKSPACE_FILES_DELEGATION_AUDIENCE, + issuedAt, + expiresAt: new Date(issuedAt.getTime() + FILE_DELEGATION_TTL_MS), + resourceScope: { + fileId, + ...(context.chatId ? { chatId: context.chatId } : {}), + ...(context.executionId ? { executionId: context.executionId } : {}), + }, + } +} + +export function messageForCopilotFileError(error: unknown): string { + const classified = asOrchestrationError(error) + if (classified && classified.code !== 'internal') return classified.message + return 'Failed to rename file' +} diff --git a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts index 322c5524e5d..a91a5e32923 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts @@ -17,6 +17,7 @@ const mocks = vi.hoisted(() => ({ getWorkspaceFileByName: vi.fn(), findWorkspaceFileFolderIdByPath: vi.fn(), ensureWorkspaceFileFolderPath: vi.fn(), + renameWorkspaceFile: vi.fn(), performMoveRenameWorkspaceFile: vi.fn(), performUpdateWorkspaceFileFolder: vi.fn(), performCreateFolder: vi.fn(), @@ -55,6 +56,19 @@ vi.mock('@/lib/workspace-files/orchestration', () => ({ performUpdateWorkspaceFileFolder: mocks.performUpdateWorkspaceFileFolder, })) +vi.mock('@/lib/workspace-files/application/rename-workspace-file', () => ({ + renameWorkspaceFile: { + operation: { id: 'files.rename', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.renameWorkspaceFile, + }, +})) + +vi.mock('@/lib/folders/orchestration', () => ({ + createFolder: mocks.performCreateFolder, + deleteFolder: vi.fn(), + updateFolder: mocks.performUpdateFolder, +})) + vi.mock('@/lib/workflows/orchestration', () => ({ performCreateFolder: mocks.performCreateFolder, performUpdateFolder: mocks.performUpdateFolder, @@ -87,7 +101,12 @@ vi.mock('@/app/api/knowledge/utils', () => ({ import type { ExecutionContext } from '@/lib/copilot/request/types' import { executeVfsCp, executeVfsMkdir, executeVfsMv } from './vfs-mutate' -const context = { userId: 'user-1', workspaceId: 'ws-1' } as ExecutionContext +const context = { + userId: 'user-1', + workspaceId: 'ws-1', + toolCallId: 'tool-call-1', + copilotToolExecution: true, +} as ExecutionContext describe('vfs mv/cp', () => { beforeEach(() => { @@ -102,6 +121,9 @@ describe('vfs mv/cp', () => { mocks.getWorkspaceFileByName.mockResolvedValue(null) mocks.findWorkspaceFileFolderIdByPath.mockResolvedValue(null) mocks.ensureWorkspaceFileFolderPath.mockResolvedValue('ensured-folder') + mocks.renameWorkspaceFile.mockResolvedValue({ + file: { id: 'file-1', name: 'renamed.md' }, + }) }) afterAll(() => { @@ -155,6 +177,42 @@ describe('vfs mv/cp', () => { }) describe('files', () => { + it('routes a same-folder rename through the delegated file use case', async () => { + mocks.getWorkspaceFileByName.mockResolvedValue({ + id: 'file-1', + name: 'draft.md', + folderId: null, + }) + mocks.renameWorkspaceFile.mockResolvedValue({ + file: { id: 'file-1', name: 'final.md' }, + }) + + const result = await executeVfsMv( + { sources: ['files/draft.md'], destination: 'files/final.md' }, + context + ) + + expect(mocks.renameWorkspaceFile).toHaveBeenCalledWith({ + principal: expect.objectContaining({ + kind: 'delegated', + subjectUserId: 'user-1', + workspaceId: 'ws-1', + delegationId: 'copilot-tool:tool-call-1', + resourceScope: expect.objectContaining({ fileId: 'file-1' }), + }), + input: { + fileId: 'file-1', + assertedWorkspaceId: 'ws-1', + name: 'final.md', + }, + }) + expect(mocks.performMoveRenameWorkspaceFile).not.toHaveBeenCalled() + expect(result).toMatchObject({ + success: true, + output: { results: [{ to: 'files/final.md', id: 'file-1' }] }, + }) + }) + it('moves and renames a file in one call, auto-creating destination folders', async () => { mocks.getWorkspaceFileByName.mockResolvedValue({ id: 'file-1', name: 'draft.md' }) mocks.performMoveRenameWorkspaceFile.mockResolvedValue({ @@ -398,6 +456,7 @@ describe('vfs mv/cp', () => { const result = await executeVfsMkdir({ paths: ['workflows/Archive'] }, context) expect(mocks.performCreateFolder).toHaveBeenCalledWith({ + resourceType: 'workflow', workspaceId: 'ws-1', userId: 'user-1', name: 'Archive', diff --git a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts index c0fa82f81c6..2fb94a95526 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts @@ -3,6 +3,10 @@ import { createLogger } from '@sim/logger' import { assertFolderMutable, assertWorkflowMutable } from '@sim/platform-authz/workflow' import { toError } from '@sim/utils/errors' import { eq } from 'drizzle-orm' +import { + createCopilotFilePrincipal, + messageForCopilotFileError, +} from '@/lib/copilot/auth/file-delegation' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { ensureWorkflowAccess, @@ -38,6 +42,7 @@ import { import { performDeleteWorkflow, performUpdateWorkflow } from '@/lib/workflows/orchestration' import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate' import { listFolders, verifyFolderWorkspace } from '@/lib/workflows/utils' +import { renameWorkspaceFile } from '@/lib/workspace-files/application/rename-workspace-file' import { performDeleteWorkspaceFileItems, performMoveRenameWorkspaceFile, @@ -411,6 +416,31 @@ async function mutateWorkspaceFiles( assertMutationNotAborted(context) const targetName = dest.dirMode ? ref.file.name : (dest.leafName as string) const targetFolderId = await dest.ensureFolderId() + if (targetFolderId === ref.file.folderId) { + try { + const result = await renameWorkspaceFile.execute({ + principal: createCopilotFilePrincipal(context, workspaceId, ref.file.id), + input: { + fileId: ref.file.id, + assertedWorkspaceId: workspaceId, + name: targetName, + }, + }) + outcomes.push({ + from: ref.source, + to: `files/${encodeVfsPathSegments([...dest.folderSegments, result.file.name])}`, + kind: 'file', + id: ref.file.id, + }) + } catch (error) { + outcomes.push({ + from: ref.source, + kind: 'file', + error: messageForCopilotFileError(error), + }) + } + continue + } const result = await performMoveRenameWorkspaceFile({ workspaceId, userId: context.userId, diff --git a/apps/sim/lib/copilot/tools/registry/server-tool-adapter.test.ts b/apps/sim/lib/copilot/tools/registry/server-tool-adapter.test.ts new file mode 100644 index 00000000000..3b205556cce --- /dev/null +++ b/apps/sim/lib/copilot/tools/registry/server-tool-adapter.test.ts @@ -0,0 +1,45 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const routeExecution = vi.hoisted(() => vi.fn()) + +vi.mock('@/lib/copilot/tools/server/router', () => ({ routeExecution })) + +import { createServerToolHandler } from '@/lib/copilot/tools/registry/server-tool-adapter' + +describe('server tool adapter authority boundary', () => { + beforeEach(() => { + vi.clearAllMocks() + routeExecution.mockResolvedValue({ success: true }) + }) + + it('overwrites model-supplied workspace scope and forwards trusted delegation context', async () => { + const handler = createServerToolHandler('workspace_file') + + await handler( + { workspaceId: 'attacker-workspace', operation: 'rename' }, + { + userId: 'user-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + executionId: 'execution-1', + toolCallId: 'tool-call-1', + copilotToolExecution: true, + } + ) + + expect(routeExecution).toHaveBeenCalledWith( + 'workspace_file', + expect.objectContaining({ workspaceId: 'workspace-1', operation: 'rename' }), + expect.objectContaining({ + userId: 'user-1', + workspaceId: 'workspace-1', + executionId: 'execution-1', + toolCallId: 'tool-call-1', + copilotToolExecution: true, + }) + ) + }) +}) diff --git a/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts b/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts index 359ed2a4023..552ed7a7b32 100644 --- a/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts +++ b/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts @@ -11,13 +11,15 @@ export function createServerToolHandler(toolId: string): ToolHandler { const enrichedParams = { ...params } if (!enrichedParams.workflowId && context.workflowId) enrichedParams.workflowId = context.workflowId - if (!enrichedParams.workspaceId && context.workspaceId) - enrichedParams.workspaceId = context.workspaceId + if (context.workspaceId) enrichedParams.workspaceId = context.workspaceId try { const result = await routeExecution(toolId, enrichedParams, { userId: context.userId, workspaceId: context.workspaceId, + executionId: context.executionId, + toolCallId: context.toolCallId, + copilotToolExecution: context.copilotToolExecution, billingAttribution: context.billingAttribution, userPermission: context.userPermission ?? undefined, chatId: context.chatId, diff --git a/apps/sim/lib/copilot/tools/server/base-tool.ts b/apps/sim/lib/copilot/tools/server/base-tool.ts index b1db4a7482d..2af97ba7490 100644 --- a/apps/sim/lib/copilot/tools/server/base-tool.ts +++ b/apps/sim/lib/copilot/tools/server/base-tool.ts @@ -5,6 +5,11 @@ import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secr export interface ServerToolContext { userId: string workspaceId?: string + executionId?: string + /** Stable, server-issued identity of the tool call currently executing. */ + toolCallId?: string + /** True only for contexts built by the authenticated Copilot execution pipeline. */ + copilotToolExecution?: boolean billingAttribution?: BillingAttributionSnapshot userPermission?: string chatId?: string diff --git a/apps/sim/lib/copilot/tools/server/files/rename-file.ts b/apps/sim/lib/copilot/tools/server/files/rename-file.ts index a6f9e9f63c8..bdb2f9441dc 100644 --- a/apps/sim/lib/copilot/tools/server/files/rename-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/rename-file.ts @@ -1,5 +1,8 @@ import { createLogger } from '@sim/logger' -import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' +import { + createCopilotFilePrincipal, + messageForCopilotFileError, +} from '@/lib/copilot/auth/file-delegation' import { assertServerToolNotAborted, type BaseServerTool, @@ -9,7 +12,7 @@ import { getWorkspaceFile, resolveWorkspaceFileReference, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { performRenameWorkspaceFile } from '@/lib/workspace-files/orchestration' +import { renameWorkspaceFile } from '@/lib/workspace-files/application/rename-workspace-file' import { validateFlatWorkspaceFileName } from './workspace-file' const logger = createLogger('RenameFileServerTool') @@ -45,8 +48,6 @@ export const renameFileServerTool: BaseServerTool { expect(result.remaining).toBe(1) }) + it('should propagate storage errors for declarative API operation buckets', async () => { + const failure = new Error('Storage error') + mockAdapter.consumeTokens.mockRejectedValue(failure) + + await expect( + rateLimiter.checkRateLimitWithSubscriptionOrThrow( + testUserId, + freeSubscription, + 'api-endpoint', + false + ) + ).rejects.toBe(failure) + }) + + it('should consume an explicit namespaced subject without rewriting its key', async () => { + const config = RATE_LIMITS.free.apiEndpoint + mockAdapter.consumeTokens.mockResolvedValue({ + allowed: true, + tokensRemaining: config.maxTokens - 1, + resetAt: new Date(Date.now() + 60_000), + }) + + await rateLimiter.checkRateLimitDirectOrThrow('v2:files.rename:api-key:key-1', config) + + expect(mockAdapter.consumeTokens).toHaveBeenCalledWith( + 'v2:files.rename:api-key:key-1', + 1, + config + ) + }) + it('should work for all non-manual trigger types', async () => { const triggerTypes = ['api', 'webhook', 'schedule', 'chat'] as const const mockResult: ConsumeResult = { diff --git a/apps/sim/lib/core/rate-limiter/rate-limiter.ts b/apps/sim/lib/core/rate-limiter/rate-limiter.ts index 9e274839d86..d2e132ecf1e 100644 --- a/apps/sim/lib/core/rate-limiter/rate-limiter.ts +++ b/apps/sim/lib/core/rate-limiter/rate-limiter.ts @@ -66,6 +66,40 @@ export class RateLimiter { } } + private async consumeWithSubscription( + subjectId: string, + subscription: SubscriptionInfo | null, + triggerType: TriggerType, + isAsync: boolean + ): Promise { + if (triggerType === 'manual') { + return this.createUnlimitedResult() + } + + const plan = (subscription?.plan || 'free') as SubscriptionPlan + const rateLimitKey = this.getRateLimitKey(subjectId, subscription) + const counterType = this.getCounterType(triggerType, isAsync) + const config = getRateLimit(plan, counterType) + const storageKey = this.buildStorageKey(rateLimitKey, counterType) + const result = await this.storage.consumeTokens(storageKey, 1, config) + + if (!result.allowed) { + logger.info('Rate limit exceeded', { + rateLimitKey, + counterType, + plan, + tokensRemaining: result.tokensRemaining, + }) + } + + return { + allowed: result.allowed, + remaining: result.tokensRemaining, + resetAt: result.resetAt, + retryAfterMs: result.retryAfterMs, + } + } + async checkRateLimitWithSubscription( userId: string, subscription: SubscriptionInfo | null, @@ -73,33 +107,7 @@ export class RateLimiter { isAsync = false ): Promise { try { - if (triggerType === 'manual') { - return this.createUnlimitedResult() - } - - const plan = (subscription?.plan || 'free') as SubscriptionPlan - const rateLimitKey = this.getRateLimitKey(userId, subscription) - const counterType = this.getCounterType(triggerType, isAsync) - const config = getRateLimit(plan, counterType) - const storageKey = this.buildStorageKey(rateLimitKey, counterType) - - const result = await this.storage.consumeTokens(storageKey, 1, config) - - if (!result.allowed) { - logger.info('Rate limit exceeded', { - rateLimitKey, - counterType, - plan, - tokensRemaining: result.tokensRemaining, - }) - } - - return { - allowed: result.allowed, - remaining: result.tokensRemaining, - resetAt: result.resetAt, - retryAfterMs: result.retryAfterMs, - } + return await this.consumeWithSubscription(userId, subscription, triggerType, isAsync) } catch (error) { logger.error('Rate limit storage error - failing open (allowing request)', { error: toError(error).message, @@ -115,6 +123,20 @@ export class RateLimiter { } } + /** + * Consumes an authenticated request token and propagates storage failures. + * Security-sensitive adapters use this instead of the compatibility method + * above so an unavailable limiter cannot silently admit traffic. + */ + async checkRateLimitWithSubscriptionOrThrow( + subjectId: string, + subscription: SubscriptionInfo | null, + triggerType: TriggerType = 'manual', + isAsync = false + ): Promise { + return this.consumeWithSubscription(subjectId, subscription, triggerType, isAsync) + } + async getRateLimitStatusWithSubscription( userId: string, subscription: SubscriptionInfo | null, @@ -204,6 +226,27 @@ export class RateLimiter { } } + /** + * Consume one token from an already-namespaced bucket and propagate storage + * failures. Declarative API adapters use this for credential/workspace + * buckets so an unavailable limiter is never mistaken for spare capacity. + */ + async checkRateLimitDirectOrThrow( + storageKey: string, + config: { maxTokens: number; refillRate: number; refillIntervalMs: number } + ): Promise { + const result = await this.storage.consumeTokens(storageKey, 1, config) + if (!result.allowed) { + logger.info('Rate limit exceeded', { storageKey, tokensRemaining: result.tokensRemaining }) + } + return { + allowed: result.allowed, + remaining: result.tokensRemaining, + resetAt: result.resetAt, + retryAfterMs: result.retryAfterMs, + } + } + async resetRateLimit(rateLimitKey: string): Promise { try { await Promise.all([ diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager-errors.test.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager-errors.test.ts index 1f51abd4b37..04300f1765a 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager-errors.test.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager-errors.test.ts @@ -3,7 +3,7 @@ */ import { dbChainMockFns, resetDbChainMock } from '@sim/testing' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' -import { listWorkspaceFiles } from './workspace-file-manager' +import { listWorkspaceFiles, loadActiveWorkspaceFileContext } from './workspace-file-manager' afterAll(resetDbChainMock) @@ -24,3 +24,35 @@ describe('listWorkspaceFiles error handling', () => { ) }) }) + +describe('loadActiveWorkspaceFileContext', () => { + beforeEach(() => { + vi.clearAllMocks() + resetDbChainMock() + }) + + it('returns the canonical workspace authorization context', async () => { + const context = { + fileId: 'file-1', + workspaceId: 'workspace-1', + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', + } + dbChainMockFns.limit.mockResolvedValueOnce([context]) + + await expect(loadActiveWorkspaceFileContext('file-1')).resolves.toEqual(context) + }) + + it('returns null when the active file does not exist', async () => { + dbChainMockFns.limit.mockResolvedValueOnce([]) + + await expect(loadActiveWorkspaceFileContext('missing-file')).resolves.toBeNull() + }) + + it('propagates database failures', async () => { + dbChainMockFns.limit.mockRejectedValueOnce(new Error('database unavailable')) + + await expect(loadActiveWorkspaceFileContext('file-1')).rejects.toThrow('database unavailable') + }) +}) diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts index 3cb689a7af2..f7fac1dd70b 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -5,7 +5,7 @@ import { randomBytes } from 'crypto' import { db } from '@sim/db' -import { workspaceFiles } from '@sim/db/schema' +import { workspace, workspaceFiles } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { describeError, @@ -135,6 +135,14 @@ export interface UploadedWorkspaceFileRecord extends WorkspaceFileRecord { deletedAt: Date | null } +export interface ActiveWorkspaceFileContext { + fileId: string + workspaceId: string + workspaceOrganizationId: string | null + allowPersonalApiKeys: boolean + billedAccountUserId: string +} + interface ListWorkspaceFilesOptions { scope?: WorkspaceFileScope folders?: WorkspaceFileFolderRecord[] @@ -1339,6 +1347,36 @@ export async function resolveWorkspaceFileReference( return findWorkspaceFileRecord(files, fileReference) } +/** + * Load the canonical authorization context for an active workspace file by resource ID. + * Database failures propagate so callers never confuse unavailable state with a missing file. + */ +export async function loadActiveWorkspaceFileContext( + fileId: string +): Promise { + const [context] = await db + .select({ + fileId: workspaceFiles.id, + workspaceId: workspace.id, + workspaceOrganizationId: workspace.organizationId, + allowPersonalApiKeys: workspace.allowPersonalApiKeys, + billedAccountUserId: workspace.billedAccountUserId, + }) + .from(workspaceFiles) + .innerJoin(workspace, eq(workspaceFiles.workspaceId, workspace.id)) + .where( + and( + eq(workspaceFiles.id, fileId), + eq(workspaceFiles.context, 'workspace'), + isNull(workspaceFiles.deletedAt), + isNull(workspace.archivedAt) + ) + ) + .limit(1) + + return context ?? null +} + /** * Get a specific workspace file. * @@ -1732,7 +1770,7 @@ export async function renameWorkspaceFile( const trimmedName = newName.trim() const normalizedName = normalizeWorkspaceFileItemName(trimmedName, 'File') - const fileRecord = await getWorkspaceFile(workspaceId, fileId) + const fileRecord = await getWorkspaceFile(workspaceId, fileId, { throwOnError: true }) if (!fileRecord) { throw new OrchestrationError('not_found', 'File not found') } diff --git a/apps/sim/lib/workspace-files/application/authorization.test.ts b/apps/sim/lib/workspace-files/application/authorization.test.ts new file mode 100644 index 00000000000..72f040dfde5 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/authorization.test.ts @@ -0,0 +1,138 @@ +/** + * @vitest-environment node + */ +import type { Principal } from '@sim/auth/principal' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const resolvePermission = vi.hoisted(() => 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: resolvePermission, +})) + +import type { OrchestrationError } from '@/lib/core/orchestration/types' +import { authorizeWorkspaceOperation } from '@/lib/workspace-files/application/authorization' +import { fileOperations } from '@/lib/workspace-files/application/operations' + +const authorizationContext = { + workspaceId: 'workspace-1', + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, + fileId: 'file-1', +} + +async function expectForbidden(principal: Principal) { + await expect( + authorizeWorkspaceOperation(principal, fileOperations.rename, authorizationContext) + ).rejects.toMatchObject>({ code: 'forbidden' }) +} + +describe('file operation authorization', () => { + beforeEach(() => { + vi.clearAllMocks() + resolvePermission.mockResolvedValue('write') + }) + + it('uses the current workspace permission for sessions', async () => { + await authorizeWorkspaceOperation( + { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + fileOperations.rename, + authorizationContext + ) + + expect(resolvePermission).toHaveBeenCalledWith( + 'user-1', + 'workspace-1', + 'organization-1', + undefined, + { forUpdate: undefined } + ) + }) + + it('rejects a reader for a write operation', async () => { + resolvePermission.mockResolvedValue('read') + await expectForbidden({ kind: 'session', userId: 'user-1', sessionId: 'session-1' }) + }) + + it('lets a workspace key use its fixed write ceiling only in its own workspace', async () => { + await authorizeWorkspaceOperation( + { kind: 'workspace_api_key', workspaceId: 'workspace-1', keyId: 'key-1' }, + fileOperations.rename, + authorizationContext + ) + expect(resolvePermission).not.toHaveBeenCalled() + + await expectForbidden({ + kind: 'workspace_api_key', + workspaceId: 'workspace-2', + keyId: 'key-2', + }) + }) + + it('fails a personal key immediately when the workspace disables it', async () => { + await expect( + authorizeWorkspaceOperation( + { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + fileOperations.rename, + { ...authorizationContext, allowPersonalApiKeys: false } + ) + ).rejects.toMatchObject>({ code: 'forbidden' }) + expect(resolvePermission).not.toHaveBeenCalled() + }) + + it('reauthorizes a valid file-scoped Copilot delegation as its human subject', async () => { + await authorizeWorkspaceOperation( + { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:workspace-files', + issuedAt: new Date(Date.now() - 1_000), + expiresAt: new Date(Date.now() + 60_000), + resourceScope: { fileId: 'file-1', chatId: 'chat-1' }, + }, + fileOperations.rename, + authorizationContext + ) + + expect(resolvePermission).toHaveBeenCalledWith( + 'user-1', + 'workspace-1', + 'organization-1', + undefined, + { forUpdate: undefined } + ) + }) + + it('rejects expired or wrong-file delegations before permission lookup', async () => { + const base = { + kind: 'delegated' as const, + serviceId: 'copilot' as const, + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:workspace-files', + issuedAt: new Date(Date.now() - 10_000), + } + + await expectForbidden({ + ...base, + expiresAt: new Date(Date.now() - 1), + resourceScope: { fileId: 'file-1' }, + }) + await expectForbidden({ + ...base, + expiresAt: new Date(Date.now() + 60_000), + resourceScope: { fileId: 'file-2' }, + }) + expect(resolvePermission).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workspace-files/application/authorization.ts b/apps/sim/lib/workspace-files/application/authorization.ts new file mode 100644 index 00000000000..10ef39abd63 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/authorization.ts @@ -0,0 +1,93 @@ +import type { Principal } from '@sim/auth/principal' +import type { db } from '@sim/db' +import { + type PermissionType, + permissionSatisfies, + resolveEffectiveWorkspacePermission, +} from '@sim/platform-authz/workspace' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { WorkspaceOperation } from '@/lib/workspace-files/application/operations' + +export const WORKSPACE_FILES_DELEGATION_AUDIENCE = 'sim:workspace-files' + +export interface WorkspaceAuthorizationContext { + workspaceId: string + workspaceOrganizationId: string | null + allowPersonalApiKeys: boolean + fileId?: string +} + +function requirePermission(permission: PermissionType | null, required: PermissionType): void { + if (!permissionSatisfies(permission, required)) { + throw new OrchestrationError('forbidden', 'Insufficient workspace permissions') + } +} + +async function requireCurrentHumanPermission( + userId: string, + context: WorkspaceAuthorizationContext, + required: PermissionType, + options?: WorkspaceAuthorizationOptions +): Promise { + const permission = await resolveEffectiveWorkspacePermission( + userId, + context.workspaceId, + context.workspaceOrganizationId, + options?.executor, + { forUpdate: options?.forUpdate } + ) + requirePermission(permission, required) +} + +export interface WorkspaceAuthorizationOptions { + executor?: Pick + forUpdate?: boolean +} + +export async function authorizeWorkspaceOperation( + principal: Principal, + operation: WorkspaceOperation, + context: WorkspaceAuthorizationContext, + options?: WorkspaceAuthorizationOptions +): Promise { + switch (principal.kind) { + case 'session': + await requireCurrentHumanPermission(principal.userId, context, operation.minimumRole, options) + return + case 'personal_api_key': + if (!context.allowPersonalApiKeys) { + throw new OrchestrationError( + 'forbidden', + 'Personal API keys are disabled for this workspace' + ) + } + await requireCurrentHumanPermission(principal.userId, context, operation.minimumRole, options) + return + case 'workspace_api_key': + if ( + principal.workspaceId !== context.workspaceId || + operation.workspaceApiKey !== 'allow' || + !permissionSatisfies('write', operation.minimumRole) + ) { + throw new OrchestrationError('forbidden', 'Workspace API key cannot perform this operation') + } + return + case 'delegated': + if ( + principal.audience !== WORKSPACE_FILES_DELEGATION_AUDIENCE || + principal.expiresAt.getTime() <= Date.now() || + principal.workspaceId !== context.workspaceId || + (principal.resourceScope?.fileId !== undefined && + principal.resourceScope.fileId !== context.fileId) + ) { + throw new OrchestrationError('forbidden', 'Delegated file access is no longer valid') + } + await requireCurrentHumanPermission( + principal.subjectUserId, + context, + operation.minimumRole, + options + ) + return + } +} diff --git a/apps/sim/lib/workspace-files/application/operations.ts b/apps/sim/lib/workspace-files/application/operations.ts new file mode 100644 index 00000000000..7a6dfffb2d4 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/operations.ts @@ -0,0 +1,28 @@ +import type { PermissionType } from '@sim/platform-authz/workspace' + +type WorkspaceApiKeyPolicy = R extends 'admin' ? 'deny' : 'allow' | 'deny' + +export interface WorkspaceOperation< + Id extends string = string, + Role extends PermissionType = PermissionType, +> { + readonly id: Id + readonly minimumRole: Role + readonly workspaceApiKey: WorkspaceApiKeyPolicy +} + +function defineWorkspaceOperation( + operation: WorkspaceOperation +): WorkspaceOperation { + return Object.freeze(operation) +} + +export const fileOperations = { + rename: defineWorkspaceOperation({ + id: 'files.rename', + minimumRole: 'write', + workspaceApiKey: 'allow', + }), +} as const + +export type FileOperation = (typeof fileOperations)[keyof typeof fileOperations] diff --git a/apps/sim/lib/workspace-files/application/rename-workspace-file.test.ts b/apps/sim/lib/workspace-files/application/rename-workspace-file.test.ts new file mode 100644 index 00000000000..a03401e836e --- /dev/null +++ b/apps/sim/lib/workspace-files/application/rename-workspace-file.test.ts @@ -0,0 +1,145 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadContext: vi.fn(), + renameStored: vi.fn(), + authorize: vi.fn(), + recordAudit: vi.fn(), + notify: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { FILE_UPDATED: 'FILE_UPDATED' }, + AuditResourceType: { FILE: 'FILE' }, + recordAudit: mocks.recordAudit, +})) + +vi.mock('@/lib/realtime/notify', () => ({ notifyWorkspaceFilesChanged: mocks.notify })) + +vi.mock('@/lib/workspace-files/application/authorization', () => ({ + authorizeWorkspaceOperation: mocks.authorize, +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + loadActiveWorkspaceFileContext: mocks.loadContext, + renameWorkspaceFile: mocks.renameStored, +})) + +import { renameWorkspaceFile } from '@/lib/workspace-files/application/rename-workspace-file' + +const canonical = { + fileId: 'file-1', + workspaceId: 'workspace-1', + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +const mappedFile = { + id: 'file-1', + workspaceId: 'workspace-1', + name: 'new.csv', + key: 'workspace/ws/file.csv', + path: '/api/files/serve/file.csv', + size: 42, + type: 'text/csv', + uploadedBy: 'uploader-1', + folderId: null, + uploadedAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), +} + +describe('renameWorkspaceFile application service', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadContext.mockResolvedValue(canonical) + mocks.renameStored.mockResolvedValue(mappedFile) + mocks.authorize.mockResolvedValue(undefined) + mocks.notify.mockResolvedValue(undefined) + }) + + it('loads, authorizes, renames, then emits side effects', async () => { + const result = await renameWorkspaceFile.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-1', name: 'new.csv' }, + }) + + expect(result).toEqual({ file: mappedFile }) + expect(mocks.loadContext).toHaveBeenCalledWith('file-1') + expect(mocks.authorize).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'session', userId: 'user-1' }), + expect.objectContaining({ id: 'files.rename' }), + expect.objectContaining({ workspaceId: 'workspace-1', fileId: 'file-1' }) + ) + expect(mocks.renameStored).toHaveBeenCalledWith('workspace-1', 'file-1', 'new.csv') + expect(mocks.renameStored.mock.invocationCallOrder[0]).toBeLessThan( + mocks.recordAudit.mock.invocationCallOrder[0] + ) + expect(mocks.renameStored.mock.invocationCallOrder[0]).toBeLessThan( + mocks.notify.mock.invocationCallOrder[0] + ) + }) + + it('conceals an asserted-workspace mismatch before authorization or mutation', async () => { + await expect( + renameWorkspaceFile.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-2', name: 'new.csv' }, + }) + ).rejects.toMatchObject({ code: 'not_found', message: 'File not found' }) + + expect(mocks.authorize).not.toHaveBeenCalled() + expect(mocks.renameStored).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it('attributes a workspace-key audit to the billing owner', async () => { + await renameWorkspaceFile.execute({ + principal: { kind: 'workspace_api_key', workspaceId: 'workspace-1', keyId: 'key-1' }, + input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-1', name: 'new.csv' }, + }) + + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: 'billing-owner-1', + metadata: expect.objectContaining({ + actor: { + kind: 'workspace_api_key', + keyId: 'key-1', + workspaceId: 'workspace-1', + }, + }), + }) + ) + }) + + it('propagates a typed rename conflict', async () => { + const failure = Object.assign(new Error('File already exists'), { code: 'conflict' }) + mocks.renameStored.mockRejectedValueOnce(failure) + + await expect( + renameWorkspaceFile.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-1', name: 'new.csv' }, + }) + ).rejects.toBe(failure) + + expect(mocks.recordAudit).not.toHaveBeenCalled() + expect(mocks.notify).not.toHaveBeenCalled() + }) + + it('propagates an infrastructure read failure without classifying it as not found', async () => { + const failure = new Error('database unavailable') + mocks.loadContext.mockRejectedValueOnce(failure) + + await expect( + renameWorkspaceFile.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { fileId: 'file-1', name: 'new.csv' }, + }) + ).rejects.toBe(failure) + }) +}) diff --git a/apps/sim/lib/workspace-files/application/rename-workspace-file.ts b/apps/sim/lib/workspace-files/application/rename-workspace-file.ts new file mode 100644 index 00000000000..3ce99418a3b --- /dev/null +++ b/apps/sim/lib/workspace-files/application/rename-workspace-file.ts @@ -0,0 +1,86 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { type Principal, resolvePrincipalAttribution } from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' +import { + loadActiveWorkspaceFileContext, + renameWorkspaceFile as renameStoredWorkspaceFile, + type WorkspaceFileRecord, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { authorizeWorkspaceOperation } from '@/lib/workspace-files/application/authorization' +import { fileOperations } from '@/lib/workspace-files/application/operations' + +const logger = createLogger('RenameWorkspaceFile') + +export interface RenameWorkspaceFileInput { + fileId: string + assertedWorkspaceId?: string + name: string +} + +export interface RenameWorkspaceFileResult { + file: WorkspaceFileRecord +} + +interface RenameWorkspaceFileArguments { + principal: Principal + input: RenameWorkspaceFileInput + request?: OrchestrationRequestContext +} + +async function executeRenameWorkspaceFile({ + principal, + input, + request, +}: RenameWorkspaceFileArguments): Promise { + const canonical = await loadActiveWorkspaceFileContext(input.fileId) + if ( + !canonical || + (input.assertedWorkspaceId !== undefined && input.assertedWorkspaceId !== canonical.workspaceId) + ) { + throw new OrchestrationError('not_found', 'File not found') + } + + await authorizeWorkspaceOperation(principal, fileOperations.rename, { + workspaceId: canonical.workspaceId, + workspaceOrganizationId: canonical.workspaceOrganizationId, + allowPersonalApiKeys: canonical.allowPersonalApiKeys, + fileId: canonical.fileId, + }) + + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: canonical.billedAccountUserId, + }) + const file = await renameStoredWorkspaceFile(canonical.workspaceId, canonical.fileId, input.name) + const workspaceId = canonical.workspaceId + + logger.info('Renamed workspace file', { + workspaceId, + fileId: input.fileId, + name: file.name, + principalKind: principal.kind, + }) + recordAudit({ + workspaceId, + actorId: attribution.attributedUserId, + action: AuditAction.FILE_UPDATED, + resourceType: AuditResourceType.FILE, + resourceId: file.id, + resourceName: file.name, + description: `Renamed file to "${file.name}"`, + metadata: { + operation: fileOperations.rename.id, + actor: attribution.actor, + }, + request, + }) + await notifyWorkspaceFilesChanged(workspaceId) + return { file } +} + +export const renameWorkspaceFile = { + operation: fileOperations.rename, + execute: executeRenameWorkspaceFile, +} as const diff --git a/packages/auth/package.json b/packages/auth/package.json index b6b88fb8fc8..2a6bbed626b 100644 --- a/packages/auth/package.json +++ b/packages/auth/package.json @@ -10,6 +10,10 @@ "node": ">=20.0.0" }, "exports": { + "./principal": { + "types": "./src/principal.ts", + "default": "./src/principal.ts" + }, "./verify": { "types": "./src/verify.ts", "default": "./src/verify.ts" diff --git a/packages/auth/src/principal.ts b/packages/auth/src/principal.ts new file mode 100644 index 00000000000..4118941b3ec --- /dev/null +++ b/packages/auth/src/principal.ts @@ -0,0 +1,103 @@ +export type Principal = + | SessionPrincipal + | PersonalApiKeyPrincipal + | WorkspaceApiKeyPrincipal + | DelegatedPrincipal + +export interface SessionPrincipal { + kind: 'session' + userId: string + sessionId: string +} + +export interface PersonalApiKeyPrincipal { + kind: 'personal_api_key' + userId: string + keyId: string +} + +export interface WorkspaceApiKeyPrincipal { + kind: 'workspace_api_key' + workspaceId: string + keyId: string +} + +export interface DelegatedPrincipal { + kind: 'delegated' + serviceId: 'copilot' | 'executor' | 'realtime' + subjectUserId: string + workspaceId: string + delegationId: string + audience: string + issuedAt: Date + expiresAt: Date + resourceScope?: { + fileId?: string + chatId?: string + executionId?: string + } +} + +export type PrincipalActor = + | { kind: 'session'; userId: string } + | { kind: 'personal_api_key'; keyId: string; userId: string } + | { kind: 'workspace_api_key'; keyId: string; workspaceId: string } + | { + kind: 'delegated' + serviceId: DelegatedPrincipal['serviceId'] + subjectUserId: string + delegationId: string + } + +export interface PrincipalAttribution { + actor: PrincipalActor + attributedUserId: string +} + +export interface PrincipalAttributionContext { + workspaceBillingOwnerUserId?: string +} + +export function toPrincipalActor(principal: Principal): PrincipalActor { + switch (principal.kind) { + case 'session': + return { kind: principal.kind, userId: principal.userId } + case 'personal_api_key': + return { kind: principal.kind, keyId: principal.keyId, userId: principal.userId } + case 'workspace_api_key': + return { + kind: principal.kind, + keyId: principal.keyId, + workspaceId: principal.workspaceId, + } + case 'delegated': + return { + kind: principal.kind, + serviceId: principal.serviceId, + subjectUserId: principal.subjectUserId, + delegationId: principal.delegationId, + } + } +} + +export function resolvePrincipalAttribution( + principal: Principal, + context: PrincipalAttributionContext = {} +): PrincipalAttribution { + const actor = toPrincipalActor(principal) + + switch (actor.kind) { + case 'session': + case 'personal_api_key': + return { actor, attributedUserId: actor.userId } + case 'workspace_api_key': { + const attributedUserId = context.workspaceBillingOwnerUserId + if (!attributedUserId) { + throw new Error('Workspace API key attribution requires a workspace billing owner') + } + return { actor, attributedUserId } + } + case 'delegated': + return { actor, attributedUserId: actor.subjectUserId } + } +} diff --git a/packages/platform-authz/src/workspace.ts b/packages/platform-authz/src/workspace.ts index 0e377bd1e35..63d6671aab3 100644 --- a/packages/platform-authz/src/workspace.ts +++ b/packages/platform-authz/src/workspace.ts @@ -25,9 +25,10 @@ export async function resolveEffectiveWorkspacePermission( userId: string, workspaceId: string, workspaceOrganizationId: string | null, - executor: Pick = db + executor: Pick = db, + options?: { forUpdate?: boolean } ): Promise { - const [permissionRow] = await executor + const permissionQuery = executor .select({ permissionType: permissions.permissionType }) .from(permissions) .where( @@ -37,16 +38,20 @@ export async function resolveEffectiveWorkspacePermission( eq(permissions.entityId, workspaceId) ) ) - .limit(1) + const [permissionRow] = options?.forUpdate + ? await permissionQuery.for('update').limit(1) + : await permissionQuery.limit(1) const explicit = (permissionRow?.permissionType as PermissionType | undefined) ?? null if (workspaceOrganizationId && explicit !== 'admin') { - const [memberRow] = await executor + const memberQuery = executor .select({ role: member.role }) .from(member) .where(and(eq(member.userId, userId), eq(member.organizationId, workspaceOrganizationId))) - .limit(1) + const [memberRow] = options?.forUpdate + ? await memberQuery.for('update').limit(1) + : await memberQuery.limit(1) if (isOrgAdminRole(memberRow?.role)) { return 'admin' } diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 156960f1a5a..e1e2c7b5304 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -142,7 +142,6 @@ const RAW_JSON_BASELINE_ROUTES = new Set([ 'apps/sim/app/api/tools/file/manage/route.ts', 'apps/sim/app/api/workspaces/invitations/batch/route.ts', 'apps/sim/app/api/workspaces/[id]/route.ts', - 'apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts', 'apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts', ]) @@ -150,6 +149,10 @@ const CONTRACT_IMPORT_PATTERN = /\bfrom\s+['"]@\/lib\/api\/contracts(?:\/[^'"]*) const PUBLIC_API_ROUTE_HANDLER_IMPORT_PATTERN = /\bimport\s*\{[^}]*\bwithPublicApiRouteHandler\b[^}]*\}\s*from\s*['"]@\/app\/api\/public-api-route-handler['"]/ const PUBLIC_API_ROUTE_HANDLER_USAGE_PATTERN = /\bwithPublicApiRouteHandler\s*\(/ +const DECLARATIVE_ROUTE_BUILDER_IMPORT_PATTERN = + /\bimport\s*\{[^}]*(?:\bdefineInternalJsonRoute\b|\bdefineV2JsonRoute\b)[^}]*\}\s*from\s*['"]@\/lib\/api\/server\/routes['"]/ +const DECLARATIVE_ROUTE_BUILDER_USAGE_PATTERN = + /\b(?:defineInternalJsonRoute|defineV2JsonRoute)\s*\(/ const SERVER_VALIDATION_IMPORT_PATTERN = /\bfrom\s+['"]@\/lib\/api\/server(?:\/validation)?['"]/ const SCHEMA_PARSE_PATTERN = /\b\w+Schema\.(?:safeParse|parse)\(/ const CONTRACT_SERVER_HELPER_PATTERN = /\bparseToolRequest\(/ @@ -727,6 +730,13 @@ function hasZodUsage(relativePath: string, content: string): boolean { ) { return true } + if ( + CONTRACT_IMPORT_PATTERN.test(content) && + DECLARATIVE_ROUTE_BUILDER_IMPORT_PATTERN.test(content) && + DECLARATIVE_ROUTE_BUILDER_USAGE_PATTERN.test(content) + ) { + return true + } if ( CONTRACT_IMPORT_PATTERN.test(content) && (SCHEMA_PARSE_PATTERN.test(content) || CONTRACT_MAP_PARSE_PATTERN.test(content)) From 047daa67903382752c91537a498e9d26dbb88d93 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 7 Aug 2026 19:12:39 -0700 Subject: [PATCH 2/9] chore(skills): add file operation migration guide --- .../skills/migrate-file-operation/SKILL.md | 214 ++++++++++++++++++ .../migrate-file-operation/agents/openai.yaml | 4 + .claude/commands/migrate-file-operation.md | 213 +++++++++++++++++ .cursor/commands/migrate-file-operation.md | 209 +++++++++++++++++ 4 files changed, 640 insertions(+) create mode 100644 .agents/skills/migrate-file-operation/SKILL.md create mode 100644 .agents/skills/migrate-file-operation/agents/openai.yaml create mode 100644 .claude/commands/migrate-file-operation.md create mode 100644 .cursor/commands/migrate-file-operation.md diff --git a/.agents/skills/migrate-file-operation/SKILL.md b/.agents/skills/migrate-file-operation/SKILL.md new file mode 100644 index 00000000000..a9a2be31ddd --- /dev/null +++ b/.agents/skills/migrate-file-operation/SKILL.md @@ -0,0 +1,214 @@ +--- +name: migrate-file-operation +description: Migrate one existing Sim workspace-file operation to the shared Principal, operation policy, application use-case, internal route, v2 route, and Copilot delegation architecture. Use when consolidating file auth or moving a file read/write/delete/move operation away from route-local authorization while preserving each surface's contracts, errors, analytics, and compatibility behavior. Do not use for v1 routes; treat uploads, large bodies, bulk recursion, and binary streaming as special cases. +--- + +# Migrate File Operation + +Migrate one bounded semantic operation at a time. Share authorization and business behavior without forcing internal, v2, and Copilot surfaces to share authentication or response shapes. + +## Start from the golden slice + +Read these files before editing: + +- `packages/auth/src/principal.ts` +- `apps/sim/lib/workspace-files/application/operations.ts` +- `apps/sim/lib/workspace-files/application/authorization.ts` +- `apps/sim/lib/workspace-files/application/rename-workspace-file.ts` +- `apps/sim/lib/api/server/routes/internal-json-route.ts` +- `apps/sim/lib/api/server/routes/v2-json-route.ts` +- `apps/sim/lib/copilot/auth/file-delegation.ts` +- The internal, v2, manager, contract, Copilot, and test files for the requested operation + +Fail immediately if the shared principal or route foundation is absent. Do not recreate a parallel framework. + +## Bound the slice + +Before editing, inventory every current entry point for the requested behavior: + +- HTTP method and route module +- Internal and v2 contracts and response envelopes +- Manager/orchestration call chain +- Copilot tools, aliases, resume paths, and polymorphic branches +- Permission checks and workspace assertions +- Audit, notification, product analytics, and other side effects +- Error/status behavior and resource-concealment behavior +- Rate-bucket identity, endpoint label, response headers, and rollout-gate behavior + +Record each entry point as `migrate`, `defer`, or `non-goal` in the handoff. Do not modify v1. Do not migrate adjacent methods merely because they share a route module. + +Preserve existing behavior unless the assigned migration explicitly changes it. If the target architecture conflicts with a current contract or test—such as 403 versus concealed 404, a legacy rate-bucket label, or a surface-specific event—stop and report the decision instead of silently choosing one. + +Use one semantic operation for all migrated surfaces. Never create internal-, v2-, or Copilot-specific copies of the same operation. If two paths have materially different transactional semantics, keep separate use cases and explain why. + +## Preserve the boundary + +Keep responsibilities in these layers: + +1. Route/tool adapter: authenticate, construct a `Principal`, select the operation and rate policy, parse the surface contract, call the use case, and render the surface response. +2. Application use case: load the canonical resource, compare any asserted workspace, authorize the operation, run the business operation, record semantic audit, and trigger shared domain notifications. +3. Manager/repository: execute storage and database reads/writes. Accept canonical IDs and workspace scope, not credentials or principals. +4. Presenter: return the contract success body only. Never return `NextResponse`, rate metadata, or errors. + +Use this order for ordinary JSON routes: + +```text +IP abuse limit, when public + -> authenticate + -> build Principal + -> operation rate limit + -> parse contract + -> application use case + -> canonical load by resource ID + -> asserted-workspace concealment + -> current authorization + -> manager mutation/read + -> audit and shared notification + -> surface presenter +``` + +Never query API keys or sessions from the application use case. Authentication freshness belongs to the adapter. Never implement raw database transactions in the application use case when a manager primitive exists; add a narrow throwing manager primitive when needed. + +Do not add API-key rechecks, row locks, or transaction-wide authorization solely for a theoretical race. Use the authenticated principal and canonical resource snapshot unless the business operation itself requires atomic multi-row behavior or the task explicitly asks for stronger race semantics. + +## Define the operation once + +Add exactly one entry to `fileOperations` with a stable semantic ID, minimum workspace role, and explicit workspace-key policy. + +```ts +operationName: defineWorkspaceOperation({ + id: 'files.operation_name', + minimumRole: 'read' satisfies PermissionType, + workspaceApiKey: 'allow', +}) +``` + +Choose policy from behavior, not from the calling surface: + +- Reads normally require `read`. +- Content or metadata mutations normally require `write`. +- Workspace API keys have a fixed `write` ceiling and cannot satisfy admin operations. +- Use `deny` when a workspace key must not perform the operation. + +The route declaration and use case must expose the same literal operation. Let route-definition initialization fail on mismatches. + +## Implement the application use case + +Expose an object with `operation` and `execute` matching `OperationUseCase`. + +```ts +export const operationWorkspaceFile = { + operation: fileOperations.operationName, + execute: executeOperationWorkspaceFile, +} as const +``` + +Inside `execute`: + +1. Load the active file by `fileId` without trusting a caller-supplied workspace. +2. Derive the canonical workspace and policy context from that record. +3. Treat an asserted-workspace mismatch like absence when the surface requires concealment. +4. Call `authorizeWorkspaceOperation` with the canonical context and shared operation. +5. Resolve `PrincipalAttribution` only when a required legacy user field or audit actor needs it. +6. Call a workspace-predicated, throwing manager primitive. +7. Record semantic audit once and send shared realtime/domain notifications once. +8. Return a surface-neutral domain result. + +Propagate infrastructure failures. Use existing typed orchestration errors for expected not-found, forbidden, validation, and conflict outcomes. Do not turn database failures into not-found or authorization failures, and never add fallback behavior. + +Inspect legacy orchestration before reusing it. If it already records audit, sends notifications, or emits analytics, call a lower-level manager primitive or remove the duplicated responsibility for migrated callers. Never wrap a side-effecting orchestration helper and then emit the same side effects again. + +### Principal and attribution rules + +- Session and personal-key principals authorize through the current human workspace permission. +- Personal API keys must also respect the workspace's `allowPersonalApiKeys` setting. +- Workspace-key principals authorize as the workspace itself under the operation's explicit allow/deny policy and write ceiling. Do not use creator membership for authorization. +- Delegated Copilot principals re-check the current subject user's permission and their workspace, audience, expiry, and resource scope. +- Use the current billing owner only where a legacy required user ID or billing attribution requires one. Never use that owner for authorization, rate-limit identity, delegated identity, or human product analytics. +- Preserve `PrincipalActor` metadata in audit records so a workspace key is not represented as a human actor. + +Audit is shared semantic behavior and belongs in the application layer. Product analytics are surface behavior: preserve an existing internal `captureServerEvent` with the internal route's optional `onSuccess`; do not move it into the shared use case or manufacture a human distinct ID for workspace keys or Copilot. + +## Adapt each surface independently + +### Internal JSON + +Use `defineInternalJsonRoute` and explicitly declare: + +- `contract` +- `auth: internalSessionAuth` +- `operation` +- `rateLimit`, including a reason for `none` +- `errorPolicy` +- `mapInput` +- `useCase` +- optional `onSuccess` only to preserve surface-specific behavior +- `present` + +Keep the existing internal success and error envelope. The builder owns `withRouteHandler`, authentication-before-parse ordering, response validation, and `NextResponse` creation. + +### V2 JSON + +Use `defineV2JsonRoute` with `v2ApiKeyAuth`, the explicit operation rate policy, the v2 error policy, and a v2-only presenter. Keep v2 schemas in `apps/sim/lib/api/contracts/v2/files.ts` and internal schemas in the workspace-file contract family. Never import Zod or declare boundary schemas in a route. + +For resource-ID operations, the target v2 policy normally conceals absence, cross-workspace assertions, and unauthorized access. If the current contract or tests expose 403, flag that compatibility decision before changing it. Keep v1 middleware and v1 routes untouched. + +### Copilot + +Resolve path/name aliases only within the trusted execution workspace, then call the same application use case with `createCopilotFilePrincipal`. Never construct authoritative workspace, subject, audience, or resource scope from model-provided tool arguments. Preserve each tool's existing result shape and resumable aliases. + +Map typed failures to a safe Copilot message. Unknown failures must become a generic retryable/system message; never return raw database or storage error messages to the model. + +## Handle special operations explicitly + +Do not force these through the ordinary JSON pipeline: + +- Upload/multipart: use a lifecycle-specific design with immutable credential binding, fresh authorization on control legs and finalization, current billing attribution at durable registration, and idempotent completion. Do not add a database migration unless explicitly authorized; under the no-migration design, store a versioned server-authored `authBinding` in existing upload-session metadata, reject missing or invalid new-format bindings, and explicitly drain or version legacy in-flight sessions. +- Large JSON bodies: perform cheap authenticated admission before bounded body buffering. +- Binary/download/streaming: use a binary or streaming route builder and a typed response descriptor. +- Bulk/recursive operations: deduplicate and cap requested IDs, cap expanded rows/bytes, load every resource canonically, preserve workspace predicates, and decide atomic versus best-effort behavior explicitly. + +Stop and report the missing design instead of weakening limits, authorization, or error handling. + +## Test the complete behavior matrix + +Add focused tests proportional to the migrated surfaces: + +- Use case: session admin/writer/reader, personal key enabled/disabled, allowed/wrong-workspace workspace key, delegated current/stale/out-of-scope principal, asserted-workspace mismatch, not found, conflict, and infrastructure failure. +- Manager: canonical active lookup, workspace-predicated mutation, archived resources, and propagated database errors. +- Internal route: authentication before parsing, exact response contract, typed errors, and any preserved `onSuccess` event only after success. +- V2 route: personal and workspace keys, rollout/rate behavior, concealment, exact v2 envelope, and rate headers after charging. +- Copilot: trusted delegation, aliases/resume paths, permission re-check, safe errors, and unchanged tool response shape. +- Side effects: audit and shared notifications are invoked once per successful use-case execution and never on rejected operations. Do not claim exactly-once delivery across independent client retries without durable idempotency/outbox support. + +Run at minimum: + +```bash +bunx vitest run +bunx biome check +bunx turbo run type-check --filter=sim --filter=@sim/auth +bun run check:api-validation:strict +git diff --check +``` + +Do not claim a check passed unless it was run successfully. + +## Work safely in parallel + +- Assign one agent a non-overlapping route-module and caller set. Two methods in one route file are not safe parallel assignments. +- Start every agent from the same foundation commit in its own branch/worktree when possible. +- Treat `operations.ts`, contract family files, and shared route builders as merge hotspots. Make only the minimal operation-specific edit. +- Do not refactor shared principal, authorization, rate limiting, or error foundations unless the task explicitly assigns that ownership. +- Preserve unrelated working-tree changes. Never stage architecture docs, lockfile drift, or another agent's edits. +- Do not commit, push, or open a PR unless the task explicitly asks for it. + +## Hand off + +Report: + +1. The semantic operation and policy. +2. Every migrated, deferred, and non-goal entry point. +3. Behavior preserved per surface, including analytics and response differences. +4. Files changed and any shared merge hotspots. +5. Tests and checks run with results. +6. Remaining risks or blockers. Fail fast if any required invariant could not be implemented. diff --git a/.agents/skills/migrate-file-operation/agents/openai.yaml b/.agents/skills/migrate-file-operation/agents/openai.yaml new file mode 100644 index 00000000000..d668a08e7be --- /dev/null +++ b/.agents/skills/migrate-file-operation/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Migrate File Operation" + short_description: "Migrate one file operation across shared auth surfaces" + default_prompt: "Use $migrate-file-operation to migrate one file operation across internal, v2, and Copilot surfaces." diff --git a/.claude/commands/migrate-file-operation.md b/.claude/commands/migrate-file-operation.md new file mode 100644 index 00000000000..bf5ad2dc1b1 --- /dev/null +++ b/.claude/commands/migrate-file-operation.md @@ -0,0 +1,213 @@ +--- +description: Migrate one existing Sim workspace-file operation to the shared Principal, operation policy, application use-case, internal route, v2 route, and Copilot delegation architecture. Use when consolidating file auth or moving a file read/write/delete/move operation away from route-local authorization while preserving each surface's contracts, errors, analytics, and compatibility behavior. Do not use for v1 routes; treat uploads, large bodies, bulk recursion, and binary streaming as special cases. +--- + +# Migrate File Operation + +Migrate one bounded semantic operation at a time. Share authorization and business behavior without forcing internal, v2, and Copilot surfaces to share authentication or response shapes. + +## Start from the golden slice + +Read these files before editing: + +- `packages/auth/src/principal.ts` +- `apps/sim/lib/workspace-files/application/operations.ts` +- `apps/sim/lib/workspace-files/application/authorization.ts` +- `apps/sim/lib/workspace-files/application/rename-workspace-file.ts` +- `apps/sim/lib/api/server/routes/internal-json-route.ts` +- `apps/sim/lib/api/server/routes/v2-json-route.ts` +- `apps/sim/lib/copilot/auth/file-delegation.ts` +- The internal, v2, manager, contract, Copilot, and test files for the requested operation + +Fail immediately if the shared principal or route foundation is absent. Do not recreate a parallel framework. + +## Bound the slice + +Before editing, inventory every current entry point for the requested behavior: + +- HTTP method and route module +- Internal and v2 contracts and response envelopes +- Manager/orchestration call chain +- Copilot tools, aliases, resume paths, and polymorphic branches +- Permission checks and workspace assertions +- Audit, notification, product analytics, and other side effects +- Error/status behavior and resource-concealment behavior +- Rate-bucket identity, endpoint label, response headers, and rollout-gate behavior + +Record each entry point as `migrate`, `defer`, or `non-goal` in the handoff. Do not modify v1. Do not migrate adjacent methods merely because they share a route module. + +Preserve existing behavior unless the assigned migration explicitly changes it. If the target architecture conflicts with a current contract or test—such as 403 versus concealed 404, a legacy rate-bucket label, or a surface-specific event—stop and report the decision instead of silently choosing one. + +Use one semantic operation for all migrated surfaces. Never create internal-, v2-, or Copilot-specific copies of the same operation. If two paths have materially different transactional semantics, keep separate use cases and explain why. + +## Preserve the boundary + +Keep responsibilities in these layers: + +1. Route/tool adapter: authenticate, construct a `Principal`, select the operation and rate policy, parse the surface contract, call the use case, and render the surface response. +2. Application use case: load the canonical resource, compare any asserted workspace, authorize the operation, run the business operation, record semantic audit, and trigger shared domain notifications. +3. Manager/repository: execute storage and database reads/writes. Accept canonical IDs and workspace scope, not credentials or principals. +4. Presenter: return the contract success body only. Never return `NextResponse`, rate metadata, or errors. + +Use this order for ordinary JSON routes: + +```text +IP abuse limit, when public + -> authenticate + -> build Principal + -> operation rate limit + -> parse contract + -> application use case + -> canonical load by resource ID + -> asserted-workspace concealment + -> current authorization + -> manager mutation/read + -> audit and shared notification + -> surface presenter +``` + +Never query API keys or sessions from the application use case. Authentication freshness belongs to the adapter. Never implement raw database transactions in the application use case when a manager primitive exists; add a narrow throwing manager primitive when needed. + +Do not add API-key rechecks, row locks, or transaction-wide authorization solely for a theoretical race. Use the authenticated principal and canonical resource snapshot unless the business operation itself requires atomic multi-row behavior or the task explicitly asks for stronger race semantics. + +## Define the operation once + +Add exactly one entry to `fileOperations` with a stable semantic ID, minimum workspace role, and explicit workspace-key policy. + +```ts +operationName: defineWorkspaceOperation({ + id: 'files.operation_name', + minimumRole: 'read' satisfies PermissionType, + workspaceApiKey: 'allow', +}) +``` + +Choose policy from behavior, not from the calling surface: + +- Reads normally require `read`. +- Content or metadata mutations normally require `write`. +- Workspace API keys have a fixed `write` ceiling and cannot satisfy admin operations. +- Use `deny` when a workspace key must not perform the operation. + +The route declaration and use case must expose the same literal operation. Let route-definition initialization fail on mismatches. + +## Implement the application use case + +Expose an object with `operation` and `execute` matching `OperationUseCase`. + +```ts +export const operationWorkspaceFile = { + operation: fileOperations.operationName, + execute: executeOperationWorkspaceFile, +} as const +``` + +Inside `execute`: + +1. Load the active file by `fileId` without trusting a caller-supplied workspace. +2. Derive the canonical workspace and policy context from that record. +3. Treat an asserted-workspace mismatch like absence when the surface requires concealment. +4. Call `authorizeWorkspaceOperation` with the canonical context and shared operation. +5. Resolve `PrincipalAttribution` only when a required legacy user field or audit actor needs it. +6. Call a workspace-predicated, throwing manager primitive. +7. Record semantic audit once and send shared realtime/domain notifications once. +8. Return a surface-neutral domain result. + +Propagate infrastructure failures. Use existing typed orchestration errors for expected not-found, forbidden, validation, and conflict outcomes. Do not turn database failures into not-found or authorization failures, and never add fallback behavior. + +Inspect legacy orchestration before reusing it. If it already records audit, sends notifications, or emits analytics, call a lower-level manager primitive or remove the duplicated responsibility for migrated callers. Never wrap a side-effecting orchestration helper and then emit the same side effects again. + +### Principal and attribution rules + +- Session and personal-key principals authorize through the current human workspace permission. +- Personal API keys must also respect the workspace's `allowPersonalApiKeys` setting. +- Workspace-key principals authorize as the workspace itself under the operation's explicit allow/deny policy and write ceiling. Do not use creator membership for authorization. +- Delegated Copilot principals re-check the current subject user's permission and their workspace, audience, expiry, and resource scope. +- Use the current billing owner only where a legacy required user ID or billing attribution requires one. Never use that owner for authorization, rate-limit identity, delegated identity, or human product analytics. +- Preserve `PrincipalActor` metadata in audit records so a workspace key is not represented as a human actor. + +Audit is shared semantic behavior and belongs in the application layer. Product analytics are surface behavior: preserve an existing internal `captureServerEvent` with the internal route's optional `onSuccess`; do not move it into the shared use case or manufacture a human distinct ID for workspace keys or Copilot. + +## Adapt each surface independently + +### Internal JSON + +Use `defineInternalJsonRoute` and explicitly declare: + +- `contract` +- `auth: internalSessionAuth` +- `operation` +- `rateLimit`, including a reason for `none` +- `errorPolicy` +- `mapInput` +- `useCase` +- optional `onSuccess` only to preserve surface-specific behavior +- `present` + +Keep the existing internal success and error envelope. The builder owns `withRouteHandler`, authentication-before-parse ordering, response validation, and `NextResponse` creation. + +### V2 JSON + +Use `defineV2JsonRoute` with `v2ApiKeyAuth`, the explicit operation rate policy, the v2 error policy, and a v2-only presenter. Keep v2 schemas in `apps/sim/lib/api/contracts/v2/files.ts` and internal schemas in the workspace-file contract family. Never import Zod or declare boundary schemas in a route. + +For resource-ID operations, the target v2 policy normally conceals absence, cross-workspace assertions, and unauthorized access. If the current contract or tests expose 403, flag that compatibility decision before changing it. Keep v1 middleware and v1 routes untouched. + +### Copilot + +Resolve path/name aliases only within the trusted execution workspace, then call the same application use case with `createCopilotFilePrincipal`. Never construct authoritative workspace, subject, audience, or resource scope from model-provided tool arguments. Preserve each tool's existing result shape and resumable aliases. + +Map typed failures to a safe Copilot message. Unknown failures must become a generic retryable/system message; never return raw database or storage error messages to the model. + +## Handle special operations explicitly + +Do not force these through the ordinary JSON pipeline: + +- Upload/multipart: use a lifecycle-specific design with immutable credential binding, fresh authorization on control legs and finalization, current billing attribution at durable registration, and idempotent completion. Do not add a database migration unless explicitly authorized; under the no-migration design, store a versioned server-authored `authBinding` in existing upload-session metadata, reject missing or invalid new-format bindings, and explicitly drain or version legacy in-flight sessions. +- Large JSON bodies: perform cheap authenticated admission before bounded body buffering. +- Binary/download/streaming: use a binary or streaming route builder and a typed response descriptor. +- Bulk/recursive operations: deduplicate and cap requested IDs, cap expanded rows/bytes, load every resource canonically, preserve workspace predicates, and decide atomic versus best-effort behavior explicitly. + +Stop and report the missing design instead of weakening limits, authorization, or error handling. + +## Test the complete behavior matrix + +Add focused tests proportional to the migrated surfaces: + +- Use case: session admin/writer/reader, personal key enabled/disabled, allowed/wrong-workspace workspace key, delegated current/stale/out-of-scope principal, asserted-workspace mismatch, not found, conflict, and infrastructure failure. +- Manager: canonical active lookup, workspace-predicated mutation, archived resources, and propagated database errors. +- Internal route: authentication before parsing, exact response contract, typed errors, and any preserved `onSuccess` event only after success. +- V2 route: personal and workspace keys, rollout/rate behavior, concealment, exact v2 envelope, and rate headers after charging. +- Copilot: trusted delegation, aliases/resume paths, permission re-check, safe errors, and unchanged tool response shape. +- Side effects: audit and shared notifications are invoked once per successful use-case execution and never on rejected operations. Do not claim exactly-once delivery across independent client retries without durable idempotency/outbox support. + +Run at minimum: + +```bash +bunx vitest run +bunx biome check +bunx turbo run type-check --filter=sim --filter=@sim/auth +bun run check:api-validation:strict +git diff --check +``` + +Do not claim a check passed unless it was run successfully. + +## Work safely in parallel + +- Assign one agent a non-overlapping route-module and caller set. Two methods in one route file are not safe parallel assignments. +- Start every agent from the same foundation commit in its own branch/worktree when possible. +- Treat `operations.ts`, contract family files, and shared route builders as merge hotspots. Make only the minimal operation-specific edit. +- Do not refactor shared principal, authorization, rate limiting, or error foundations unless the task explicitly assigns that ownership. +- Preserve unrelated working-tree changes. Never stage architecture docs, lockfile drift, or another agent's edits. +- Do not commit, push, or open a PR unless the task explicitly asks for it. + +## Hand off + +Report: + +1. The semantic operation and policy. +2. Every migrated, deferred, and non-goal entry point. +3. Behavior preserved per surface, including analytics and response differences. +4. Files changed and any shared merge hotspots. +5. Tests and checks run with results. +6. Remaining risks or blockers. Fail fast if any required invariant could not be implemented. diff --git a/.cursor/commands/migrate-file-operation.md b/.cursor/commands/migrate-file-operation.md new file mode 100644 index 00000000000..822bd3ba2d7 --- /dev/null +++ b/.cursor/commands/migrate-file-operation.md @@ -0,0 +1,209 @@ +# Migrate File Operation + +Migrate one bounded semantic operation at a time. Share authorization and business behavior without forcing internal, v2, and Copilot surfaces to share authentication or response shapes. + +## Start from the golden slice + +Read these files before editing: + +- `packages/auth/src/principal.ts` +- `apps/sim/lib/workspace-files/application/operations.ts` +- `apps/sim/lib/workspace-files/application/authorization.ts` +- `apps/sim/lib/workspace-files/application/rename-workspace-file.ts` +- `apps/sim/lib/api/server/routes/internal-json-route.ts` +- `apps/sim/lib/api/server/routes/v2-json-route.ts` +- `apps/sim/lib/copilot/auth/file-delegation.ts` +- The internal, v2, manager, contract, Copilot, and test files for the requested operation + +Fail immediately if the shared principal or route foundation is absent. Do not recreate a parallel framework. + +## Bound the slice + +Before editing, inventory every current entry point for the requested behavior: + +- HTTP method and route module +- Internal and v2 contracts and response envelopes +- Manager/orchestration call chain +- Copilot tools, aliases, resume paths, and polymorphic branches +- Permission checks and workspace assertions +- Audit, notification, product analytics, and other side effects +- Error/status behavior and resource-concealment behavior +- Rate-bucket identity, endpoint label, response headers, and rollout-gate behavior + +Record each entry point as `migrate`, `defer`, or `non-goal` in the handoff. Do not modify v1. Do not migrate adjacent methods merely because they share a route module. + +Preserve existing behavior unless the assigned migration explicitly changes it. If the target architecture conflicts with a current contract or test—such as 403 versus concealed 404, a legacy rate-bucket label, or a surface-specific event—stop and report the decision instead of silently choosing one. + +Use one semantic operation for all migrated surfaces. Never create internal-, v2-, or Copilot-specific copies of the same operation. If two paths have materially different transactional semantics, keep separate use cases and explain why. + +## Preserve the boundary + +Keep responsibilities in these layers: + +1. Route/tool adapter: authenticate, construct a `Principal`, select the operation and rate policy, parse the surface contract, call the use case, and render the surface response. +2. Application use case: load the canonical resource, compare any asserted workspace, authorize the operation, run the business operation, record semantic audit, and trigger shared domain notifications. +3. Manager/repository: execute storage and database reads/writes. Accept canonical IDs and workspace scope, not credentials or principals. +4. Presenter: return the contract success body only. Never return `NextResponse`, rate metadata, or errors. + +Use this order for ordinary JSON routes: + +```text +IP abuse limit, when public + -> authenticate + -> build Principal + -> operation rate limit + -> parse contract + -> application use case + -> canonical load by resource ID + -> asserted-workspace concealment + -> current authorization + -> manager mutation/read + -> audit and shared notification + -> surface presenter +``` + +Never query API keys or sessions from the application use case. Authentication freshness belongs to the adapter. Never implement raw database transactions in the application use case when a manager primitive exists; add a narrow throwing manager primitive when needed. + +Do not add API-key rechecks, row locks, or transaction-wide authorization solely for a theoretical race. Use the authenticated principal and canonical resource snapshot unless the business operation itself requires atomic multi-row behavior or the task explicitly asks for stronger race semantics. + +## Define the operation once + +Add exactly one entry to `fileOperations` with a stable semantic ID, minimum workspace role, and explicit workspace-key policy. + +```ts +operationName: defineWorkspaceOperation({ + id: 'files.operation_name', + minimumRole: 'read' satisfies PermissionType, + workspaceApiKey: 'allow', +}) +``` + +Choose policy from behavior, not from the calling surface: + +- Reads normally require `read`. +- Content or metadata mutations normally require `write`. +- Workspace API keys have a fixed `write` ceiling and cannot satisfy admin operations. +- Use `deny` when a workspace key must not perform the operation. + +The route declaration and use case must expose the same literal operation. Let route-definition initialization fail on mismatches. + +## Implement the application use case + +Expose an object with `operation` and `execute` matching `OperationUseCase`. + +```ts +export const operationWorkspaceFile = { + operation: fileOperations.operationName, + execute: executeOperationWorkspaceFile, +} as const +``` + +Inside `execute`: + +1. Load the active file by `fileId` without trusting a caller-supplied workspace. +2. Derive the canonical workspace and policy context from that record. +3. Treat an asserted-workspace mismatch like absence when the surface requires concealment. +4. Call `authorizeWorkspaceOperation` with the canonical context and shared operation. +5. Resolve `PrincipalAttribution` only when a required legacy user field or audit actor needs it. +6. Call a workspace-predicated, throwing manager primitive. +7. Record semantic audit once and send shared realtime/domain notifications once. +8. Return a surface-neutral domain result. + +Propagate infrastructure failures. Use existing typed orchestration errors for expected not-found, forbidden, validation, and conflict outcomes. Do not turn database failures into not-found or authorization failures, and never add fallback behavior. + +Inspect legacy orchestration before reusing it. If it already records audit, sends notifications, or emits analytics, call a lower-level manager primitive or remove the duplicated responsibility for migrated callers. Never wrap a side-effecting orchestration helper and then emit the same side effects again. + +### Principal and attribution rules + +- Session and personal-key principals authorize through the current human workspace permission. +- Personal API keys must also respect the workspace's `allowPersonalApiKeys` setting. +- Workspace-key principals authorize as the workspace itself under the operation's explicit allow/deny policy and write ceiling. Do not use creator membership for authorization. +- Delegated Copilot principals re-check the current subject user's permission and their workspace, audience, expiry, and resource scope. +- Use the current billing owner only where a legacy required user ID or billing attribution requires one. Never use that owner for authorization, rate-limit identity, delegated identity, or human product analytics. +- Preserve `PrincipalActor` metadata in audit records so a workspace key is not represented as a human actor. + +Audit is shared semantic behavior and belongs in the application layer. Product analytics are surface behavior: preserve an existing internal `captureServerEvent` with the internal route's optional `onSuccess`; do not move it into the shared use case or manufacture a human distinct ID for workspace keys or Copilot. + +## Adapt each surface independently + +### Internal JSON + +Use `defineInternalJsonRoute` and explicitly declare: + +- `contract` +- `auth: internalSessionAuth` +- `operation` +- `rateLimit`, including a reason for `none` +- `errorPolicy` +- `mapInput` +- `useCase` +- optional `onSuccess` only to preserve surface-specific behavior +- `present` + +Keep the existing internal success and error envelope. The builder owns `withRouteHandler`, authentication-before-parse ordering, response validation, and `NextResponse` creation. + +### V2 JSON + +Use `defineV2JsonRoute` with `v2ApiKeyAuth`, the explicit operation rate policy, the v2 error policy, and a v2-only presenter. Keep v2 schemas in `apps/sim/lib/api/contracts/v2/files.ts` and internal schemas in the workspace-file contract family. Never import Zod or declare boundary schemas in a route. + +For resource-ID operations, the target v2 policy normally conceals absence, cross-workspace assertions, and unauthorized access. If the current contract or tests expose 403, flag that compatibility decision before changing it. Keep v1 middleware and v1 routes untouched. + +### Copilot + +Resolve path/name aliases only within the trusted execution workspace, then call the same application use case with `createCopilotFilePrincipal`. Never construct authoritative workspace, subject, audience, or resource scope from model-provided tool arguments. Preserve each tool's existing result shape and resumable aliases. + +Map typed failures to a safe Copilot message. Unknown failures must become a generic retryable/system message; never return raw database or storage error messages to the model. + +## Handle special operations explicitly + +Do not force these through the ordinary JSON pipeline: + +- Upload/multipart: use a lifecycle-specific design with immutable credential binding, fresh authorization on control legs and finalization, current billing attribution at durable registration, and idempotent completion. Do not add a database migration unless explicitly authorized; under the no-migration design, store a versioned server-authored `authBinding` in existing upload-session metadata, reject missing or invalid new-format bindings, and explicitly drain or version legacy in-flight sessions. +- Large JSON bodies: perform cheap authenticated admission before bounded body buffering. +- Binary/download/streaming: use a binary or streaming route builder and a typed response descriptor. +- Bulk/recursive operations: deduplicate and cap requested IDs, cap expanded rows/bytes, load every resource canonically, preserve workspace predicates, and decide atomic versus best-effort behavior explicitly. + +Stop and report the missing design instead of weakening limits, authorization, or error handling. + +## Test the complete behavior matrix + +Add focused tests proportional to the migrated surfaces: + +- Use case: session admin/writer/reader, personal key enabled/disabled, allowed/wrong-workspace workspace key, delegated current/stale/out-of-scope principal, asserted-workspace mismatch, not found, conflict, and infrastructure failure. +- Manager: canonical active lookup, workspace-predicated mutation, archived resources, and propagated database errors. +- Internal route: authentication before parsing, exact response contract, typed errors, and any preserved `onSuccess` event only after success. +- V2 route: personal and workspace keys, rollout/rate behavior, concealment, exact v2 envelope, and rate headers after charging. +- Copilot: trusted delegation, aliases/resume paths, permission re-check, safe errors, and unchanged tool response shape. +- Side effects: audit and shared notifications are invoked once per successful use-case execution and never on rejected operations. Do not claim exactly-once delivery across independent client retries without durable idempotency/outbox support. + +Run at minimum: + +```bash +bunx vitest run +bunx biome check +bunx turbo run type-check --filter=sim --filter=@sim/auth +bun run check:api-validation:strict +git diff --check +``` + +Do not claim a check passed unless it was run successfully. + +## Work safely in parallel + +- Assign one agent a non-overlapping route-module and caller set. Two methods in one route file are not safe parallel assignments. +- Start every agent from the same foundation commit in its own branch/worktree when possible. +- Treat `operations.ts`, contract family files, and shared route builders as merge hotspots. Make only the minimal operation-specific edit. +- Do not refactor shared principal, authorization, rate limiting, or error foundations unless the task explicitly assigns that ownership. +- Preserve unrelated working-tree changes. Never stage architecture docs, lockfile drift, or another agent's edits. +- Do not commit, push, or open a PR unless the task explicitly asks for it. + +## Hand off + +Report: + +1. The semantic operation and policy. +2. Every migrated, deferred, and non-goal entry point. +3. Behavior preserved per surface, including analytics and response differences. +4. Files changed and any shared merge hotspots. +5. Tests and checks run with results. +6. Remaining risks or blockers. Fail fast if any required invariant could not be implemented. From 07a86919900c46cb79cff51d6e97836e06443319 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 7 Aug 2026 21:15:59 -0700 Subject: [PATCH 3/9] improvement(files): consolidate file operation authorization --- .../uploads/[uploadId]/complete/route.ts | 23 +- .../files/uploads/[uploadId]/parts/route.ts | 25 +- .../app/api/files/uploads/[uploadId]/route.ts | 8 +- .../app/api/files/uploads/finalizers.test.ts | 59 +- apps/sim/app/api/files/uploads/finalizers.ts | 97 ++- apps/sim/app/api/files/uploads/purposes.ts | 90 ++- apps/sim/app/api/files/uploads/route.test.ts | 60 +- apps/sim/app/api/files/uploads/route.ts | 8 +- apps/sim/app/api/files/uploads/utils.ts | 13 +- apps/sim/app/api/files/utils.ts | 2 +- .../app/api/function/execute/route.test.ts | 18 +- apps/sim/app/api/function/execute/route.ts | 71 ++- .../app/api/tools/file/manage/route.test.ts | 122 +++- apps/sim/app/api/tools/file/manage/route.ts | 459 +++++++------ .../v2/files/[fileId]/content/route.test.ts | 281 ++++---- .../api/v2/files/[fileId]/content/route.ts | 71 +-- .../v2/files/[fileId]/metadata/route.test.ts | 119 ++-- .../api/v2/files/[fileId]/metadata/route.ts | 37 +- .../app/api/v2/files/[fileId]/route.test.ts | 451 ++++--------- apps/sim/app/api/v2/files/[fileId]/route.ts | 103 +-- .../api/v2/files/[fileId]/share/route.test.ts | 357 +++++------ .../app/api/v2/files/[fileId]/share/route.ts | 109 ++-- .../api/v2/files/bulk-delete/route.test.ts | 115 ++-- .../sim/app/api/v2/files/bulk-delete/route.ts | 49 +- .../app/api/v2/files/folders/route.test.ts | 223 +++++++ apps/sim/app/api/v2/files/folders/route.ts | 164 +++-- apps/sim/app/api/v2/files/move/route.test.ts | 132 ++-- apps/sim/app/api/v2/files/move/route.ts | 57 +- apps/sim/app/api/v2/files/route.test.ts | 601 +++++------------- apps/sim/app/api/v2/files/route.ts | 145 ++--- .../uploads/[uploadId]/complete/route.ts | 62 +- .../files/uploads/[uploadId]/parts/route.ts | 52 +- .../api/v2/files/uploads/[uploadId]/route.ts | 48 +- .../app/api/v2/files/uploads/route.test.ts | 250 +++----- apps/sim/app/api/v2/files/uploads/route.ts | 76 +-- apps/sim/app/api/v2/files/uploads/utils.ts | 18 + .../files/[fileId]/compiled-check/route.ts | 156 ++--- .../[id]/files/[fileId]/content/route.test.ts | 68 +- .../[id]/files/[fileId]/content/route.ts | 106 +-- .../[id]/files/[fileId]/csv-preview/route.ts | 71 +-- .../files/[fileId]/dimensions/route.test.ts | 125 ++-- .../[id]/files/[fileId]/dimensions/route.ts | 79 +-- .../[id]/files/[fileId]/download/route.ts | 140 ++-- .../[id]/files/[fileId]/restore/route.ts | 82 +-- .../workspaces/[id]/files/[fileId]/route.ts | 105 +-- .../[id]/files/[fileId]/share/route.test.ts | 256 ++++---- .../[id]/files/[fileId]/share/route.ts | 140 ++-- .../[id]/files/[fileId]/style/route.ts | 115 +--- .../[id]/files/bulk-archive/route.test.ts | 79 +++ .../[id]/files/bulk-archive/route.ts | 103 ++- .../files/download/route.integration.test.ts | 14 +- .../[id]/files/download/route.test.ts | 341 ++-------- .../workspaces/[id]/files/download/route.ts | 301 ++------- .../files/folders/[folderId]/restore/route.ts | 89 +-- .../files/folders/[folderId]/route.test.ts | 143 +++++ .../[id]/files/folders/[folderId]/route.ts | 167 ++--- .../[id]/files/folders/route.test.ts | 114 ++++ .../workspaces/[id]/files/folders/route.ts | 130 ++-- .../[id]/files/inline/route.test.ts | 82 +-- .../api/workspaces/[id]/files/inline/route.ts | 119 ++-- .../workspaces/[id]/files/move/route.test.ts | 86 +++ .../api/workspaces/[id]/files/move/route.ts | 120 ++-- .../api/workspaces/[id]/files/route.test.ts | 282 +++----- .../app/api/workspaces/[id]/files/route.ts | 226 ++----- apps/sim/lib/api/contracts/v2/files.ts | 3 +- .../api/contracts/workspace-file-folders.ts | 21 +- apps/sim/lib/api/contracts/workspace-files.ts | 17 + .../lib/api/server/routes/definition.test.ts | 42 +- apps/sim/lib/api/server/routes/definition.ts | 24 + apps/sim/lib/api/server/routes/index.ts | 4 + .../server/routes/internal-binary-route.ts | 123 ++++ .../server/routes/internal-json-route.test.ts | 73 +++ .../api/server/routes/internal-json-route.ts | 106 ++- apps/sim/lib/api/server/routes/types.ts | 26 + .../api/server/routes/v2-api-key-auth.test.ts | 22 + .../lib/api/server/routes/v2-api-key-auth.ts | 11 +- .../lib/api/server/routes/v2-binary-route.ts | 93 +++ .../lib/api/server/routes/v2-json-route.ts | 101 ++- apps/sim/lib/auth/principal.test.ts | 42 +- .../lib/copilot/auth/file-delegation.test.ts | 18 +- apps/sim/lib/copilot/auth/file-delegation.ts | 30 +- .../lib/copilot/chat/process-contents.test.ts | 12 + apps/sim/lib/copilot/chat/process-contents.ts | 46 +- .../sim/lib/copilot/chat/workspace-context.ts | 26 +- .../request/go/file-preview-adapter.ts | 27 +- .../sim/lib/copilot/request/go/stream.test.ts | 56 +- .../lib/copilot/request/tools/files.test.ts | 2 + apps/sim/lib/copilot/request/tools/files.ts | 4 +- .../handlers/deployment/custom-block.test.ts | 59 +- .../tools/handlers/deployment/custom-block.ts | 35 +- .../tools/handlers/function-execute.test.ts | 72 ++- .../tools/handlers/function-execute.ts | 61 +- .../tools/handlers/materialize-file.test.ts | 47 +- .../tools/handlers/materialize-file.ts | 40 +- .../lib/copilot/tools/handlers/resources.ts | 29 +- .../copilot/tools/handlers/vfs-mutate.test.ts | 161 +++-- .../lib/copilot/tools/handlers/vfs-mutate.ts | 242 ++++--- apps/sim/lib/copilot/tools/handlers/vfs.ts | 27 +- .../tools/handlers/workflow/queries.ts | 8 +- .../copilot/tools/server/files/create-file.ts | 92 +-- .../copilot/tools/server/files/doc-compile.ts | 49 +- .../copilot/tools/server/files/doc-recalc.ts | 3 + .../files/download-to-workspace-file.ts | 40 +- .../tools/server/files/edit-content.ts | 38 +- .../server/files/file-folder-application.ts | 53 ++ .../tools/server/files/file-folders.ts | 146 +++-- .../tools/server/files/file-preview.ts | 20 +- .../copilot/tools/server/files/rename-file.ts | 34 +- .../copilot/tools/server/files/share-file.ts | 187 +++--- .../tools/server/files/workspace-file.ts | 182 ++++-- .../tools/server/image/generate-image.ts | 57 +- .../server/knowledge/knowledge-base.test.ts | 15 +- .../tools/server/knowledge/knowledge-base.ts | 16 +- .../lib/copilot/tools/server/media/ffmpeg.ts | 34 +- .../tools/server/media/generate-audio.ts | 28 +- .../tools/server/media/generate-video.ts | 32 +- .../server/media/model-boundaries.test.ts | 16 +- .../tools/server/table/user-table.test.ts | 20 + .../copilot/tools/server/table/user-table.ts | 72 ++- apps/sim/lib/copilot/vfs/file-reader.ts | 20 +- .../lib/copilot/vfs/resource-writer.test.ts | 79 ++- apps/sim/lib/copilot/vfs/resource-writer.ts | 171 ++--- apps/sim/lib/copilot/vfs/workspace-vfs.ts | 162 +++-- apps/sim/lib/uploads/archive.test.ts | 113 ++-- apps/sim/lib/uploads/archive.ts | 67 +- .../workspace-file-folder-manager.ts | 159 ++++- .../workspace/workspace-file-manager.ts | 102 ++- .../lib/uploads/upload-session/application.ts | 350 ++++++++++ .../uploads/upload-session/service.test.ts | 120 +++- .../sim/lib/uploads/upload-session/service.ts | 215 ++++++- .../archive-workspace-file-items.test.ts | 92 +++ .../archive-workspace-file-items.ts | 112 ++++ .../compiled-check-workspace-file.ts | 114 ++++ .../application/create-workspace-file.ts | 175 +++++ .../application/csv-preview-workspace-file.ts | 36 ++ .../application/delegated-principal.ts | 39 ++ .../application/delete-workspace-file.test.ts | 81 +++ .../application/delete-workspace-file.ts | 67 ++ .../download-workspace-file-items.test.ts | 152 +++++ .../download-workspace-file-items.ts | 183 ++++++ .../application/download-workspace-file.ts | 92 +++ .../application/list-workspace-files.ts | 91 +++ .../load-authorized-workspace-file.test.ts | 92 +++ .../load-authorized-workspace-file.ts | 41 ++ .../move-workspace-file-items.test.ts | 98 +++ .../application/move-workspace-file-items.ts | 117 ++++ .../application/operations.test.ts | 29 + .../workspace-files/application/operations.ts | 105 +++ .../read-workspace-file-content.ts | 57 ++ .../read-workspace-file-metadata.test.ts | 84 +++ .../read-workspace-file-metadata.ts | 49 ++ .../application/read-workspace-file-record.ts | 54 ++ .../read-workspace-inline-file.test.ts | 90 +++ .../application/read-workspace-inline-file.ts | 59 ++ .../application/rename-workspace-file.test.ts | 7 +- .../application/rename-workspace-file.ts | 33 +- .../resolve-workspace-file-reference.ts | 69 ++ .../restore-workspace-file.test.ts | 81 +++ .../application/restore-workspace-file.ts | 78 +++ .../application/share-workspace-file.ts | 171 +++++ .../application/style-workspace-file.ts | 57 ++ .../update-workspace-file-content.ts | 192 ++++++ .../update-workspace-file-dimensions.test.ts | 57 ++ .../update-workspace-file-dimensions.ts | 44 ++ .../workspace-file-folders.test.ts | 97 +++ .../application/workspace-file-folders.ts | 329 ++++++++++ .../workspace-operation-context.ts | 31 + .../write-workspace-file-by-path.ts | 224 +++++++ apps/sim/lib/workspace-files/limits.ts | 2 + .../workspace-files/workspace-file-path.ts | 29 + packages/auth/src/principal.ts | 29 + scripts/check-api-validation-contracts.ts | 4 +- 172 files changed, 10715 insertions(+), 5744 deletions(-) create mode 100644 apps/sim/app/api/v2/files/folders/route.test.ts create mode 100644 apps/sim/app/api/workspaces/[id]/files/bulk-archive/route.test.ts create mode 100644 apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.test.ts create mode 100644 apps/sim/app/api/workspaces/[id]/files/folders/route.test.ts create mode 100644 apps/sim/app/api/workspaces/[id]/files/move/route.test.ts create mode 100644 apps/sim/lib/api/server/routes/internal-binary-route.ts create mode 100644 apps/sim/lib/api/server/routes/internal-json-route.test.ts create mode 100644 apps/sim/lib/api/server/routes/v2-binary-route.ts create mode 100644 apps/sim/lib/copilot/tools/server/files/file-folder-application.ts create mode 100644 apps/sim/lib/uploads/upload-session/application.ts create mode 100644 apps/sim/lib/workspace-files/application/archive-workspace-file-items.test.ts create mode 100644 apps/sim/lib/workspace-files/application/archive-workspace-file-items.ts create mode 100644 apps/sim/lib/workspace-files/application/compiled-check-workspace-file.ts create mode 100644 apps/sim/lib/workspace-files/application/create-workspace-file.ts create mode 100644 apps/sim/lib/workspace-files/application/csv-preview-workspace-file.ts create mode 100644 apps/sim/lib/workspace-files/application/delegated-principal.ts create mode 100644 apps/sim/lib/workspace-files/application/delete-workspace-file.test.ts create mode 100644 apps/sim/lib/workspace-files/application/delete-workspace-file.ts create mode 100644 apps/sim/lib/workspace-files/application/download-workspace-file-items.test.ts create mode 100644 apps/sim/lib/workspace-files/application/download-workspace-file-items.ts create mode 100644 apps/sim/lib/workspace-files/application/download-workspace-file.ts create mode 100644 apps/sim/lib/workspace-files/application/list-workspace-files.ts create mode 100644 apps/sim/lib/workspace-files/application/load-authorized-workspace-file.test.ts create mode 100644 apps/sim/lib/workspace-files/application/load-authorized-workspace-file.ts create mode 100644 apps/sim/lib/workspace-files/application/move-workspace-file-items.test.ts create mode 100644 apps/sim/lib/workspace-files/application/move-workspace-file-items.ts create mode 100644 apps/sim/lib/workspace-files/application/operations.test.ts create mode 100644 apps/sim/lib/workspace-files/application/read-workspace-file-content.ts create mode 100644 apps/sim/lib/workspace-files/application/read-workspace-file-metadata.test.ts create mode 100644 apps/sim/lib/workspace-files/application/read-workspace-file-metadata.ts create mode 100644 apps/sim/lib/workspace-files/application/read-workspace-file-record.ts create mode 100644 apps/sim/lib/workspace-files/application/read-workspace-inline-file.test.ts create mode 100644 apps/sim/lib/workspace-files/application/read-workspace-inline-file.ts create mode 100644 apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.ts create mode 100644 apps/sim/lib/workspace-files/application/restore-workspace-file.test.ts create mode 100644 apps/sim/lib/workspace-files/application/restore-workspace-file.ts create mode 100644 apps/sim/lib/workspace-files/application/share-workspace-file.ts create mode 100644 apps/sim/lib/workspace-files/application/style-workspace-file.ts create mode 100644 apps/sim/lib/workspace-files/application/update-workspace-file-content.ts create mode 100644 apps/sim/lib/workspace-files/application/update-workspace-file-dimensions.test.ts create mode 100644 apps/sim/lib/workspace-files/application/update-workspace-file-dimensions.ts create mode 100644 apps/sim/lib/workspace-files/application/workspace-file-folders.test.ts create mode 100644 apps/sim/lib/workspace-files/application/workspace-file-folders.ts create mode 100644 apps/sim/lib/workspace-files/application/workspace-operation-context.ts create mode 100644 apps/sim/lib/workspace-files/application/write-workspace-file-by-path.ts create mode 100644 apps/sim/lib/workspace-files/limits.ts create mode 100644 apps/sim/lib/workspace-files/workspace-file-path.ts diff --git a/apps/sim/app/api/files/uploads/[uploadId]/complete/route.ts b/apps/sim/app/api/files/uploads/[uploadId]/complete/route.ts index 6ed8d6d1e35..38cd89b45c8 100644 --- a/apps/sim/app/api/files/uploads/[uploadId]/complete/route.ts +++ b/apps/sim/app/api/files/uploads/[uploadId]/complete/route.ts @@ -2,9 +2,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { completeInternalFileUploadContract } from '@/lib/api/contracts/upload-sessions' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { completeUploadSession, getOwnedUploadSession } from '@/lib/uploads/upload-session/service' -import { finalizeUploadPurpose } from '@/app/api/files/uploads/finalizers' -import { reauthorizeUploadPurpose } from '@/app/api/files/uploads/purposes' +import { completeInternalUploadSession } from '@/lib/uploads/upload-session/application' import { requireUploadUser, toInternalUploadSession, @@ -22,16 +20,15 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Uploa if (!parsed.success) return parsed.response try { - const session = await getOwnedUploadSession({ - uploadId: parsed.data.params.uploadId, - uploadToken: parsed.data.headers['upload-token'], - userId: actor.id, - }) - await reauthorizeUploadPurpose(actor.id, session) - const completed = await completeUploadSession({ - session, - finalize: (claimed) => finalizeUploadPurpose({ session: claimed, actor, request }), - }) + const completed = await completeInternalUploadSession( + actor.principal, + { + uploadId: parsed.data.params.uploadId, + uploadToken: parsed.data.headers['upload-token'], + actor, + }, + request + ) return NextResponse.json({ data: toInternalUploadSession(completed.session, completed.value), }) diff --git a/apps/sim/app/api/files/uploads/[uploadId]/parts/route.ts b/apps/sim/app/api/files/uploads/[uploadId]/parts/route.ts index b707f196567..29491528450 100644 --- a/apps/sim/app/api/files/uploads/[uploadId]/parts/route.ts +++ b/apps/sim/app/api/files/uploads/[uploadId]/parts/route.ts @@ -2,8 +2,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { createInternalFileUploadPartUrlsContract } from '@/lib/api/contracts/upload-sessions' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { createUploadPartUrls, getOwnedUploadSession } from '@/lib/uploads/upload-session/service' -import { reauthorizeUploadPurpose } from '@/app/api/files/uploads/purposes' +import { issueInternalUploadPartUrls } from '@/lib/uploads/upload-session/application' import { requireUploadUser, uploadSessionErrorResponse } from '@/app/api/files/uploads/utils' interface UploadRouteParams { @@ -17,18 +16,16 @@ export const POST = withRouteHandler(async (request: NextRequest, context: Uploa if (!parsed.success) return parsed.response try { - const session = await getOwnedUploadSession({ - uploadId: parsed.data.params.uploadId, - uploadToken: parsed.data.headers['upload-token'], - userId: actor.id, - }) - await reauthorizeUploadPurpose(actor.id, session) - const parts = await createUploadPartUrls({ - session, - partNumbers: parsed.data.body.partNumbers, - localOrigin: request.nextUrl.origin, - }) - return NextResponse.json({ data: { parts } }) + const parts = await issueInternalUploadPartUrls( + actor.principal, + { + uploadId: parsed.data.params.uploadId, + uploadToken: parsed.data.headers['upload-token'], + partNumbers: parsed.data.body.partNumbers, + }, + request + ) + return NextResponse.json({ data: parts }) } catch (error) { const classified = uploadSessionErrorResponse(error) if (classified) return classified diff --git a/apps/sim/app/api/files/uploads/[uploadId]/route.ts b/apps/sim/app/api/files/uploads/[uploadId]/route.ts index 8887bfbb593..162620470fe 100644 --- a/apps/sim/app/api/files/uploads/[uploadId]/route.ts +++ b/apps/sim/app/api/files/uploads/[uploadId]/route.ts @@ -2,8 +2,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { abortInternalFileUploadContract } from '@/lib/api/contracts/upload-sessions' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { abortUploadSession, getOwnedUploadSession } from '@/lib/uploads/upload-session/service' -import { reauthorizeUploadPurpose } from '@/app/api/files/uploads/purposes' +import { abortInternalUploadSession } from '@/lib/uploads/upload-session/application' import { requireUploadUser, toInternalUploadSession, @@ -21,13 +20,10 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Upl if (!parsed.success) return parsed.response try { - const session = await getOwnedUploadSession({ + const aborted = await abortInternalUploadSession(actor.principal, { uploadId: parsed.data.params.uploadId, uploadToken: parsed.data.headers['upload-token'], - userId: actor.id, }) - await reauthorizeUploadPurpose(actor.id, session) - const aborted = await abortUploadSession(session) return NextResponse.json({ data: toInternalUploadSession(aborted, null) }) } catch (error) { const classified = uploadSessionErrorResponse(error) diff --git a/apps/sim/app/api/files/uploads/finalizers.test.ts b/apps/sim/app/api/files/uploads/finalizers.test.ts index a5ed437fe66..37434dbac84 100644 --- a/apps/sim/app/api/files/uploads/finalizers.test.ts +++ b/apps/sim/app/api/files/uploads/finalizers.test.ts @@ -73,6 +73,7 @@ import { finalizeUploadPurpose } from '@/app/api/files/uploads/finalizers' const now = new Date('2026-08-04T12:00:00.000Z') const actor = { id: 'user-1', name: 'Ada', email: 'ada@example.com' } +const principal = { kind: 'session' as const, userId: actor.id, sessionId: 'session-1' } const metadataRow = { id: 'file-1', key: 'workspace-logos/upload-1-logo.png', @@ -147,8 +148,8 @@ describe('upload purpose finalizers', () => { mockInsertReturning.mockResolvedValueOnce([metadataRow]) const request = new NextRequest('http://localhost/api/files/uploads/upload-1/complete') - const first = await finalizeUploadPurpose({ session: uploadSession, actor, request }) - const retry = await finalizeUploadPurpose({ session: uploadSession, actor, request }) + const first = await finalizeUploadPurpose({ session: uploadSession, actor, principal, request }) + const retry = await finalizeUploadPurpose({ session: uploadSession, actor, principal, request }) expect(first.value).toEqual({ path: `/api/files/serve/s3/${encodeURIComponent(metadataRow.key)}?context=workspace-logos`, @@ -169,6 +170,7 @@ describe('upload purpose finalizers', () => { finalizeUploadPurpose({ session: uploadSession, actor, + principal, request: new NextRequest('http://localhost/api/files/uploads/upload-1/complete'), }) ).rejects.toMatchObject({ code: 'conflict' }) @@ -185,6 +187,7 @@ describe('upload purpose finalizers', () => { finalizeUploadPurpose({ session: uploadSession, actor, + principal, request: new NextRequest('http://localhost/api/files/uploads/upload-1/complete'), }) ).rejects.toMatchObject({ code: 'conflict' }) @@ -208,10 +211,23 @@ describe('upload purpose finalizers', () => { mockGetWorkspaceFile.mockResolvedValue(workspaceFile) const request = new NextRequest('http://localhost/api/files/uploads/upload-1/complete') - const first = await finalizeUploadPurpose({ session: workspaceSession, actor, request }) - const retry = await finalizeUploadPurpose({ session: workspaceSession, actor, request }) + const first = await finalizeUploadPurpose({ + session: workspaceSession, + actor, + principal, + request, + }) + const retry = await finalizeUploadPurpose({ + session: workspaceSession, + actor, + principal, + request, + }) expect(retry.value).toEqual(first.value) + expect(mockRegisterUploadedWorkspaceFile).toHaveBeenCalledWith( + expect.objectContaining({ uploadSessionId: workspaceSession.id }) + ) expect(mockNotifyWorkspaceFilesChanged).toHaveBeenCalledTimes(1) expect(mockRecordAudit).toHaveBeenCalledTimes(1) expect(mockCaptureServerEvent).toHaveBeenCalledTimes(1) @@ -236,6 +252,7 @@ describe('upload purpose finalizers', () => { finalizeUploadPurpose({ session: workspaceSession, actor, + principal, request: new NextRequest('http://localhost/api/files/uploads/upload-1/complete'), }) ).rejects.toMatchObject({ code: 'conflict' }) @@ -243,4 +260,38 @@ describe('upload purpose finalizers', () => { expect(mockRecordAudit).not.toHaveBeenCalled() expect(mockCaptureServerEvent).not.toHaveBeenCalled() }) + + it('uses the current billing owner only for workspace-key legacy attribution', async () => { + const workspaceSession = { + ...uploadSession, + purpose: 'workspace_file' as const, + storageContext: 'workspace' as const, + storageKey: workspaceFile.key, + finalKey: workspaceFile.key, + fileName: workspaceFile.name, + contentType: workspaceFile.type, + } + mockRegisterUploadedWorkspaceFile.mockResolvedValueOnce({ + file: { id: workspaceFile.id }, + created: true, + }) + mockGetWorkspaceFile.mockResolvedValue(workspaceFile) + const request = new NextRequest('http://localhost/api/files/uploads/upload-1/complete') + + await finalizeUploadPurpose({ + session: workspaceSession, + actor: { id: 'current-owner' }, + principal: { + kind: 'workspace_api_key', + workspaceId: 'workspace-1', + keyId: 'key-1', + }, + request, + }) + + expect(mockRegisterUploadedWorkspaceFile).toHaveBeenCalledWith( + expect.objectContaining({ userId: 'current-owner' }) + ) + expect(mockCaptureServerEvent).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/app/api/files/uploads/finalizers.ts b/apps/sim/app/api/files/uploads/finalizers.ts index 3c998f1be30..a59eec3beb0 100644 --- a/apps/sim/app/api/files/uploads/finalizers.ts +++ b/apps/sim/app/api/files/uploads/finalizers.ts @@ -1,10 +1,11 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { type Principal, resolvePrincipalAuditAttribution } from '@sim/auth/principal' import { db } from '@sim/db' import { workspaceFiles } from '@sim/db/schema' import { generateId } from '@sim/utils/id' import { eq, sql } from 'drizzle-orm' -import type { NextRequest } from 'next/server' import type { V2File } from '@/lib/api/contracts/v2/files' +import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' import { captureServerEvent } from '@/lib/posthog/server' import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' import { getServeStoragePrefix } from '@/lib/uploads/config' @@ -51,7 +52,9 @@ interface FinalizedWorkspaceFile { interface FinalizeUploadPurposeParams { session: UploadSessionRecord actor: UploadActor - request: NextRequest + request: OrchestrationRequestContext + principal: Principal + authorizeBeforeRegistration?: () => Promise } interface FinalizedUploadPurpose { @@ -79,10 +82,18 @@ export async function finalizeUploadPurpose({ session, actor, request, + principal, + authorizeBeforeRegistration, }: FinalizeUploadPurposeParams): Promise { switch (session.purpose) { case 'workspace_file': - return finalizeInternalWorkspaceFile(session, actor, request) + return finalizeInternalWorkspaceFile( + session, + actor, + request, + principal, + authorizeBeforeRegistration + ) case 'profile_picture': return { value: storedAssetResult(session, 'profile-pictures') } case 'workspace_logo': @@ -100,12 +111,30 @@ export async function finalizeUploadPurpose({ } } +export async function loadCompletedUploadPurpose( + session: UploadSessionRecord +): Promise { + if (session.purpose !== 'workspace_file') { + throw new Error(`Upload purpose ${session.purpose} has no durable file result`) + } + return toV2File(await loadCompletedWorkspaceFileUpload(session)) +} + async function finalizeInternalWorkspaceFile( session: UploadSessionRecord, actor: UploadActor, - request: NextRequest + request: OrchestrationRequestContext, + principal: Principal, + authorizeBeforeRegistration?: () => Promise ): Promise { - const finalized = await finalizeWorkspaceFileUpload({ session, actor, request, source: 'ui' }) + const finalized = await finalizeWorkspaceFileUpload({ + session, + actor, + request, + source: 'ui', + principal, + authorizeBeforeRegistration, + }) return { value: await toV2File(finalized.file), completedFileId: finalized.file.id, @@ -119,15 +148,24 @@ async function finalizeInternalWorkspaceFile( export async function finalizeWorkspaceFileUpload(params: { session: UploadSessionRecord actor: UploadActor - request: NextRequest + request: OrchestrationRequestContext source: 'api' | 'ui' + principal: Principal + authorizeBeforeRegistration?: () => Promise }): Promise { - const { session, actor, request, source } = params + const { session, actor, request, source, principal, authorizeBeforeRegistration } = params const workspaceId = requireWorkspaceId(session) const metadata = session.metadata as { folderId?: string | null } + if (session.completedFileId) { + await authorizeBeforeRegistration?.() + return { file: await loadCompletedWorkspaceFileUpload(session), created: false } + } + await authorizeBeforeRegistration?.() + const legacyAttributionUserId = principal.kind === 'workspace_api_key' ? actor.id : session.userId const registered = await registerUploadedWorkspaceFile({ workspaceId, - userId: session.userId, + userId: legacyAttributionUserId, + uploadSessionId: session.id, key: session.storageKey, originalName: session.fileName, contentType: session.contentType, @@ -145,33 +183,56 @@ export async function finalizeWorkspaceFileUpload(params: { } if (registered.created) { await notifyWorkspaceFilesChanged(workspaceId) - captureServerEvent( - actor.id, - 'file_uploaded', - { workspace_id: workspaceId, file_type: session.contentType }, - { groups: { workspace: workspaceId } } - ) + if (principal.kind !== 'workspace_api_key') { + captureServerEvent( + actor.id, + 'file_uploaded', + { workspace_id: workspaceId, file_type: session.contentType }, + { groups: { workspace: workspaceId } } + ) + } + const auditAttribution = resolvePrincipalAuditAttribution(principal) recordAudit({ workspaceId, - actorId: actor.id, - actorName: actor.name, + actorId: auditAttribution.actorId, + actorName: auditAttribution.actorName ?? actor.name, actorEmail: actor.email, action: AuditAction.FILE_UPLOADED, resourceType: AuditResourceType.FILE, resourceId: file.id, resourceName: file.name, description: `Uploaded file "${file.name}"${source === 'api' ? ' via API' : ''}`, - metadata: { fileSize: file.size, fileType: file.type }, + metadata: { + fileSize: file.size, + fileType: file.type, + actor: auditAttribution.actor, + }, request, }) } return { file, created: registered.created } } +export async function loadCompletedWorkspaceFileUpload( + session: UploadSessionRecord +): Promise { + const workspaceId = requireWorkspaceId(session) + if (!session.completedFileId) { + throw new Error('Workspace upload session has no completed file marker') + } + const durable = await getWorkspaceFile(workspaceId, session.completedFileId, { + includeDeleted: true, + throwOnError: true, + }) + if (!durable) throw new UploadSessionError('conflict', 'Completed workspace file not found') + if (durable.deletedAt) throw new UploadSessionError('conflict', 'Upload result was deleted') + return durable +} + async function finalizeWorkspaceLogo( session: UploadSessionRecord, actor: UploadActor, - request: NextRequest + request: OrchestrationRequestContext ): Promise { const workspaceId = requireWorkspaceId(session) const finalized = await insertOrLoadFileMetadata({ diff --git a/apps/sim/app/api/files/uploads/purposes.ts b/apps/sim/app/api/files/uploads/purposes.ts index a67d2e3298c..8ae9ffd9a0b 100644 --- a/apps/sim/app/api/files/uploads/purposes.ts +++ b/apps/sim/app/api/files/uploads/purposes.ts @@ -1,13 +1,21 @@ +import type { Principal } from '@sim/auth/principal' +import { db } from '@sim/db' +import { workspace } from '@sim/db/schema' import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' +import { and, eq, isNull } from 'drizzle-orm' import type { CreateInternalFileUploadBody } from '@/lib/api/contracts/upload-sessions' import { assertWorkspaceFileFolderTarget } from '@/lib/uploads/contexts/workspace' import { + assertUploadSessionAuthBinding, createUploadSession, UploadSessionError, type UploadSessionRecord, } from '@/lib/uploads/upload-session/service' import { isImageFileType } from '@/lib/uploads/utils/file-utils' import { validateAttachmentFileType } from '@/lib/uploads/utils/validation' +import { authorizeWorkspaceOperation } from '@/lib/workspace-files/application/authorization' +import type { WorkspaceOperation } from '@/lib/workspace-files/application/operations' +import { fileOperations } from '@/lib/workspace-files/application/operations' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' export type InternalUploadPurpose = CreateInternalFileUploadBody['purpose'] @@ -21,15 +29,21 @@ const INTERNAL_UPLOAD_PURPOSES = new Set([ ]) export async function createPurposeUploadSession( - userId: string, + principal: Principal, body: CreateInternalFileUploadBody, localOrigin: string ) { + const userId = await principalUserId( + principal, + 'workspaceId' in body ? body.workspaceId : undefined + ) validatePurposeFile(body) switch (body.purpose) { case 'workspace_file': { - await requireWorkspacePermission(userId, body.workspaceId, 'write') + const context = await loadWorkspaceAuthorizationContext(body.workspaceId) + if (!context) throw new UploadSessionError('not_found', 'Workspace not found') + await authorizeWorkspaceOperation(principal, fileOperations.uploadCreate, context) const folderId = await assertWorkspaceFileFolderTarget(body.workspaceId, body.folderId) return createUploadSession({ purpose: body.purpose, @@ -39,6 +53,7 @@ export async function createPurposeUploadSession( contentType: body.contentType, fileSize: body.size, metadata: { folderId }, + principal, localOrigin, }) } @@ -120,10 +135,41 @@ export async function reauthorizeUploadPurpose( } } +/** + * Re-authorizes a workspace-file control leg against the current principal and + * current workspace policy. Session metadata is only accepted after the + * immutable, server-authored credential binding matches. + */ +export async function reauthorizeWorkspaceUploadPurpose( + principal: Principal, + session: UploadSessionRecord, + operation: WorkspaceOperation = fileOperations.uploadComplete +): Promise { + if (session.purpose !== 'workspace_file' || !session.workspaceId) { + throw new UploadSessionError('not_found', 'Upload session not found') + } + assertUploadSessionAuthBinding(session, principal) + const context = await loadWorkspaceAuthorizationContext(session.workspaceId) + if (!context) throw new UploadSessionError('not_found', 'Upload session not found') + await authorizeWorkspaceOperation(principal, operation, { + workspaceId: context.workspaceId, + workspaceOrganizationId: context.workspaceOrganizationId, + allowPersonalApiKeys: context.allowPersonalApiKeys, + }) +} + export function isInternalUploadPurpose(purpose: string): purpose is InternalUploadPurpose { return INTERNAL_UPLOAD_PURPOSES.has(purpose as InternalUploadPurpose) } +/** Resolves the current billing owner only for legacy upload attribution fields. */ +export async function resolveUploadAttributionUserId( + principal: Principal, + workspaceId: string +): Promise { + return principalUserId(principal, workspaceId) +} + function validatePurposeFile(body: CreateInternalFileUploadBody): void { if (body.purpose === 'profile_picture' || body.purpose === 'workspace_logo') { if (!isImageFileType(body.contentType)) { @@ -186,3 +232,43 @@ function requireSessionScope(value: string | null, label = 'scope'): string { } return value } + +async function principalUserId(principal: Principal, workspaceId?: string): Promise { + switch (principal.kind) { + case 'session': + case 'personal_api_key': + return principal.userId + case 'workspace_api_key': + if (!workspaceId || principal.workspaceId !== workspaceId) { + throw new UploadSessionError('forbidden', 'Workspace API key cannot access this workspace') + } + { + const context = await loadWorkspaceAuthorizationContext(workspaceId) + if (!context?.billedAccountUserId) { + throw new Error('Workspace upload attribution requires a billing owner') + } + return context.billedAccountUserId + } + case 'delegated': + throw new UploadSessionError('forbidden', 'Delegated principals cannot create uploads') + } +} + +async function loadWorkspaceAuthorizationContext(workspaceId: string): Promise<{ + workspaceId: string + workspaceOrganizationId: string | null + allowPersonalApiKeys: boolean + billedAccountUserId: string +} | null> { + const [row] = await db + .select({ + workspaceId: workspace.id, + workspaceOrganizationId: workspace.organizationId, + allowPersonalApiKeys: workspace.allowPersonalApiKeys, + billedAccountUserId: workspace.billedAccountUserId, + }) + .from(workspace) + .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) + .limit(1) + return row ?? null +} diff --git a/apps/sim/app/api/files/uploads/route.test.ts b/apps/sim/app/api/files/uploads/route.test.ts index 4ea8c61428a..f36e29862d1 100644 --- a/apps/sim/app/api/files/uploads/route.test.ts +++ b/apps/sim/app/api/files/uploads/route.test.ts @@ -7,6 +7,8 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockGetSession, mockCreateUploadSession, + mockCreateInternalPurposeUploadSession, + mockCompleteInternalUploadSession, mockGetOwnedUploadSession, mockCompleteUploadSession, mockGetUserEntityPermissions, @@ -14,6 +16,8 @@ const { } = vi.hoisted(() => ({ mockGetSession: vi.fn(), mockCreateUploadSession: vi.fn(), + mockCreateInternalPurposeUploadSession: vi.fn(), + mockCompleteInternalUploadSession: vi.fn(), mockGetOwnedUploadSession: vi.fn(), mockCompleteUploadSession: vi.fn(), mockGetUserEntityPermissions: vi.fn(), @@ -38,6 +42,13 @@ vi.mock('@/lib/uploads/upload-session/service', () => ({ abortUploadSession: vi.fn(), })) +vi.mock('@/lib/uploads/upload-session/application', () => ({ + createInternalPurposeUploadSession: mockCreateInternalPurposeUploadSession, + completeInternalUploadSession: mockCompleteInternalUploadSession, + issueInternalUploadPartUrls: vi.fn(), + abortInternalUploadSession: vi.fn(), +})) + vi.mock('@/lib/workspaces/permissions/utils', () => ({ getUserEntityPermissions: mockGetUserEntityPermissions, })) @@ -98,12 +109,12 @@ function session(overrides: Record = {}) { describe('/api/files/uploads', () => { beforeEach(() => { vi.clearAllMocks() - mockGetSession.mockResolvedValue({ user: actor }) + mockGetSession.mockResolvedValue({ user: actor, session: { id: 'session-1' } }) mockGetUserEntityPermissions.mockResolvedValue('admin') }) it('creates a purpose-scoped PUT session without exposing write capability in the session', async () => { - mockCreateUploadSession.mockResolvedValue({ + mockCreateInternalPurposeUploadSession.mockResolvedValue({ ...session(), transfer: { method: 'put', @@ -126,12 +137,10 @@ describe('/api/files/uploads', () => { const body = await response.json() expect(response.status).toBe(201) - expect(mockCreateUploadSession).toHaveBeenCalledWith( - expect.objectContaining({ - purpose: 'profile_picture', - userId: actor.id, - localOrigin: 'http://localhost', - }) + expect(mockCreateInternalPurposeUploadSession).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'session', userId: actor.id }), + expect.objectContaining({ purpose: 'profile_picture' }), + request ) expect(body.data).toMatchObject({ session: { @@ -148,7 +157,7 @@ describe('/api/files/uploads', () => { }) it('creates a PUT session for an empty workspace file', async () => { - mockCreateUploadSession.mockResolvedValue({ + mockCreateInternalPurposeUploadSession.mockResolvedValue({ ...session({ workspaceId: 'workspace-1', purpose: 'workspace_file', @@ -179,8 +188,10 @@ describe('/api/files/uploads', () => { const response = await createUpload(request) expect(response.status).toBe(201) - expect(mockCreateUploadSession).toHaveBeenCalledWith( - expect.objectContaining({ purpose: 'workspace_file', fileSize: 0 }) + expect(mockCreateInternalPurposeUploadSession).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'session' }), + expect.objectContaining({ purpose: 'workspace_file', size: 0 }), + request ) await expect(response.json()).resolves.toMatchObject({ data: { session: { purpose: 'workspace_file', size: 0 } }, @@ -188,7 +199,7 @@ describe('/api/files/uploads', () => { }) it('preserves the 5 GiB direct-to-storage limit for mothership attachments', async () => { - mockCreateUploadSession.mockResolvedValue({ + mockCreateInternalPurposeUploadSession.mockResolvedValue({ ...session({ workspaceId: 'workspace-1', purpose: 'mothership_attachment', @@ -216,11 +227,10 @@ describe('/api/files/uploads', () => { const response = await createUpload(request) expect(response.status).toBe(201) - expect(mockCreateUploadSession).toHaveBeenCalledWith( - expect.objectContaining({ - purpose: 'mothership_attachment', - fileSize: MAX_WORKSPACE_FILE_SIZE, - }) + expect(mockCreateInternalPurposeUploadSession).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'session' }), + expect.objectContaining({ purpose: 'mothership_attachment', size: MAX_WORKSPACE_FILE_SIZE }), + request ) }) @@ -240,7 +250,7 @@ describe('/api/files/uploads', () => { const response = await createUpload(request) expect(response.status).toBe(400) - expect(mockCreateUploadSession).not.toHaveBeenCalled() + expect(mockCreateInternalPurposeUploadSession).not.toHaveBeenCalled() }) it('reauthorizes a terminal request and returns only the terminal-safe session', async () => { @@ -259,7 +269,7 @@ describe('/api/files/uploads', () => { type: 'image/png', } mockGetOwnedUploadSession.mockReturnValue(logoSession) - mockCompleteUploadSession.mockResolvedValue({ + mockCompleteInternalUploadSession.mockResolvedValue({ session: { ...logoSession, status: 'completed', completedAt: now }, value: result, alreadyCompleted: false, @@ -275,9 +285,13 @@ describe('/api/files/uploads', () => { const body = await response.json() expect(response.status).toBe(200) - expect(mockGetUserEntityPermissions).toHaveBeenCalledWith(actor.id, 'workspace', 'workspace-1') - expect(mockCompleteUploadSession).toHaveBeenCalledWith( - expect.objectContaining({ session: logoSession }) + expect(mockCompleteInternalUploadSession).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'session' }), + expect.objectContaining({ + uploadId: 'upload-1', + actor: expect.objectContaining({ id: actor.id }), + }), + request ) expect(body).toEqual({ data: expect.objectContaining({ @@ -302,6 +316,6 @@ describe('/api/files/uploads', () => { const response = await createUpload(request) expect(response.status).toBe(401) - expect(mockCreateUploadSession).not.toHaveBeenCalled() + expect(mockCreateInternalPurposeUploadSession).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/files/uploads/route.ts b/apps/sim/app/api/files/uploads/route.ts index 85752ea6e82..c7e4e0a56d9 100644 --- a/apps/sim/app/api/files/uploads/route.ts +++ b/apps/sim/app/api/files/uploads/route.ts @@ -2,7 +2,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { createInternalFileUploadContract } from '@/lib/api/contracts/upload-sessions' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { createPurposeUploadSession } from '@/app/api/files/uploads/purposes' +import { createInternalPurposeUploadSession } from '@/lib/uploads/upload-session/application' import { requireUploadUser, toInternalUploadSession, @@ -16,10 +16,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (!parsed.success) return parsed.response try { - const created = await createPurposeUploadSession( - actor.id, + const created = await createInternalPurposeUploadSession( + actor.principal, parsed.data.body, - request.nextUrl.origin + request ) return NextResponse.json( { diff --git a/apps/sim/app/api/files/uploads/utils.ts b/apps/sim/app/api/files/uploads/utils.ts index ca8e9356cb3..e19ea1a9210 100644 --- a/apps/sim/app/api/files/uploads/utils.ts +++ b/apps/sim/app/api/files/uploads/utils.ts @@ -1,3 +1,4 @@ +import type { SessionPrincipal } from '@sim/auth/principal' import { NextResponse } from 'next/server' import { type InternalFileUploadSession, @@ -8,15 +9,25 @@ import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/or import type { UploadSessionRecord } from '@/lib/uploads/upload-session/service' import type { UploadActor, UploadPurposeResult } from '@/app/api/files/uploads/finalizers' -export async function requireUploadUser(): Promise { +export type AuthenticatedUploadActor = UploadActor & { principal: SessionPrincipal } + +export async function requireUploadUser(): Promise { const session = await getSession() if (!session?.user?.id) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } + const sessionId = session.session?.id + if (!sessionId) throw new Error('Authenticated session is missing its session ID') + const principal: SessionPrincipal = { + kind: 'session', + userId: session.user.id, + sessionId, + } return { id: session.user.id, name: session.user.name, email: session.user.email, + principal, } } diff --git a/apps/sim/app/api/files/utils.ts b/apps/sim/app/api/files/utils.ts index a5f97e4b431..96e865cfaf6 100644 --- a/apps/sim/app/api/files/utils.ts +++ b/apps/sim/app/api/files/utils.ts @@ -167,7 +167,7 @@ const SAFE_INLINE_TYPES = new Set([ const FORCE_ATTACHMENT_EXTENSIONS = new Set(['html', 'htm', 'js', 'css', 'xml']) -function getSecureFileHeaders(filename: string, originalContentType: string) { +export function getSecureFileHeaders(filename: string, originalContentType: string) { const extension = filename.split('.').pop()?.toLowerCase() || '' if (FORCE_ATTACHMENT_EXTENSIONS.has(extension)) { diff --git a/apps/sim/app/api/function/execute/route.test.ts b/apps/sim/app/api/function/execute/route.test.ts index 351d9f8d7d4..70885990b8f 100644 --- a/apps/sim/app/api/function/execute/route.test.ts +++ b/apps/sim/app/api/function/execute/route.test.ts @@ -125,6 +125,16 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ uploadWorkspaceFile: vi.fn(), })) +vi.mock('@/lib/workspace-files/application/resolve-workspace-file-reference', () => ({ + resolveWorkspaceFileReference: mockResolveWorkspaceFileReference, +})) + +vi.mock('@/lib/workspace-files/application/read-workspace-file-content', () => ({ + readWorkspaceFileContent: { + execute: vi.fn(async () => ({ content: await mockFetchWorkspaceFileBuffer() })), + }, +})) + vi.mock('@/lib/uploads', () => ({ StorageService: { uploadFile: mockUploadFile, @@ -183,7 +193,13 @@ describe('Function Execute API Route', () => { url: '/api/files/view/existing', key: 'workspace/existing.png', }) - mockResolveWorkspaceFileReference.mockResolvedValue(null) + mockResolveWorkspaceFileReference.mockResolvedValue({ + id: 'wf_existing', + workspaceId: 'workspace-1', + name: 'existing.txt', + size: 0, + key: 'workspace/existing.txt', + }) mockFetchWorkspaceFileBuffer.mockResolvedValue(Buffer.alloc(0)) mockValidateWorkspaceFileWriteTarget.mockImplementation(async ({ target }) => ({ mode: target.mode, diff --git a/apps/sim/app/api/function/execute/route.ts b/apps/sim/app/api/function/execute/route.ts index 67e80f7cd7d..b2f658822e5 100644 --- a/apps/sim/app/api/function/execute/route.ts +++ b/apps/sim/app/api/function/execute/route.ts @@ -1,3 +1,4 @@ +import type { Principal } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { sha256Hex } from '@sim/security/hash' import { getErrorMessage } from '@sim/utils/errors' @@ -78,15 +79,15 @@ import { MAX_SANDBOX_OUTPUT_BYTES, } from '@/lib/execution/remote-sandbox/output-limits' import { isExecutionResourceLimitError } from '@/lib/execution/resource-errors' -import { - fetchWorkspaceFileBuffer, - resolveWorkspaceFileReference, -} from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { mergeWorkspaceFileSecretProvenance, type WorkspaceFileSecretProvenance, } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' import { getWorkflowById } from '@/lib/workflows/utils' +import { createWorkspaceFileDelegatedPrincipal } from '@/lib/workspace-files/application/delegated-principal' +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 { escapeRegExp, normalizeName, REFERENCE } from '@/executor/constants' import { type OutputSchema, resolveBlockReference } from '@/executor/utils/block-reference' import { @@ -1319,26 +1320,33 @@ async function appendPrivateResolvedSecretNames( * either a legitimately idempotent regeneration, or the incident signature of * code that never wrote to the declared sandboxPath (the file still holds the * mounted input). Only the model can tell those apart, so callers surface the - * fact loudly in the receipt instead of failing the write. Comparison failures - * never block the write; the current content is only downloaded when the sizes - * already match. + * fact loudly in the receipt instead of failing the write. The current content + * is only downloaded when the sizes already match. */ async function checkOverwriteTarget( + principal: Principal, workspaceId: string, targetPath: string, buffer: Buffer ): Promise<{ previousSize?: number; identical: boolean }> { - try { - const existing = await resolveWorkspaceFileReference(workspaceId, targetPath) - if (!existing) return { identical: false } - if (existing.size !== buffer.length) { - return { previousSize: existing.size, identical: false } - } - const current = await fetchWorkspaceFileBuffer(existing) - return { previousSize: existing.size, identical: current.equals(buffer) } - } catch { - return { identical: false } + const existing = await resolveWorkspaceFileReference({ + principal, + operation: fileOperations.updateContent, + workspaceId, + reference: targetPath, + }) + if (existing.size !== buffer.length) { + return { previousSize: existing.size, identical: false } } + const { content: current } = await readWorkspaceFileContent.execute({ + principal, + input: { + fileId: existing.id, + assertedWorkspaceId: workspaceId, + maxBytes: buffer.length, + }, + }) + return { previousSize: existing.size, identical: current.equals(buffer) } } function formatExportReceipt(bytes: number, previousSize: number | undefined, sha256: string) { @@ -1455,11 +1463,18 @@ async function maybeExportSandboxFileToWorkspace(args: { const mode = outputMode ?? (overwriteFileId ? 'overwrite' : 'create') const targetPath = mode === 'create' ? outputPath : overwriteFileId || outputPath + const principal = createWorkspaceFileDelegatedPrincipal({ + serviceId: 'executor', + subjectUserId: authUserId, + workspaceId: resolvedWorkspaceId, + delegationId: `function-execute:${routeContext.requestId}`, + executionId: routeContext.executionId, + }) let previousSize: number | undefined let unchanged = false if (mode === 'overwrite') { - const check = await checkOverwriteTarget(resolvedWorkspaceId, targetPath, fileBuffer) + const check = await checkOverwriteTarget(principal, resolvedWorkspaceId, targetPath, fileBuffer) previousSize = check.previousSize unchanged = check.identical } @@ -1468,7 +1483,7 @@ async function maybeExportSandboxFileToWorkspace(args: { const sha256 = sha256Hex(fileBuffer) const written = await writeWorkspaceFileByPath({ workspaceId: resolvedWorkspaceId, - userId: authUserId, + principal, target: { path: targetPath, mode, @@ -1656,13 +1671,20 @@ async function maybeExportSandboxFilesToWorkspace(args: { }) } + const principal = createWorkspaceFileDelegatedPrincipal({ + serviceId: 'executor', + subjectUserId: args.authUserId, + workspaceId: resolvedWorkspaceId, + delegationId: `function-execute:${args.routeContext.requestId}`, + executionId: args.routeContext.executionId, + }) let validationPaths: string[] try { const validations = await Promise.all( preparedFiles.map((prepared) => validateWorkspaceFileWriteTarget({ workspaceId: resolvedWorkspaceId, - userId: args.authUserId, + principal, target: prepared.target, }) ) @@ -1709,14 +1731,19 @@ async function maybeExportSandboxFilesToWorkspace(args: { let previousSize: number | undefined let unchanged = false if (prepared.target.mode === 'overwrite') { - const check = await checkOverwriteTarget(resolvedWorkspaceId, prepared.target.path, buffer) + const check = await checkOverwriteTarget( + principal, + resolvedWorkspaceId, + prepared.target.path, + buffer + ) previousSize = check.previousSize unchanged = check.identical } const sha256 = sha256Hex(buffer) const written = await writeWorkspaceFileByPath({ workspaceId: resolvedWorkspaceId, - userId: args.authUserId, + principal, target: prepared.target, buffer, inferredMimeType: prepared.resolvedMimeType, diff --git a/apps/sim/app/api/tools/file/manage/route.test.ts b/apps/sim/app/api/tools/file/manage/route.test.ts index 27fbdb18858..e7a4625a089 100644 --- a/apps/sim/app/api/tools/file/manage/route.test.ts +++ b/apps/sim/app/api/tools/file/manage/route.test.ts @@ -13,6 +13,9 @@ const { mockEnsureWorkspaceFileFolderPath, mockFetchWorkspaceFileBuffer, mockGetBoundWorkspaceFileSecretProvenance, + mockLoadActiveWorkspaceContext, + mockLoadActiveWorkspaceFileContext, + mockResolveEffectiveWorkspacePermission, mockGetFileMetadataByKey, mockGetWorkspaceFile, mockResolveWorkspaceFileReference, @@ -26,6 +29,9 @@ const { mockEnsureWorkspaceFileFolderPath: vi.fn(), mockFetchWorkspaceFileBuffer: vi.fn(), mockGetBoundWorkspaceFileSecretProvenance: vi.fn(), + mockLoadActiveWorkspaceContext: vi.fn(), + mockLoadActiveWorkspaceFileContext: vi.fn(), + mockResolveEffectiveWorkspacePermission: vi.fn(), mockGetFileMetadataByKey: vi.fn(), mockGetWorkspaceFile: vi.fn(), mockResolveWorkspaceFileReference: vi.fn(), @@ -38,16 +44,50 @@ vi.mock('@/lib/file-parsers', () => ({ parseBuffer: vi.fn(), })) +vi.mock('@sim/audit', () => ({ + AuditAction: { FILE_UPLOADED: 'file_uploaded', FILE_UPDATED: 'file_updated' }, + AuditResourceType: { FILE: 'file' }, + recordAudit: vi.fn(), +})) + +vi.mock('@/lib/realtime/notify', () => ({ + notifyWorkspaceFilesChanged: vi.fn(async () => undefined), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (permission: string | null, required: string) => + permission === 'admin' || + permission === required || + (permission === 'write' && required === 'read'), + resolveEffectiveWorkspacePermission: (...args: unknown[]) => + mockResolveEffectiveWorkspacePermission(...args), +})) + vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ fetchWorkspaceFileBuffer: (...args: unknown[]) => mockFetchWorkspaceFileBuffer(...args), getWorkspaceFile: (...args: unknown[]) => mockGetWorkspaceFile(...args), + loadActiveWorkspaceContext: (...args: unknown[]) => mockLoadActiveWorkspaceContext(...args), + loadActiveWorkspaceFileContext: (...args: unknown[]) => + mockLoadActiveWorkspaceFileContext(...args), resolveWorkspaceFileReference: (...args: unknown[]) => mockResolveWorkspaceFileReference(...args), updateWorkspaceFileContent: (...args: unknown[]) => mockUpdateWorkspaceFileContent(...args), uploadWorkspaceFile: (...args: unknown[]) => mockUploadWorkspaceFile(...args), })) -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => ({ - ensureWorkspaceFileFolderPath: (...args: unknown[]) => mockEnsureWorkspaceFileFolderPath(...args), +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + FileConflictError: class FileConflictError extends Error {}, + ContentVersionConflictError: class ContentVersionConflictError extends Error {}, + fetchWorkspaceFileBuffer: (...args: unknown[]) => mockFetchWorkspaceFileBuffer(...args), + getWorkspaceFile: (...args: unknown[]) => mockGetWorkspaceFile(...args), + loadActiveWorkspaceContext: (...args: unknown[]) => mockLoadActiveWorkspaceContext(...args), + updateWorkspaceFileContent: (...args: unknown[]) => mockUpdateWorkspaceFileContent(...args), + uploadWorkspaceFile: (...args: unknown[]) => mockUploadWorkspaceFile(...args), +})) + +vi.mock('@/lib/workspace-files/application/workspace-file-folders', () => ({ + createWorkspaceFileFolderOperation: { + execute: (...args: unknown[]) => mockEnsureWorkspaceFileFolderPath(...args), + }, })) vi.mock('@/lib/core/config/redis', () => ({ @@ -56,6 +96,7 @@ vi.mock('@/lib/core/config/redis', () => ({ })) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', () => ({ + EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE: { status: 'exact', entries: [] }, getBoundWorkspaceFileSecretProvenance: (...args: unknown[]) => mockGetBoundWorkspaceFileSecretProvenance(...args), mergeWorkspaceFileSecretProvenance: ( @@ -131,12 +172,30 @@ describe('POST /api/tools/file/manage content provenance', () => { authType: 'internal_jwt', }) mockAssertActiveWorkspaceAccess.mockResolvedValue(undefined) + mockResolveEffectiveWorkspacePermission.mockResolvedValue('write') + mockGetWorkspaceFile.mockImplementation(async (_workspaceId: string, fileId: string) => + workspaceFile(fileId) + ) + mockLoadActiveWorkspaceContext.mockResolvedValue({ + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'user-1', + }) + mockLoadActiveWorkspaceFileContext.mockImplementation(async (fileId: string) => ({ + fileId, + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'user-1', + })) mockAssertToolFileAccess.mockResolvedValue(undefined) - mockEnsureWorkspaceFileFolderPath.mockResolvedValue(null) + mockEnsureWorkspaceFileFolderPath.mockResolvedValue({ folder: { id: 'folder-1' } }) mockDownloadServableFileFromStorage.mockImplementation(async (file: { name: string }) => ({ buffer: Buffer.from(`content:${file.name}`), })) mockFetchWorkspaceFileBuffer.mockResolvedValue(Buffer.from('before')) + mockUpdateWorkspaceFileContent.mockResolvedValue({ file: workspaceFile('file-1') }) mockUploadWorkspaceFile.mockResolvedValue({ id: 'new-file', name: 'new.txt', @@ -146,9 +205,6 @@ describe('POST /api/tools/file/manage content provenance', () => { }) it('returns a scoped, deduplicated union of exact canonical file provenance', async () => { - mockGetWorkspaceFile.mockImplementation(async (_workspaceId: string, fileId: string) => - workspaceFile(fileId) - ) mockGetBoundWorkspaceFileSecretProvenance.mockImplementation( async (_workspaceId: string, identity: { fileId: string }) => identity.fileId === 'file-1' @@ -229,7 +285,9 @@ describe('POST /api/tools/file/manage content provenance', () => { 'new.txt', 'text/plain', { + exactName: false, folderId: null, + folderPath: undefined, secretProvenance: { status: 'exact', entries: [ @@ -308,11 +366,12 @@ describe('POST /api/tools/file/manage content provenance', () => { ) expect(response.status).toBe(200) - expect(mockEnsureWorkspaceFileFolderPath).toHaveBeenCalledWith({ - workspaceId: 'workspace-1', - userId: 'user-1', - pathSegments: ['Reports'], - }) + expect(mockEnsureWorkspaceFileFolderPath).toHaveBeenCalledWith( + expect.objectContaining({ + principal: expect.objectContaining({ kind: 'delegated', subjectUserId: 'user-1' }), + input: { workspaceId: 'workspace-1', path: 'Reports' }, + }) + ) expect(mockUploadWorkspaceFile).toHaveBeenCalledWith( 'workspace-1', 'user-1', @@ -320,7 +379,9 @@ describe('POST /api/tools/file/manage content provenance', () => { 'secret-value.txt', 'text/plain', { - folderId: null, + exactName: false, + folderId: 'folder-1', + folderPath: undefined, secretProvenance: { status: 'exact', entries: [] }, } ) @@ -335,7 +396,6 @@ describe('POST /api/tools/file/manage content provenance', () => { content: 'ordinary text', }) ) - expect(response.status).toBe(200) expect(mockUploadWorkspaceFile).toHaveBeenCalledWith( 'workspace-1', @@ -343,7 +403,12 @@ describe('POST /api/tools/file/manage content provenance', () => { Buffer.from('ordinary text'), 'new.txt', 'text/plain', - { folderId: null } + { + exactName: false, + folderId: null, + folderPath: undefined, + secretProvenance: { status: 'exact', entries: [] }, + } ) }) @@ -471,19 +536,20 @@ describe('POST /api/tools/file/manage content provenance', () => { ) expect(response.status).toBe(200) + expect(Buffer.isBuffer(mockUploadWorkspaceFile.mock.calls[0]?.[2])).toBe(true) expect(mockUploadWorkspaceFile).toHaveBeenCalledWith( 'workspace-1', 'user-1', - expect.any(Buffer), + expect.anything(), 'bundle.zip', 'application/zip', - { + expect.objectContaining({ folderId: null, secretProvenance: { status: 'exact', entries: [{ name: 'TOKEN', encryptedValue: 'encrypted-token' }], }, - } + }) ) }) @@ -607,4 +673,26 @@ describe('POST /api/tools/file/manage content provenance', () => { expect(body).not.toHaveProperty('__resolvedSecretTraceProvenance') expect(mockGetBoundWorkspaceFileSecretProvenance).not.toHaveBeenCalled() }) + + it('never uses query.userId as the authorization identity', async () => { + mockGetWorkspaceFile.mockResolvedValue(workspaceFile('file-1')) + + const response = await POST( + createMockRequest( + 'POST', + { operation: 'get', workspaceId: 'workspace-1', fileId: 'file-1' }, + {}, + 'http://localhost:3000/api/tools/file/manage?userId=attacker' + ) + ) + + expect(response.status).toBe(200) + expect(mockResolveEffectiveWorkspacePermission).toHaveBeenCalledWith( + 'user-1', + 'workspace-1', + null, + undefined, + { forUpdate: undefined } + ) + }) }) diff --git a/apps/sim/app/api/tools/file/manage/route.ts b/apps/sim/app/api/tools/file/manage/route.ts index 1af9a5f4c70..0cd7b7e3868 100644 --- a/apps/sim/app/api/tools/file/manage/route.ts +++ b/apps/sim/app/api/tools/file/manage/route.ts @@ -1,5 +1,4 @@ import { Buffer, isUtf8 } from 'buffer' -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { generateShortId } from '@sim/utils/id' @@ -10,6 +9,7 @@ import { parseRequest } from '@/lib/api/server' import { AuthType, type AuthTypeValue, checkInternalAuth } from '@/lib/auth/hybrid' import { splitWorkspaceFilePath } from '@/lib/copilot/tools/server/files/workspace-file' import { acquireLock, releaseLock } from '@/lib/core/config/redis' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { ensureAbsoluteUrl } from '@/lib/core/utils/urls' @@ -26,26 +26,14 @@ import { requestsPrivateToolMetadata, } from '@/lib/execution/private-tool-metadata' import { isSupportedFileType, parseBuffer } from '@/lib/file-parsers' -import { - getShareForResource, - getSharesForResources, - ShareValidationError, - upsertFileShare, -} from '@/lib/public-shares/share-manager' +import { getSharesForResources, ShareValidationError } from '@/lib/public-shares/share-manager' import { ArchiveError, type DecompressResult, decompressArchiveBufferToWorkspaceFiles, MAX_ARCHIVE_BYTES, } from '@/lib/uploads/archive' -import { ensureWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' -import { - fetchWorkspaceFileBuffer, - getWorkspaceFile, - resolveWorkspaceFileReference, - updateWorkspaceFileContent, - uploadWorkspaceFile, -} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import type { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { getBoundWorkspaceFileSecretProvenance, mergeWorkspaceFileSecretProvenance, @@ -60,17 +48,23 @@ import { } from '@/lib/uploads/utils/file-utils.server' import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response' import { buildZipEntryPaths } from '@/lib/uploads/zip-entry-path' -import { performMoveWorkspaceFileItems } from '@/lib/workspace-files/orchestration' import { - assertActiveWorkspaceAccess, - getUserEntityPermissions, - isWorkspaceAccessDeniedError, -} from '@/lib/workspaces/permissions/utils' + createWorkspaceFile, + createWorkspaceFileFromBuffer, +} from '@/lib/workspace-files/application/create-workspace-file' +import { createWorkspaceFileDelegatedPrincipal } from '@/lib/workspace-files/application/delegated-principal' +import { moveWorkspaceFileItemsOperation } from '@/lib/workspace-files/application/move-workspace-file-items' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' +import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' +import { downloadWorkspaceFileRecord } from '@/lib/workspace-files/application/read-workspace-file-record' +import { resolveWorkspaceFileReference } from '@/lib/workspace-files/application/resolve-workspace-file-reference' +import { updateWorkspaceFileShare } from '@/lib/workspace-files/application/share-workspace-file' +import { updateWorkspaceFileContent } from '@/lib/workspace-files/application/update-workspace-file-content' +import { createWorkspaceFileFolderOperation } from '@/lib/workspace-files/application/workspace-file-folders' +import { MAX_WORKSPACE_FILE_CONTENT_BYTES } from '@/lib/workspace-files/orchestration' +import { isWorkspaceAccessDeniedError } from '@/lib/workspaces/permissions/utils' import { assertToolFileAccess } from '@/app/api/files/authorization' -import { - PublicFileSharingNotAllowedError, - validatePublicFileSharing, -} from '@/ee/access-control/utils/permission-check' import type { UserFile } from '@/executor/types' import { ResolvedSecretTraceProvenanceAccumulator, @@ -82,6 +76,16 @@ export const dynamic = 'force-dynamic' const logger = createLogger('FileManageAPI') +function requireInternalPrincipal(auth: { userId?: string }, workspaceId: string) { + if (!auth.userId) throw new Error('Authenticated internal file operation is missing its user ID') + return createWorkspaceFileDelegatedPrincipal({ + serviceId: 'executor', + subjectUserId: auth.userId, + workspaceId, + delegationId: `internal-file-tool:${auth.userId}`, + }) +} + const workspaceFileToUserFile = (file: Awaited>) => { if (!file) return null @@ -440,15 +444,14 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (!parsed.success) return parsed.response const { query, body } = parsed.data - const userId = auth.userId || query.userId - if (!userId) { - return NextResponse.json({ success: false, error: 'userId is required' }, { status: 400 }) - } + if (!auth.userId) throw new Error('Authenticated internal file operation is missing its user ID') + const userId = auth.userId const workspaceId = body.workspaceId || query.workspaceId if (!workspaceId) { return NextResponse.json({ success: false, error: 'workspaceId is required' }, { status: 400 }) } + const principal = requireInternalPrincipal(auth, workspaceId) const includePrivateContentProvenance = body.operation === 'content' && requestsPrivateToolMetadata(request.headers, RESOLVED_SECRET_PROVENANCE_METADATA_V1) @@ -459,8 +462,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) => fileContentJsonResponse(responseBody, includePrivateContentProvenance, init, provenance) try { - await assertActiveWorkspaceAccess(workspaceId, userId) - switch (body.operation) { case 'get': { const { fileId, fileInput } = body @@ -481,12 +482,23 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ success: false, error: 'File is required' }, { status: 400 }) } - const file = await getWorkspaceFile(workspaceId, selectedFileId) - if (!file) { - return NextResponse.json( - { success: false, error: `File not found: "${selectedFileId}"` }, - { status: 404 } - ) + let file: Awaited> + try { + file = ( + await readWorkspaceFileMetadata.execute({ + principal, + input: { fileId: selectedFileId, assertedWorkspaceId: workspaceId }, + request, + }) + ).file + } catch (error) { + if (error instanceof OrchestrationError && error.code === 'not_found') { + return NextResponse.json( + { success: false, error: `File not found: "${selectedFileId}"` }, + { status: 404 } + ) + } + throw error } logger.info('File retrieved', { @@ -515,15 +527,27 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ success: false, error: 'File is required' }, { status: 400 }) } - const files = await Promise.all( - selectedFileIds.map((id) => getWorkspaceFile(workspaceId, id)) - ) - const missingFileId = selectedFileIds.find((_, index) => !files[index]) - if (missingFileId) { - return NextResponse.json( - { success: false, error: `File not found: "${missingFileId}"` }, - { status: 404 } - ) + const files = [] as Array>>> + for (const id of selectedFileIds) { + try { + files.push( + ( + await readWorkspaceFileMetadata.execute({ + principal, + input: { fileId: id, assertedWorkspaceId: workspaceId }, + request, + }) + ).file + ) + } catch (error) { + if (error instanceof OrchestrationError && error.code === 'not_found') { + return NextResponse.json( + { success: false, error: `File not found: "${id}"` }, + { status: 404 } + ) + } + throw error + } } const shares = await getSharesForResources('file', selectedFileIds) @@ -580,15 +604,29 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return contentResponse({ success: false, error: 'File is required' }, { status: 400 }) } - const workspaceFiles = await Promise.all( - selectedFileIds.map((id) => getWorkspaceFile(workspaceId, id)) - ) - const missingFileId = selectedFileIds.find((_, index) => !workspaceFiles[index]) - if (missingFileId) { - return contentResponse( - { success: false, error: `File not found: "${missingFileId}"` }, - { status: 404 } - ) + const workspaceFiles = [] as Array< + NonNullable>> + > + for (const id of selectedFileIds) { + try { + workspaceFiles.push( + ( + await readWorkspaceFileMetadata.execute({ + principal, + input: { fileId: id, assertedWorkspaceId: workspaceId }, + request, + }) + ).file + ) + } catch (error) { + if (error instanceof OrchestrationError && error.code === 'not_found') { + return contentResponse( + { success: false, error: `File not found: "${id}"` }, + { status: 404 } + ) + } + throw error + } } const canonicalSources: FileContentSource[] = workspaceFiles.flatMap((file) => { @@ -660,29 +698,38 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } const { folderSegments, leafName } = splitWorkspaceFilePath(fileName) - const folderId = await ensureWorkspaceFileFolderPath({ - workspaceId, - userId, - pathSegments: folderSegments, - }) + await createWorkspaceFile.admit(principal, workspaceId) + const folderId = + folderSegments.length === 0 + ? null + : ( + await createWorkspaceFileFolderOperation.execute({ + principal, + input: { workspaceId, path: folderSegments.join('/') }, + request, + }) + ).folder.id const mimeType = contentType || getMimeTypeFromExtension(getFileExtension(leafName)) - const fileBuffer = Buffer.from(content ?? '', 'utf-8') - const result = await uploadWorkspaceFile( - workspaceId, - userId, - fileBuffer, - leafName, - mimeType, - { + const result = await createWorkspaceFile.execute({ + principal, + input: { + workspaceId, + name: leafName, + contentType: mimeType, + content: content ?? '', + encoding: 'utf-8', folderId, + exactName: false, ...(provenanceResolution.contentProvenance ? { secretProvenance: provenanceResolution.contentProvenance } : {}), - } - ) + }, + request, + }) + const fileBuffer = Buffer.from(content ?? '', 'utf-8') logger.info('File created', { - fileId: result.id, + fileId: result.file.id, name: fileName, size: fileBuffer.length, }) @@ -690,10 +737,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ success: true, data: { - id: result.id, - name: result.name, + id: result.file.id, + name: result.file.name, size: fileBuffer.length, - url: ensureAbsoluteUrl(result.url), + url: ensureAbsoluteUrl(result.file.url ?? result.file.path), }, }) } @@ -707,30 +754,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => { .map((s) => s.trim()) .filter(Boolean) : [] - const targetFolderId = await ensureWorkspaceFileFolderPath({ - workspaceId, - userId, - pathSegments, - }) - const moveResult = await performMoveWorkspaceFileItems({ - workspaceId, - userId, - fileIds: [fileId], - targetFolderId, + await moveWorkspaceFileItemsOperation.execute({ + principal, + input: { + workspaceId, + fileIds: [fileId], + targetFolderPath: pathSegments.join('/'), + }, + request, }) - if (!moveResult.success) { - return NextResponse.json( - { success: false, error: moveResult.error }, - { - status: - moveResult.errorCode === 'conflict' - ? 409 - : moveResult.errorCode === 'not_found' - ? 404 - : 400, - } - ) - } logger.info('File moved', { fileId, targetFolder: targetFolder || '(root)' }) return NextResponse.json({ success: true, @@ -741,18 +773,6 @@ export const POST = withRouteHandler(async (request: NextRequest) => { case 'manage_sharing': { const { fileId, fileInput, isActive, authType, password, allowedEmails } = body - // Check permission before probing file existence so a read-only caller - // can't distinguish 404 from 403 as a file-existence side channel. - // Publishing is more sensitive than the other mutating ops, so it - // requires write/admin (not just workspace access) like the share route. - const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (permission !== 'admin' && permission !== 'write') { - return NextResponse.json( - { success: false, error: 'Insufficient permissions' }, - { status: 403 } - ) - } - // Resolve the canonical file id. The basic file picker provides an object // with a storage `key` but no id, so map the key to the workspace file row. let resolvedFileId = typeof fileId === 'string' ? fileId : undefined @@ -776,52 +796,20 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } - const file = await getWorkspaceFile(workspaceId, resolvedFileId) - if (!file) { - return NextResponse.json( - { success: false, error: `File not found: "${resolvedFileId}"` }, - { status: 404 } - ) - } - - // Enabling a share is gated by the org's access-control policy; disabling - // is always allowed so users can un-share after the policy is turned on. - if (isActive) { - // Resolve the auth type the same way upsertFileShare will (falling back - // to the existing share's type) so the policy gate can't be bypassed by - // re-enabling a pre-existing restricted share without an explicit authType. - const existingShare = await getShareForResource('file', resolvedFileId) - const resolvedAuthType = authType ?? existingShare?.authType ?? 'public' - try { - await validatePublicFileSharing(userId, workspaceId, resolvedAuthType) - } catch (error) { - if (error instanceof PublicFileSharingNotAllowedError) { - return NextResponse.json({ success: false, error: error.message }, { status: 403 }) - } - throw error - } - } - - const share = await upsertFileShare({ - workspaceId, - fileId: resolvedFileId, - userId, - isActive, - authType, - password, - allowedEmails, - }) - - recordAudit({ - workspaceId, - actorId: userId, - action: isActive ? AuditAction.FILE_SHARED : AuditAction.FILE_SHARE_DISABLED, - resourceType: AuditResourceType.FILE, - resourceId: resolvedFileId, - resourceName: file.name, - description: `${isActive ? 'Enabled' : 'Disabled'} public share for "${file.name}"`, - request, - }) + const share = ( + await updateWorkspaceFileShare.execute({ + principal, + input: { + fileId: resolvedFileId, + assertedWorkspaceId: workspaceId, + isActive, + authType, + password, + allowedEmails, + }, + request, + }) + ).share logger.info('File sharing updated', { fileId: resolvedFileId, @@ -837,13 +825,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { case 'append': { const { fileName, content } = body - const existing = await resolveWorkspaceFileReference(workspaceId, fileName) - if (!existing) { - return NextResponse.json( - { success: false, error: `File not found: "${fileName}"` }, - { status: 404 } - ) - } + const existing = await resolveWorkspaceFileReference({ + principal, + operation: fileOperations.updateContent, + workspaceId, + reference: fileName, + }) const lockKey = `file-append:${workspaceId}:${existing.id}` const lockValue = `${Date.now()}-${generateShortId()}` @@ -887,22 +874,29 @@ export const POST = withRouteHandler(async (request: NextRequest) => { : appendedProvenance ? mergeWorkspaceFileSecretProvenance(existingProvenance, appendedProvenance) : undefined - const existingBuffer = await fetchWorkspaceFileBuffer(existing) + const { content: existingBuffer } = await readWorkspaceFileContent.execute({ + principal, + input: { + fileId: existing.id, + assertedWorkspaceId: workspaceId, + maxBytes: MAX_WORKSPACE_FILE_CONTENT_BYTES, + }, + }) const finalContent = existingBuffer.toString('utf-8') + content const fileBuffer = Buffer.from(finalContent, 'utf-8') - await updateWorkspaceFileContent( - workspaceId, - existing.id, - userId, - fileBuffer, - undefined, - { - expectedUpdatedAt: existing.contentUpdatedAt, - secretProvenancePolicy: secretProvenance - ? { mode: 'replace', provenance: secretProvenance } - : { mode: 'preserve' }, - } - ) + await updateWorkspaceFileContent.execute({ + principal, + input: { + fileId: existing.id, + assertedWorkspaceId: workspaceId, + content: finalContent, + encoding: 'utf-8', + expectedUpdatedAt: existing.contentUpdatedAt ?? undefined, + provenanceMode: secretProvenance ? undefined : 'preserve', + ...(secretProvenance ? { secretProvenance } : {}), + }, + request, + }) logger.info('File appended', { fileId: existing.id, @@ -938,16 +932,31 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (selectedFileIds.length === 0 && selectedInputFiles.length === 0) { return NextResponse.json({ success: false, error: 'File is required' }, { status: 400 }) } + await createWorkspaceFile.admit(principal, workspaceId) - const workspaceFiles = await Promise.all( - selectedFileIds.map((id) => getWorkspaceFile(workspaceId, id)) - ) - const missingFileId = selectedFileIds.find((_, index) => !workspaceFiles[index]) - if (missingFileId) { - return NextResponse.json( - { success: false, error: `File not found: "${missingFileId}"` }, - { status: 404 } - ) + const workspaceFiles = [] as Array< + NonNullable>> + > + for (const id of selectedFileIds) { + try { + workspaceFiles.push( + ( + await downloadWorkspaceFileRecord.execute({ + principal, + input: { fileId: id, assertedWorkspaceId: workspaceId }, + request, + }) + ).file + ) + } catch (error) { + if (error instanceof OrchestrationError && error.code === 'not_found') { + return NextResponse.json( + { success: false, error: `File not found: "${id}"` }, + { status: 404 } + ) + } + throw error + } } const workspaceEntries: ArchiveEntry[] = workspaceFiles.flatMap((file) => { @@ -1028,29 +1037,29 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ? stripExtension(toFlatFileName(userFiles[0].name, 'archive')) : 'archive' const leafName = ensureZipExtension(baseName) - const folderId = await ensureWorkspaceFileFolderPath({ - workspaceId, - userId, - pathSegments: [], + const result = await createWorkspaceFileFromBuffer.execute({ + principal, + input: { + workspaceId, + name: leafName, + contentType: 'application/zip', + content: zipBuffer, + folderId: null, + exactName: false, + secretProvenance: archiveProvenance, + }, + request, }) - const result = await uploadWorkspaceFile( - workspaceId, - userId, - zipBuffer, - leafName, - 'application/zip', - { folderId, secretProvenance: archiveProvenance } - ) const compressedFile: UserFile = { - ...result, - url: ensureAbsoluteUrl(result.url), + ...result.file, + url: ensureAbsoluteUrl(result.file.url ?? result.file.path), size: zipBuffer.length, } logger.info('Files compressed', { - fileId: result.id, - name: result.name, + fileId: result.file.id, + name: result.file.name, fileCount: userFiles.length, size: zipBuffer.length, }) @@ -1083,16 +1092,31 @@ export const POST = withRouteHandler(async (request: NextRequest) => { { status: 400 } ) } + await createWorkspaceFile.admit(principal, workspaceId) - const workspaceFiles = await Promise.all( - selectedFileIds.map((id) => getWorkspaceFile(workspaceId, id)) - ) - const missingFileId = selectedFileIds.find((_, index) => !workspaceFiles[index]) - if (missingFileId) { - return NextResponse.json( - { success: false, error: `File not found: "${missingFileId}"` }, - { status: 404 } - ) + const workspaceFiles = [] as Array< + NonNullable>> + > + for (const id of selectedFileIds) { + try { + workspaceFiles.push( + ( + await downloadWorkspaceFileRecord.execute({ + principal, + input: { fileId: id, assertedWorkspaceId: workspaceId }, + request, + }) + ).file + ) + } catch (error) { + if (error instanceof OrchestrationError && error.code === 'not_found') { + return NextResponse.json( + { success: false, error: `File not found: "${id}"` }, + { status: 404 } + ) + } + throw error + } } const archive = workspaceFiles @@ -1147,7 +1171,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { try { result = await decompressArchiveBufferToWorkspaceFiles(archiveBuffer, { workspaceId, - userId, + principal, secretProvenance: archiveProvenance, }) } catch (archiveError) { @@ -1202,6 +1226,21 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (isWorkspaceAccessDeniedError(error)) { return contentResponse({ success: false, error: 'Workspace access denied' }, { status: 403 }) } + if (error instanceof OrchestrationError) { + const status = + error.code === 'forbidden' + ? 403 + : error.code === 'not_found' + ? 404 + : error.code === 'conflict' + ? 409 + : error.code === 'payload_too_large' + ? 413 + : error.code === 'validation' + ? 400 + : 500 + return contentResponse({ success: false, error: error.message }, { status }) + } const notReady = docNotReadyResponse(error) if (notReady) { if (!includePrivateContentProvenance) return notReady diff --git a/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts index 5ed44af26fb..a8fc4104082 100644 --- a/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts +++ b/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts @@ -4,62 +4,68 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceAccess, - mockPerformUpdateContent, - mockGetUserEmailsByIds, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockPerformUpdateContent: vi.fn(), - mockGetUserEmailsByIds: vi.fn(), +const mocks = vi.hoisted(() => ({ + admit: vi.fn(), + updateContent: vi.fn(), + authenticateV2ApiKey: vi.fn(), + checkRateLimitDirect: vi.fn(), + checkRateLimitDirectOrThrow: vi.fn(), + getUserEmailsByIds: vi.fn(), })) -vi.mock('@/lib/users/queries', () => ({ - getUserEmailsByIds: mockGetUserEmailsByIds, - requireResolvedUserEmail: (emails: Map, userId: string) => emails.get(userId)!, +vi.mock('@/lib/workspace-files/orchestration', () => ({ + MAX_WORKSPACE_FILE_INLINE_BODY_BYTES: 70 * 1024 * 1024, +})) + +vi.mock('@/lib/workspace-files/application/update-workspace-file-content', () => ({ + updateWorkspaceFileContent: { + operation: { id: 'files.update_content', minimumRole: 'write', workspaceApiKey: 'allow' }, + admit: mocks.admit, + execute: mocks.updateContent, + }, })) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticateV2ApiKey, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, +})) + +vi.mock('@/lib/core/rate-limiter', () => ({ + getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }), + RateLimiter: class RateLimiter { + checkRateLimitDirect = mocks.checkRateLimitDirect + checkRateLimitDirectOrThrow = mocks.checkRateLimitDirectOrThrow + }, })) vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: vi.fn().mockResolvedValue(null), })) -vi.mock('@/lib/workspace-files/orchestration', () => ({ - MAX_WORKSPACE_FILE_INLINE_BODY_BYTES: 70 * 1024 * 1024, - performUpdateWorkspaceFileContent: mockPerformUpdateContent, +vi.mock('@/lib/users/queries', () => ({ + getUserEmailsByIds: mocks.getUserEmailsByIds, + requireResolvedUserEmail: (emails: Map, userId: string) => emails.get(userId)!, })) +import { OrchestrationError } from '@/lib/core/orchestration/types' import { PUT } from '@/app/api/v2/files/[fileId]/content/route' -const WS = 'workspace-1' +const WORKSPACE_ID = 'workspace-1' const FILE_ID = 'wf_1' - -const RATE_LIMIT_OK = { - allowed: true, - userId: 'user-1', - keyType: 'workspace', - limit: 100, - remaining: 99, - resetAt: new Date('2024-01-01T01:00:00Z'), -} - -const RATE_LIMIT_DENIED = { - allowed: false, - limit: 100, - remaining: 0, - resetAt: new Date('2024-01-01T01:00:00Z'), - retryAfterMs: 1000, +const auth = { + principal: { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', + }, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, } - -const RECORD = { +const record = { id: FILE_ID, - workspaceId: WS, + workspaceId: WORKSPACE_ID, name: 'data.csv', key: 'workspace/ws/1-x-data.csv', path: '/api/files/serve/x', @@ -67,7 +73,6 @@ const RECORD = { type: 'text/csv', uploadedBy: 'user-1', folderId: null, - folderPath: null, uploadedAt: new Date('2024-01-01T00:00:00Z'), updatedAt: new Date('2024-01-03T00:00:00Z'), } @@ -88,151 +93,99 @@ const callPut = (body: unknown, contentLength?: number) => describe('PUT /api/v2/files/[fileId]/content', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockPerformUpdateContent.mockResolvedValue({ success: true, file: RECORD }) - mockGetUserEmailsByIds.mockResolvedValue(new Map([['user-1', 'ada@example.com']])) - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callPut({ workspaceId: WS, content: 'id,name\n' }) - - expect(res.status).toBe(404) - expect(mockPerformUpdateContent).not.toHaveBeenCalled() - }) - - it('400s when content is missing', async () => { - const res = await callPut({ workspaceId: WS }) - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(mockPerformUpdateContent).not.toHaveBeenCalled() - }) - - it('400s on an encoding outside the enum', async () => { - const res = await callPut({ workspaceId: WS, content: 'x', encoding: 'latin1' }) - expect(res.status).toBe(400) - expect(mockPerformUpdateContent).not.toHaveBeenCalled() + mocks.authenticateV2ApiKey.mockResolvedValue(auth) + mocks.checkRateLimitDirect.mockResolvedValue({ + allowed: true, + remaining: 599, + resetAt: new Date('2024-01-01T01:00:00Z'), + }) + mocks.checkRateLimitDirectOrThrow.mockResolvedValue({ + allowed: true, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), + }) + mocks.admit.mockResolvedValue(undefined) + mocks.updateContent.mockResolvedValue({ file: record }) + mocks.getUserEmailsByIds.mockResolvedValue(new Map([['user-1', 'ada@example.com']])) }) - it('400s malformed base64 in the v2 error envelope', async () => { - const res = await callPut({ workspaceId: WS, content: 'not-base64!', encoding: 'base64' }) - const body = await res.json() - - expect(res.status).toBe(400) - expect(body.error.code).toBe('BAD_REQUEST') - expect(body.error.message).toBe('content must be valid base64') - expect(mockPerformUpdateContent).not.toHaveBeenCalled() - }) + it('performs authenticated admission before parsing a large or malformed body', async () => { + mocks.admit.mockRejectedValue( + new OrchestrationError('forbidden', 'Insufficient workspace permissions') + ) - it('accepts empty base64 as a zero-byte replacement', async () => { - const res = await callPut({ workspaceId: WS, content: '', encoding: 'base64' }) + const response = await callPut('{not-json') - expect(res.status).toBe(200) - expect(mockPerformUpdateContent).toHaveBeenCalledWith( - expect.objectContaining({ content: '', encoding: 'base64' }) - ) + expect(response.status).toBe(404) + expect(mocks.admit).toHaveBeenCalledWith(auth.principal, FILE_ID) + expect(mocks.updateContent).not.toHaveBeenCalled() }) - it('allows JSON bodies above the default 50 MiB cap for base64 expansion', async () => { - const res = await callPut( - { workspaceId: WS, content: 'TQ==', encoding: 'base64' }, - 60 * 1024 * 1024 - ) + it('validates body fields after admission', async () => { + const response = await callPut({ workspaceId: WORKSPACE_ID }) - expect(res.status).toBe(200) - expect(mockPerformUpdateContent).toHaveBeenCalled() + expect(response.status).toBe(400) + expect((await response.json()).error.code).toBe('BAD_REQUEST') + expect(mocks.updateContent).not.toHaveBeenCalled() }) - it('returns an oversized JSON body in the canonical v2 413 envelope', async () => { - const res = await callPut({ workspaceId: WS, content: '' }, 70 * 1024 * 1024 + 1) + it('returns an oversized body in the canonical v2 envelope', async () => { + const response = await callPut({ workspaceId: WORKSPACE_ID, content: '' }, 70 * 1024 * 1024 + 1) - expect(res.status).toBe(413) - await expect(res.json()).resolves.toEqual({ + expect(response.status).toBe(413) + await expect(response.json()).resolves.toEqual({ error: { code: 'PAYLOAD_TOO_LARGE', message: 'Request body is too large' }, }) - expect(mockPerformUpdateContent).not.toHaveBeenCalled() + expect(mocks.admit).toHaveBeenCalled() + expect(mocks.updateContent).not.toHaveBeenCalled() }) - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', + it('replaces content through the shared use case and returns the v2 projection', async () => { + const request = new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}/content`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workspaceId: WORKSPACE_ID, content: 'id,name\n' }), }) - const res = await callPut({ workspaceId: WS, content: 'id,name\n' }) - expect(res.status).toBe(403) - expect(mockPerformUpdateContent).not.toHaveBeenCalled() - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callPut({ workspaceId: WS, content: 'id,name\n' }) - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') - }) - - it('replaces the content and returns the updated file', async () => { - const res = await callPut({ workspaceId: WS, content: 'id,name\n' }) - const body = await res.json() - - expect(res.status).toBe(200) - expect(body.data).toEqual({ - id: FILE_ID, - name: 'data.csv', - size: 8, - type: 'text/csv', - key: 'workspace/ws/1-x-data.csv', - folderPath: '/', - uploadedByEmail: 'ada@example.com', - uploadedAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-03T00:00:00.000Z', + const response = await PUT(request, { params: Promise.resolve({ fileId: FILE_ID }) }) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: { + id: FILE_ID, + name: 'data.csv', + size: 8, + type: 'text/csv', + key: 'workspace/ws/1-x-data.csv', + folderPath: '/', + uploadedByEmail: 'ada@example.com', + uploadedAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-03T00:00:00.000Z', + }, }) - expect(mockPerformUpdateContent).toHaveBeenCalledWith({ - workspaceId: WS, - fileId: FILE_ID, - userId: 'user-1', - content: 'id,name\n', - encoding: 'utf-8', - request: expect.anything(), + expect(mocks.updateContent).toHaveBeenCalledWith({ + principal: auth.principal, + input: { + fileId: FILE_ID, + assertedWorkspaceId: WORKSPACE_ID, + content: 'id,name\n', + encoding: 'utf-8', + }, + request, }) - }) - - it('forwards base64 encoding through to the orchestration', async () => { - await callPut({ workspaceId: WS, content: 'aWQsbmFtZQo=', encoding: 'base64' }) - expect(mockPerformUpdateContent).toHaveBeenCalledWith( - expect.objectContaining({ encoding: 'base64' }) + expect(mocks.checkRateLimitDirectOrThrow).toHaveBeenCalledWith( + 'v2:files.update_content:api-key:key-1', + expect.anything() ) }) - it('maps a payload_too_large errorCode to 413 rather than string-sniffing', async () => { - mockPerformUpdateContent.mockResolvedValue({ - success: false, - error: 'Storage limit exceeded. Used: 5.10GB, Limit: 5GB', - errorCode: 'payload_too_large', - }) - - const res = await callPut({ workspaceId: WS, content: 'id,name\n' }) - const body = await res.json() - - expect(res.status).toBe(413) - expect(body.error.code).toBe('PAYLOAD_TOO_LARGE') - expect(body.error.message).toContain('Storage limit exceeded') - }) - - it('maps a not_found errorCode to 404', async () => { - mockPerformUpdateContent.mockResolvedValue({ - success: false, - error: 'File not found', - errorCode: 'not_found', - }) + it('maps typed quota failures to 413', async () => { + mocks.updateContent.mockRejectedValue( + new OrchestrationError('payload_too_large', 'Storage limit exceeded') + ) - const res = await callPut({ workspaceId: WS, content: 'id,name\n' }) + const response = await callPut({ workspaceId: WORKSPACE_ID, content: 'id,name\n' }) - expect(res.status).toBe(404) - expect((await res.json()).error.code).toBe('NOT_FOUND') + expect(response.status).toBe(413) + expect((await response.json()).error.code).toBe('PAYLOAD_TOO_LARGE') }) }) diff --git a/apps/sim/app/api/v2/files/[fileId]/content/route.ts b/apps/sim/app/api/v2/files/[fileId]/content/route.ts index 3c295bd1306..3ada1f526d5 100644 --- a/apps/sim/app/api/v2/files/[fileId]/content/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/content/route.ts @@ -1,61 +1,42 @@ import { v2UpdateFileContentContract } from '@/lib/api/contracts/v2/files' -import { messageForOrchestrationError } from '@/lib/core/orchestration/types' import { - MAX_WORKSPACE_FILE_INLINE_BODY_BYTES, - performUpdateWorkspaceFileContent, -} from '@/lib/workspace-files/orchestration' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' + defineV2JsonRoute, + v2ApiKeyAuth, + v2FileErrorPolicies, + v2RateLimits, +} from '@/lib/api/server/routes' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { updateWorkspaceFileContent } from '@/lib/workspace-files/application/update-workspace-file-content' +import { MAX_WORKSPACE_FILE_INLINE_BODY_BYTES } from '@/lib/workspace-files/orchestration' import { toV2File } from '@/app/api/v2/files/utils' -import { - v2Data, - v2Error, - v2ErrorForOrchestration, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' +import { v2Error } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** - * PUT /api/v2/files/[fileId]/content — Replace a file's bytes. - * - * A full replace, not an append: `content` becomes the entire body of the file. - * `encoding: 'base64'` carries non-UTF-8 bytes. The decoded body is capped at - * 50 MB and still debits the workspace storage quota, so a write that would push - * the payer past its limit fails with 413. - */ -export const PUT = withPublicApiRouteHandler({ +/** PUT /api/v2/files/[fileId]/content — Replace a file's bytes. */ +export const PUT = defineV2JsonRoute({ contract: v2UpdateFileContentContract, - rateLimitEndpoint: 'file-content', + auth: v2ApiKeyAuth, + operation: fileOperations.updateContent, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, parseOptions: { invalidJsonResponse: () => v2Error('BAD_REQUEST', 'Request body must be valid JSON'), maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES, payloadTooLargeResponse: () => v2Error('PAYLOAD_TOO_LARGE', 'Request body is too large'), }, - handler: async ({ request, input, auth: { userId, rateLimit } }) => { - const { fileId } = input.params - const { workspaceId, content, encoding } = input.body - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - const result = await performUpdateWorkspaceFileContent({ - workspaceId, - fileId, - userId, - content, - encoding, - request, - }) - - if (!result.success || !result.file) { - return v2ErrorForOrchestration( - result.errorCode, - messageForOrchestrationError(result, 'Failed to update file content') - ) + beforeParse: async ({ principal, params }) => { + if (typeof params.fileId === 'string') { + await updateWorkspaceFileContent.admit(principal, params.fileId) } - - return v2Data(await toV2File(result.file), { rateLimit }) }, + mapInput: ({ params, body }) => ({ + fileId: params.fileId, + assertedWorkspaceId: body.workspaceId, + content: body.content, + encoding: body.encoding, + }), + useCase: updateWorkspaceFileContent, + present: async ({ file }) => ({ data: await toV2File(file) }), }) diff --git a/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts index 69dd8f2dc7c..e838e936fd0 100644 --- a/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts +++ b/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts @@ -4,52 +4,57 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceAccess, - mockGetWorkspaceFile, - mockGetUserEmailsByIds, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockGetWorkspaceFile: vi.fn(), - mockGetUserEmailsByIds: vi.fn(), +const mocks = vi.hoisted(() => ({ + readMetadata: vi.fn(), + authenticateV2ApiKey: vi.fn(), + checkRateLimitDirect: vi.fn(), + checkRateLimitDirectOrThrow: vi.fn(), + getUserEmailsByIds: vi.fn(), })) -vi.mock('@/lib/users/queries', () => ({ - getUserEmailsByIds: mockGetUserEmailsByIds, - requireResolvedUserEmail: (emails: Map, userId: string) => emails.get(userId)!, +vi.mock('@/lib/workspace-files/application/read-workspace-file-metadata', () => ({ + readWorkspaceFileMetadata: { + operation: { id: 'files.read_metadata', minimumRole: 'read', workspaceApiKey: 'allow' }, + execute: mocks.readMetadata, + }, })) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticateV2ApiKey, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, +})) + +vi.mock('@/lib/core/rate-limiter', () => ({ + getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }), + RateLimiter: class RateLimiter { + checkRateLimitDirect = mocks.checkRateLimitDirect + checkRateLimitDirectOrThrow = mocks.checkRateLimitDirectOrThrow + }, })) vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: vi.fn().mockResolvedValue(null), })) -vi.mock('@/lib/uploads/contexts/workspace', () => ({ - getWorkspaceFile: mockGetWorkspaceFile, +vi.mock('@/lib/users/queries', () => ({ + getUserEmailsByIds: mocks.getUserEmailsByIds, + requireResolvedUserEmail: (emails: Map, userId: string) => emails.get(userId)!, })) +import { OrchestrationError } from '@/lib/core/orchestration/types' import { GET } from '@/app/api/v2/files/[fileId]/metadata/route' const WORKSPACE_ID = 'workspace-1' const FILE_ID = 'wf_1' - -const RATE_LIMIT_OK = { - allowed: true, - userId: 'user-1', - keyType: 'workspace', - limit: 100, - remaining: 99, - resetAt: new Date('2024-01-01T01:00:00Z'), +const context = { params: Promise.resolve({ fileId: FILE_ID }) } +const auth = { + principal: { kind: 'workspace_api_key' as const, workspaceId: WORKSPACE_ID, keyId: 'key-1' }, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, } -const ctx = { params: Promise.resolve({ fileId: FILE_ID }) } - function buildRecord() { return { id: FILE_ID, @@ -68,39 +73,39 @@ function buildRecord() { } const callGet = (query: string) => - GET(new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}/metadata?${query}`), ctx) + GET(new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}/metadata?${query}`), context) describe('GET /api/v2/files/[fileId]/metadata', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockGetWorkspaceFile.mockResolvedValue(buildRecord()) - mockGetUserEmailsByIds.mockResolvedValue(new Map([['user-1', 'ada@example.com']])) + mocks.authenticateV2ApiKey.mockResolvedValue(auth) + mocks.checkRateLimitDirect.mockResolvedValue({ + allowed: true, + remaining: 599, + resetAt: new Date('2024-01-01T01:00:00Z'), + }) + mocks.checkRateLimitDirectOrThrow.mockResolvedValue({ + allowed: true, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), + }) + mocks.readMetadata.mockResolvedValue({ file: buildRecord() }) + mocks.getUserEmailsByIds.mockResolvedValue(new Map([['user-1', 'ada@example.com']])) }) - it('400s when workspaceId is missing', async () => { + it('authenticates and charges before rejecting a missing workspaceId', async () => { const response = await callGet('') expect(response.status).toBe(400) - expect(mockGetWorkspaceFile).not.toHaveBeenCalled() + expect(mocks.authenticateV2ApiKey).toHaveBeenCalled() + expect(mocks.checkRateLimitDirectOrThrow).toHaveBeenCalledTimes(2) + expect(mocks.readMetadata).not.toHaveBeenCalled() }) - it('surfaces an access-denied failure', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', - }) - - const response = await callGet(`workspaceId=${WORKSPACE_ID}`) - - expect(response.status).toBe(403) - expect(mockGetWorkspaceFile).not.toHaveBeenCalled() - }) - - it('404s when the workspace-scoped file does not exist', async () => { - mockGetWorkspaceFile.mockResolvedValue(null) + it('conceals an authorization failure as not found', async () => { + mocks.readMetadata.mockRejectedValue( + new OrchestrationError('forbidden', 'Insufficient workspace permissions') + ) const response = await callGet(`workspaceId=${WORKSPACE_ID}`) @@ -108,7 +113,7 @@ describe('GET /api/v2/files/[fileId]/metadata', () => { expect((await response.json()).error.code).toBe('NOT_FOUND') }) - it('returns the public metadata projection without loading content', async () => { + it('returns the v2 metadata projection through the shared use case', async () => { const response = await callGet(`workspaceId=${WORKSPACE_ID}`) expect(response.status).toBe(200) @@ -125,14 +130,10 @@ describe('GET /api/v2/files/[fileId]/metadata', () => { updatedAt: '2024-01-02T00:00:00.000Z', }, }) - expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( - expect.anything(), - 'user-1', - WORKSPACE_ID, - 'read' - ) - expect(mockGetWorkspaceFile).toHaveBeenCalledWith(WORKSPACE_ID, FILE_ID, { - throwOnError: true, + expect(mocks.readMetadata).toHaveBeenCalledWith({ + principal: auth.principal, + input: { fileId: FILE_ID, assertedWorkspaceId: WORKSPACE_ID }, + request: expect.anything(), }) }) }) diff --git a/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts b/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts index f74c7fe55be..62744856fd7 100644 --- a/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts @@ -1,27 +1,28 @@ import { v2GetFileContract } from '@/lib/api/contracts/v2/files' -import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2FileErrorPolicies, + v2RateLimits, +} from '@/lib/api/server/routes' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' import { toV2File } from '@/app/api/v2/files/utils' -import { v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 /** GET /api/v2/files/[fileId]/metadata — Return file metadata without downloading its bytes. */ -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2GetFileContract, - rateLimitEndpoint: 'file-detail', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { fileId } = input.params - const { workspaceId } = input.query - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - - const file = await getWorkspaceFile(workspaceId, fileId, { throwOnError: true }) - if (!file) return v2Error('NOT_FOUND', 'File not found') - - return v2Data(await toV2File(file), { rateLimit }) - }, + auth: v2ApiKeyAuth, + operation: fileOperations.readMetadata, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, + mapInput: ({ params, query }) => ({ + fileId: params.fileId, + assertedWorkspaceId: query.workspaceId, + }), + useCase: readWorkspaceFileMetadata, + present: async ({ file }) => ({ data: await toV2File(file) }), }) diff --git a/apps/sim/app/api/v2/files/[fileId]/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/route.test.ts index 04fc7712f27..4168341cf70 100644 --- a/apps/sim/app/api/v2/files/[fileId]/route.test.ts +++ b/apps/sim/app/api/v2/files/[fileId]/route.test.ts @@ -1,395 +1,210 @@ /** * @vitest-environment node - * - * Public v2 file detail: download, rename, archive. Covers the orchestration - * error mapping that replaced the route-local status switch. */ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceAccess, - mockGetWorkspaceFile, - mockFetchWorkspaceFileBuffer, - mockPerformRename, - mockPerformDelete, - mockGetUserEmailsByIds, - mockAuthenticateV2ApiKey, - mockCheckRateLimitDirect, - mockCheckRateLimitDirectOrThrow, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockGetWorkspaceFile: vi.fn(), - mockFetchWorkspaceFileBuffer: vi.fn(), - mockPerformRename: vi.fn(), - mockPerformDelete: vi.fn(), - mockGetUserEmailsByIds: vi.fn(), - mockAuthenticateV2ApiKey: vi.fn(), - mockCheckRateLimitDirect: vi.fn(), - mockCheckRateLimitDirectOrThrow: vi.fn(), +const mocks = vi.hoisted(() => ({ + download: vi.fn(), + rename: vi.fn(), + deleteFile: vi.fn(), + authenticateV2ApiKey: vi.fn(), + checkRateLimitDirect: vi.fn(), + checkRateLimitDirectOrThrow: vi.fn(), + getUserEmailsByIds: vi.fn(), })) -vi.mock('@/lib/users/queries', () => ({ - getUserEmailsByIds: mockGetUserEmailsByIds, - requireResolvedUserEmail: (emails: Map, userId: string) => emails.get(userId)!, -})) - -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, -})) - -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), -})) - -vi.mock('@/lib/uploads/contexts/workspace', () => ({ - getWorkspaceFile: mockGetWorkspaceFile, - fetchWorkspaceFileBuffer: mockFetchWorkspaceFileBuffer, -})) - -vi.mock('@/lib/workspace-files/orchestration', () => ({ - performDeleteWorkspaceFileItems: mockPerformDelete, +vi.mock('@/lib/workspace-files/application/download-workspace-file', () => ({ + downloadWorkspaceFileStream: { + operation: { id: 'files.download', minimumRole: 'read', workspaceApiKey: 'allow' }, + execute: mocks.download, + }, })) vi.mock('@/lib/workspace-files/application/rename-workspace-file', () => ({ renameWorkspaceFile: { operation: { id: 'files.rename', minimumRole: 'write', workspaceApiKey: 'allow' }, - execute: mockPerformRename, + execute: mocks.rename, + }, +})) + +vi.mock('@/lib/workspace-files/application/delete-workspace-file', () => ({ + deleteWorkspaceFileOperation: { + operation: { id: 'files.delete', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.deleteFile, }, })) vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ - authenticateV2ApiKey: mockAuthenticateV2ApiKey, + authenticateV2ApiKey: mocks.authenticateV2ApiKey, V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) vi.mock('@/lib/core/rate-limiter', () => ({ getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }), RateLimiter: class RateLimiter { - checkRateLimitDirect = mockCheckRateLimitDirect - checkRateLimitDirectOrThrow = mockCheckRateLimitDirectOrThrow + checkRateLimitDirect = mocks.checkRateLimitDirect + checkRateLimitDirectOrThrow = mocks.checkRateLimitDirectOrThrow }, })) -import { DELETE, GET, PATCH } from '@/app/api/v2/files/[fileId]/route' - -const WS = 'workspace-1' -const FILE_ID = 'wf_1' +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: vi.fn().mockResolvedValue(null) })) -const RATE_LIMIT_OK = { - allowed: true, - userId: 'user-1', - keyType: 'workspace', - limit: 100, - remaining: 99, - resetAt: new Date('2024-01-01T01:00:00Z'), -} +vi.mock('@/lib/users/queries', () => ({ + getUserEmailsByIds: mocks.getUserEmailsByIds, + requireResolvedUserEmail: (emails: Map, userId: string) => emails.get(userId)!, +})) -const RATE_LIMIT_DENIED = { - allowed: false, - limit: 100, - remaining: 0, - resetAt: new Date('2024-01-01T01:00:00Z'), - retryAfterMs: 1000, -} +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { DELETE, GET, PATCH } from '@/app/api/v2/files/[fileId]/route' -const V2_AUTH = { - principal: { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'key-1' }, - rolloutUserId: 'user-1', - rateLimitSubjectIds: ['api-key:key-1', 'user:user-1'] as const, +const WORKSPACE_ID = 'workspace-1' +const FILE_ID = 'wf_1' +const context = { params: Promise.resolve({ fileId: FILE_ID }) } +const auth = { + principal: { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', + }, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`] as const, rateLimitSubscription: null, - keyType: 'personal' as const, + keyType: 'workspace' as const, } -function buildRecord(overrides: Record = {}) { +function fileRecord(overrides: Record = {}) { return { id: FILE_ID, - workspaceId: WS, + workspaceId: WORKSPACE_ID, name: 'data.csv', key: 'workspace/ws/1-x-data.csv', path: '/api/files/serve/x', - size: 1024, + size: 8, type: 'text/csv', uploadedBy: 'user-1', folderId: null, - folderPath: null, uploadedAt: new Date('2024-01-01T00:00:00Z'), - updatedAt: new Date('2024-01-02T00:00:00Z'), + updatedAt: new Date('2024-01-03T00:00:00Z'), ...overrides, } } -const ctx = { params: Promise.resolve({ fileId: FILE_ID }) } - -const callDownload = (query: string) => - GET(new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}?${query}`), ctx) - -const callRename = (body: unknown) => - PATCH( - new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }), - ctx - ) - -const callDelete = (query: string) => - DELETE(new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}?${query}`), ctx) - -describe('GET /api/v2/files/[fileId]', () => { +describe('v2 single-file routes', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockGetWorkspaceFile.mockResolvedValue(buildRecord()) - mockFetchWorkspaceFileBuffer.mockResolvedValue(Buffer.from('id,name\n')) - mockGetUserEmailsByIds.mockResolvedValue(new Map([['user-1', 'ada@example.com']])) - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callDownload(`workspaceId=${WS}`) - - expect(res.status).toBe(404) - expect(mockGetWorkspaceFile).not.toHaveBeenCalled() - }) - - it('400s when workspaceId is missing', async () => { - const res = await callDownload('') - expect(res.status).toBe(400) - expect(mockGetWorkspaceFile).not.toHaveBeenCalled() - }) - - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', - }) - const res = await callDownload(`workspaceId=${WS}`) - expect(res.status).toBe(403) - expect(mockGetWorkspaceFile).not.toHaveBeenCalled() - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callDownload(`workspaceId=${WS}`) - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') - }) - - it('streams the bytes with rate-limit headers', async () => { - const res = await callDownload(`workspaceId=${WS}`) - expect(res.status).toBe(200) - expect(res.headers.get('Content-Type')).toBe('text/csv') - expect(res.headers.get('X-RateLimit-Remaining')).toBe('99') - expect(await res.text()).toBe('id,name\n') - }) -}) - -describe('PATCH /api/v2/files/[fileId]', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockAuthenticateV2ApiKey.mockResolvedValue(V2_AUTH) - mockCheckRateLimitDirect.mockResolvedValue({ + mocks.authenticateV2ApiKey.mockResolvedValue(auth) + mocks.checkRateLimitDirect.mockResolvedValue({ allowed: true, remaining: 599, resetAt: new Date('2024-01-01T01:00:00Z'), }) - mockCheckRateLimitDirectOrThrow.mockResolvedValue({ + mocks.checkRateLimitDirectOrThrow.mockResolvedValue({ allowed: true, remaining: 99, resetAt: new Date('2024-01-01T01:00:00Z'), }) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockPerformRename.mockResolvedValue({ file: buildRecord({ name: 'renamed.csv' }) }) - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callRename({ workspaceId: WS, name: 'renamed.csv' }) - - expect(res.status).toBe(404) - expect(mockPerformRename).not.toHaveBeenCalled() - }) - - it('400s on a name containing a path separator', async () => { - const res = await callRename({ workspaceId: WS, name: 'nested/renamed.csv' }) - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(mockPerformRename).not.toHaveBeenCalled() - }) - - it('400s on an unknown body field', async () => { - const res = await callRename({ workspaceId: WS, name: 'renamed.csv', folderId: 'fold_1' }) - expect(res.status).toBe(400) - expect(mockPerformRename).not.toHaveBeenCalled() - }) - - it('conceals an access-denied canonical file as not found', async () => { - const { OrchestrationError } = await import('@/lib/core/orchestration/types') - mockPerformRename.mockRejectedValue( - new OrchestrationError('forbidden', 'Insufficient workspace permissions') - ) - const res = await callRename({ workspaceId: WS, name: 'renamed.csv' }) - expect(res.status).toBe(404) - expect((await res.json()).error.code).toBe('NOT_FOUND') - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimitDirectOrThrow.mockResolvedValueOnce({ - allowed: false, - remaining: 0, - resetAt: new Date('2024-01-01T01:00:00Z'), - retryAfterMs: 1000, + mocks.download.mockResolvedValue({ + file: fileRecord(), + stream: new Blob(['id,name\n']).stream(), }) - const res = await callRename({ workspaceId: WS, name: 'renamed.csv' }) - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') - }) - - it('renames and returns the public file shape', async () => { - const res = await callRename({ workspaceId: WS, name: 'renamed.csv' }) - const body = await res.json() - - expect(res.status).toBe(200) - expect(body.data).toEqual({ + mocks.rename.mockResolvedValue({ file: fileRecord({ name: 'renamed.csv' }) }) + mocks.deleteFile.mockResolvedValue({ id: FILE_ID, - name: 'renamed.csv', - size: 1024, - type: 'text/csv', - key: 'workspace/ws/1-x-data.csv', - folderPath: '/', - uploadedByEmail: 'ada@example.com', - uploadedAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-02T00:00:00.000Z', + workspaceId: WORKSPACE_ID, + deleted: true, }) - expect(mockPerformRename).toHaveBeenCalledWith({ - principal: V2_AUTH.principal, - input: { - fileId: FILE_ID, - assertedWorkspaceId: WS, - name: 'renamed.csv', - }, - request: expect.anything(), - }) - expect(mockCheckRateLimitDirectOrThrow).toHaveBeenCalledTimes(2) - expect(mockCheckRateLimitDirectOrThrow).toHaveBeenCalledWith( - 'v2:files.rename:api-key:key-1', - expect.anything() - ) - expect(mockCheckRateLimitDirectOrThrow).toHaveBeenCalledWith( - 'v2:files.rename:user:user-1', - expect.anything() - ) + mocks.getUserEmailsByIds.mockResolvedValue(new Map([['user-1', 'ada@example.com']])) }) - it('maps a typed conflict to 409 through the shared mapper', async () => { - const { OrchestrationError } = await import('@/lib/core/orchestration/types') - mockPerformRename.mockRejectedValue( - new OrchestrationError( - 'conflict', - 'A file named "renamed.csv" already exists in this workspace' - ) + it('downloads bytes through the binary adapter with operation rate headers', async () => { + const response = await GET( + new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}?workspaceId=${WORKSPACE_ID}`), + context ) - const res = await callRename({ workspaceId: WS, name: 'renamed.csv' }) - const body = await res.json() - - expect(res.status).toBe(409) - expect(body.error.code).toBe('CONFLICT') - expect(body.error.message).toContain('already exists') - }) - - it('hides an unclassified failure behind a generic 500', async () => { - mockPerformRename.mockRejectedValue(new Error('update "workspace_files" set ... failed')) - - const res = await callRename({ workspaceId: WS, name: 'renamed.csv' }) - const body = await res.json() - - expect(res.status).toBe(500) - expect(body.error.message).toBe('Internal server error') - }) -}) - -describe('DELETE /api/v2/files/[fileId]', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockPerformDelete.mockResolvedValue({ success: true, deletedItems: { files: 1, folders: 0 } }) + expect(response.status).toBe(200) + expect(response.headers.get('Content-Type')).toBe('text/csv') + expect(response.headers.get('Content-Disposition')).toContain('data.csv') + expect(response.headers.get('X-RateLimit-Remaining')).toBe('99') + expect(await response.text()).toBe('id,name\n') + expect(mocks.download).toHaveBeenCalledWith({ + principal: auth.principal, + input: { fileId: FILE_ID, assertedWorkspaceId: WORKSPACE_ID }, + request: expect.anything(), + }) }) - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callDelete(`workspaceId=${WS}`) + it('conceals download authorization failures', async () => { + mocks.download.mockRejectedValue( + new OrchestrationError('forbidden', 'Insufficient workspace permissions') + ) - expect(res.status).toBe(404) - expect(mockPerformDelete).not.toHaveBeenCalled() - }) + const response = await GET( + new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}?workspaceId=${WORKSPACE_ID}`), + context + ) - it('400s when workspaceId is missing', async () => { - const res = await callDelete('') - expect(res.status).toBe(400) - expect(mockPerformDelete).not.toHaveBeenCalled() + expect(response.status).toBe(404) + expect((await response.json()).error.code).toBe('NOT_FOUND') }) - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', + it('renames through the shared use case and v2 presenter', async () => { + const request = new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workspaceId: WORKSPACE_ID, name: 'renamed.csv' }), + }) + const response = await PATCH(request, context) + + expect(response.status).toBe(200) + expect((await response.json()).data.name).toBe('renamed.csv') + expect(mocks.rename).toHaveBeenCalledWith({ + principal: auth.principal, + input: { fileId: FILE_ID, assertedWorkspaceId: WORKSPACE_ID, name: 'renamed.csv' }, + request, }) - const res = await callDelete(`workspaceId=${WS}`) - expect(res.status).toBe(403) - expect(mockPerformDelete).not.toHaveBeenCalled() - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callDelete(`workspaceId=${WS}`) - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') }) - it('archives the file and acknowledges', async () => { - const res = await callDelete(`workspaceId=${WS}`) - const body = await res.json() + it('maps rename conflicts and conceals authorization failures', async () => { + mocks.rename.mockRejectedValueOnce(new OrchestrationError('conflict', 'Name exists')) + const conflict = await PATCH( + new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workspaceId: WORKSPACE_ID, name: 'renamed.csv' }), + }), + context + ) + expect(conflict.status).toBe(409) - expect(res.status).toBe(200) - expect(body.data).toEqual({ id: FILE_ID, deleted: true }) - expect(mockPerformDelete).toHaveBeenCalledWith({ - workspaceId: WS, - userId: 'user-1', - fileIds: [FILE_ID], - request: expect.anything(), - }) + mocks.rename.mockRejectedValueOnce( + new OrchestrationError('forbidden', 'Insufficient workspace permissions') + ) + const concealed = await PATCH( + new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ workspaceId: WORKSPACE_ID, name: 'renamed.csv' }), + }), + context + ) + expect(concealed.status).toBe(404) }) - it('maps a not_found errorCode to 404', async () => { - mockPerformDelete.mockResolvedValue({ - success: false, - error: 'File not found', - errorCode: 'not_found', + it('archives through the same principal and operation pipeline', async () => { + const request = new NextRequest( + `http://localhost:3000/api/v2/files/${FILE_ID}?workspaceId=${WORKSPACE_ID}`, + { method: 'DELETE' } + ) + const response = await DELETE(request, context) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ data: { id: FILE_ID, deleted: true } }) + expect(mocks.deleteFile).toHaveBeenCalledWith({ + principal: auth.principal, + input: { fileId: FILE_ID, assertedWorkspaceId: WORKSPACE_ID }, + request, }) - - const res = await callDelete(`workspaceId=${WS}`) - - expect(res.status).toBe(404) - expect((await res.json()).error.code).toBe('NOT_FOUND') }) }) diff --git a/apps/sim/app/api/v2/files/[fileId]/route.ts b/apps/sim/app/api/v2/files/[fileId]/route.ts index f0efdf5084a..455bc41c63d 100644 --- a/apps/sim/app/api/v2/files/[fileId]/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/route.ts @@ -1,32 +1,20 @@ -import { createLogger } from '@sim/logger' import { v2DeleteFileContract, v2DownloadFileContract, v2RenameFileContract, } from '@/lib/api/contracts/v2/files' import { + defineV2BinaryRoute, defineV2JsonRoute, v2ApiKeyAuth, v2FileErrorPolicies, v2RateLimits, } from '@/lib/api/server/routes' -import { messageForOrchestrationError } from '@/lib/core/orchestration/types' -import { fetchWorkspaceFileBuffer, getWorkspaceFile } from '@/lib/uploads/contexts/workspace' +import { deleteWorkspaceFileOperation } from '@/lib/workspace-files/application/delete-workspace-file' +import { downloadWorkspaceFileStream } from '@/lib/workspace-files/application/download-workspace-file' import { fileOperations } from '@/lib/workspace-files/application/operations' import { renameWorkspaceFile } from '@/lib/workspace-files/application/rename-workspace-file' -import { performDeleteWorkspaceFileItems } from '@/lib/workspace-files/orchestration' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' import { toV2File } from '@/app/api/v2/files/utils' -import { - rateLimitHeaders, - v2Data, - v2Error, - v2ErrorForOrchestration, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2FileDetailAPI') export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -38,31 +26,23 @@ export const revalidate = 0 * `X-RateLimit-*` headers. Errors still render the canonical v2 JSON error body. * Lookups are workspace-scoped (IDOR-safe): a file in another workspace 404s. */ -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2BinaryRoute({ contract: v2DownloadFileContract, - rateLimitEndpoint: 'file-detail', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { fileId } = input.params - const { workspaceId } = input.query - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - - const fileRecord = await getWorkspaceFile(workspaceId, fileId) - if (!fileRecord) return v2Error('NOT_FOUND', 'File not found') - - const buffer = await fetchWorkspaceFileBuffer(fileRecord) - - return new Response(new Uint8Array(buffer), { - status: 200, - headers: { - 'Content-Type': fileRecord.type || 'application/octet-stream', - 'Content-Disposition': `attachment; filename="${fileRecord.name.replace(/[^\w.-]/g, '_')}"; filename*=UTF-8''${encodeURIComponent(fileRecord.name)}`, - 'Content-Length': String(buffer.length), - ...rateLimitHeaders(rateLimit), - }, - }) - }, + auth: v2ApiKeyAuth, + operation: fileOperations.download, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, + mapInput: ({ params, query }) => ({ + fileId: params.fileId, + assertedWorkspaceId: query.workspaceId, + }), + useCase: downloadWorkspaceFileStream, + present: ({ file, stream }) => ({ + body: stream, + contentType: file.type || 'application/octet-stream', + contentDisposition: `attachment; filename="${file.name.replace(/[^\w.-]/g, '_')}"; filename*=UTF-8''${encodeURIComponent(file.name)}`, + contentLength: file.size, + }), }) /** @@ -90,37 +70,20 @@ export const PATCH = defineV2JsonRoute({ /** * DELETE /api/v2/files/[fileId] — Delete a file. * - * Delegates to the shared orchestration, which is workspace-scoped and records - * its own audit entry (the request is forwarded so that entry captures client - * IP / user agent). Orchestration `errorCode`s map to specific v2 codes rather - * than v1's blanket 500. + * Uses the shared workspace-file application operation, which canonicalizes the + * resource, authorizes the API-key principal, archives it, and records the + * semantic audit/notification side effects once. */ -export const DELETE = withPublicApiRouteHandler({ +export const DELETE = defineV2JsonRoute({ contract: v2DeleteFileContract, - rateLimitEndpoint: 'file-detail', - handler: async ({ request, input, auth: { userId, rateLimit } }) => { - const { fileId } = input.params - const { workspaceId } = input.query - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - const result = await performDeleteWorkspaceFileItems({ - workspaceId, - userId, - fileIds: [fileId], - request, - }) - - if (!result.success) { - return v2ErrorForOrchestration( - result.errorCode, - messageForOrchestrationError(result, 'Failed to delete file') - ) - } - - logger.info(`Deleted file ${fileId} from workspace ${workspaceId}`) - - return v2Data({ id: fileId, deleted: true as const }, { rateLimit }) - }, + auth: v2ApiKeyAuth, + operation: fileOperations.delete, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, + mapInput: ({ params, query }) => ({ + fileId: params.fileId, + assertedWorkspaceId: query.workspaceId, + }), + useCase: deleteWorkspaceFileOperation, + present: ({ id, deleted }) => ({ data: { id, deleted } }), }) diff --git a/apps/sim/app/api/v2/files/[fileId]/share/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/share/route.test.ts index 25b15d2f47b..78123770c87 100644 --- a/apps/sim/app/api/v2/files/[fileId]/share/route.test.ts +++ b/apps/sim/app/api/v2/files/[fileId]/share/route.test.ts @@ -1,57 +1,90 @@ /** * @vitest-environment node - * - * Public v2 file share. The two decisions that separate it from the internal - * route are pinned here: the caller-supplied `token` is rejected, and a bare - * re-enable keeps the token the orchestration already stored. */ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockCheckRateLimit, mockResolveWorkspaceAccess, mockPerformGetShare, mockPerformUpsert } = - vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockPerformGetShare: vi.fn(), - mockPerformUpsert: vi.fn(), - })) - -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, +const { mocks, MockV2ApiKeyUnauthenticatedError } = vi.hoisted(() => { + class MockV2ApiKeyUnauthenticatedError extends Error { + constructor(message = 'Invalid API key') { + super(message) + this.name = 'V2ApiKeyUnauthenticatedError' + } + } + + return { + mocks: { + authenticate: vi.fn(), + preauthRate: vi.fn(), + operationRate: vi.fn(), + gate: vi.fn(), + getShare: vi.fn(), + updateShare: vi.fn(), + }, + MockV2ApiKeyUnauthenticatedError, + } +}) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: MockV2ApiKeyUnauthenticatedError, +})) + +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mocks.preauthRate + checkRateLimitDirectOrThrow = mocks.operationRate + }, + getRateLimit: vi.fn().mockReturnValue({ + maxTokens: 100, + refillRate: 100, + refillIntervalMs: 60_000, + }), })) -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +vi.mock('@/lib/api/server/rate-limit-context', () => ({ + recordRateLimitSnapshot: vi.fn(), + getRateLimitHeaders: vi.fn().mockReturnValue(null), })) -vi.mock('@/lib/workspace-files/orchestration', () => ({ - performGetWorkspaceFileShare: mockPerformGetShare, - performUpsertWorkspaceFileShare: mockPerformUpsert, +vi.mock('@/lib/core/utils/request', () => ({ + generateRequestId: vi.fn().mockReturnValue('request-1'), + getClientIp: vi.fn().mockReturnValue('127.0.0.1'), })) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) + +vi.mock('@/lib/workspace-files/application/share-workspace-file', () => ({ + getWorkspaceFileShare: { + operation: { id: 'files.share.read', minimumRole: 'read', workspaceApiKey: 'allow' }, + execute: mocks.getShare, + }, + updateWorkspaceFileShare: { + operation: { id: 'files.share.update', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.updateShare, + }, +})) + +import { OrchestrationError } from '@/lib/core/orchestration/types' import { GET, PUT } from '@/app/api/v2/files/[fileId]/share/route' -const WS = 'workspace-1' +const WORKSPACE_ID = 'workspace-1' const FILE_ID = 'wf_1' - +const PRINCIPAL = { kind: 'workspace_api_key' as const, workspaceId: WORKSPACE_ID, keyId: 'key-1' } +const AUTH = { + principal: PRINCIPAL, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: ['workspace:workspace-1'] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, +} const RATE_LIMIT_OK = { allowed: true, - userId: 'user-1', - keyType: 'workspace', limit: 100, remaining: 99, resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 0, } - -const RATE_LIMIT_DENIED = { - allowed: false, - limit: 100, - remaining: 0, - resetAt: new Date('2024-01-01T01:00:00Z'), - retryAfterMs: 1000, -} - const SHARE = { id: 'shr_1', token: 'existing-token-abcd', @@ -63,209 +96,177 @@ const SHARE = { hasPassword: false, allowedEmails: [] as string[], } +const context = { params: Promise.resolve({ fileId: FILE_ID }) } -const ctx = { params: Promise.resolve({ fileId: FILE_ID }) } - -const callGet = (query: string) => - GET(new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}/share?${query}`), ctx) +function callGet(query = `workspaceId=${WORKSPACE_ID}`) { + return GET( + new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}/share?${query}`, { + headers: { 'x-api-key': 'key' }, + }), + context + ) +} -const callPut = (body: unknown) => - PUT( +function callPut(body: unknown) { + return PUT( new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}/share`, { method: 'PUT', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', 'x-api-key': 'key' }, body: JSON.stringify(body), }), - ctx + context ) +} describe('GET /api/v2/files/[fileId]/share', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockPerformGetShare.mockResolvedValue({ success: true, share: SHARE }) + mocks.authenticate.mockResolvedValue(AUTH) + mocks.preauthRate.mockResolvedValue(RATE_LIMIT_OK) + mocks.operationRate.mockResolvedValue(RATE_LIMIT_OK) + mocks.gate.mockResolvedValue(null) + mocks.getShare.mockResolvedValue({ share: SHARE }) + }) + + it('authenticates and rate-limits before parsing or executing', async () => { + mocks.authenticate.mockRejectedValueOnce( + new MockV2ApiKeyUnauthenticatedError('API key required') + ) + + const response = await callGet() + + expect(response.status).toBe(401) + expect(mocks.getShare).not.toHaveBeenCalled() + expect(mocks.operationRate).not.toHaveBeenCalled() }) it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) + mocks.gate.mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - const res = await callGet(`workspaceId=${WS}`) + const response = await callGet() - expect(res.status).toBe(404) - expect(mockPerformGetShare).not.toHaveBeenCalled() + expect(response.status).toBe(404) + expect(mocks.getShare).not.toHaveBeenCalled() }) - it('400s when workspaceId is missing', async () => { - const res = await callGet('') - expect(res.status).toBe(400) - expect(mockPerformGetShare).not.toHaveBeenCalled() + it('validates the asserted workspace before executing the use case', async () => { + const response = await callGet('') + + expect(response.status).toBe(400) + expect(mocks.getShare).not.toHaveBeenCalled() }) - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', + it('conceals authorization failures as not found', async () => { + mocks.getShare.mockRejectedValueOnce(new OrchestrationError('forbidden', 'Access denied')) + + const response = await callGet() + const body = await response.json() + + expect(response.status).toBe(404) + expect(body.error.code).toBe('NOT_FOUND') + expect(mocks.getShare).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { fileId: FILE_ID, assertedWorkspaceId: WORKSPACE_ID }, + request: expect.anything(), }) - const res = await callGet(`workspaceId=${WS}`) - expect(res.status).toBe(403) - expect(mockPerformGetShare).not.toHaveBeenCalled() }) - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callGet(`workspaceId=${WS}`) - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') + it('returns the share through the v2 envelope', async () => { + const response = await callGet() + + expect(response.status).toBe(200) + expect((await response.json()).data).toEqual({ share: SHARE }) }) - it('reads at workspace read level and returns the share', async () => { - const res = await callGet(`workspaceId=${WS}`) - const body = await res.json() + it('returns the rate-limit response when denied', async () => { + mocks.operationRate.mockResolvedValueOnce({ ...RATE_LIMIT_OK, allowed: false, remaining: 0 }) - expect(res.status).toBe(200) - expect(body.data).toEqual({ share: SHARE }) - expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith(expect.anything(), 'user-1', WS, 'read') - expect(mockPerformGetShare).toHaveBeenCalledWith({ workspaceId: WS, fileId: FILE_ID }) - }) + const response = await callGet() - it('returns a null share for a file that was never shared', async () => { - mockPerformGetShare.mockResolvedValue({ success: true, share: null }) - const res = await callGet(`workspaceId=${WS}`) - expect((await res.json()).data).toEqual({ share: null }) + expect(response.status).toBe(429) + expect((await response.json()).error.code).toBe('RATE_LIMITED') + expect(mocks.getShare).not.toHaveBeenCalled() }) }) describe('PUT /api/v2/files/[fileId]/share', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockPerformUpsert.mockResolvedValue({ success: true, share: SHARE }) - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callPut({ workspaceId: WS, isActive: true }) - - expect(res.status).toBe(404) - expect(mockPerformUpsert).not.toHaveBeenCalled() - }) - - it('400s when isActive is missing', async () => { - const res = await callPut({ workspaceId: WS }) - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(mockPerformUpsert).not.toHaveBeenCalled() + mocks.authenticate.mockResolvedValue(AUTH) + mocks.preauthRate.mockResolvedValue(RATE_LIMIT_OK) + mocks.operationRate.mockResolvedValue(RATE_LIMIT_OK) + mocks.gate.mockResolvedValue(null) + mocks.updateShare.mockResolvedValue({ share: SHARE }) }) - it('rejects a caller-supplied token instead of minting a predictable URL', async () => { - const res = await callPut({ - workspaceId: WS, + it('rejects a caller-supplied token at the v2 boundary', async () => { + const response = await callPut({ + workspaceId: WORKSPACE_ID, isActive: true, token: 'attacker-chosen-token', }) - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(mockPerformUpsert).not.toHaveBeenCalled() + expect(response.status).toBe(400) + expect((await response.json()).error.code).toBe('BAD_REQUEST') + expect(mocks.updateShare).not.toHaveBeenCalled() }) - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', - }) - const res = await callPut({ workspaceId: WS, isActive: true }) - expect(res.status).toBe(403) - expect(mockPerformUpsert).not.toHaveBeenCalled() - }) + it('renders typed validation failures in the v2 envelope', async () => { + mocks.updateShare.mockRejectedValueOnce( + new OrchestrationError('validation', 'Password is required for password-protected shares') + ) - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callPut({ workspaceId: WS, isActive: true }) - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') + const response = await callPut({ workspaceId: WORKSPACE_ID, isActive: true }) + const body = await response.json() + + expect(response.status).toBe(400) + expect(body.error).toEqual({ + code: 'BAD_REQUEST', + message: 'Password is required for password-protected shares', + }) }) - it('enables the share at workspace write level and never forwards a token', async () => { - const res = await callPut({ - workspaceId: WS, + it('passes the shared principal and canonical workspace assertion to the use case', async () => { + const response = await callPut({ + workspaceId: WORKSPACE_ID, isActive: true, authType: 'password', password: 'hunter2hunter2', }) - const body = await res.json() - - expect(res.status).toBe(200) - expect(body.data).toEqual({ share: SHARE }) - expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith( - expect.anything(), - 'user-1', - WS, - 'write' - ) - expect(mockPerformUpsert).toHaveBeenCalledWith({ - workspaceId: WS, - fileId: FILE_ID, - userId: 'user-1', - isActive: true, - authType: 'password', - password: 'hunter2hunter2', - allowedEmails: undefined, + + expect(response.status).toBe(200) + expect((await response.json()).data).toEqual({ share: SHARE }) + expect(mocks.updateShare).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + fileId: FILE_ID, + assertedWorkspaceId: WORKSPACE_ID, + isActive: true, + authType: 'password', + password: 'hunter2hunter2', + allowedEmails: undefined, + }, request: expect.anything(), }) - expect(mockPerformUpsert.mock.calls[0][0]).not.toHaveProperty('token') }) - it('preserves the existing token on a bare re-enable', async () => { - const res = await callPut({ workspaceId: WS, isActive: true }) - const body = await res.json() - - expect(res.status).toBe(200) - expect(body.data.share.token).toBe('existing-token-abcd') - expect(body.data.share.url).toBe('https://www.sim.ai/f/existing-token-abcd') - // No authType either: the orchestration resolves the stored one, so the - // access-control gate is evaluated against the real mode, not 'public'. - expect(mockPerformUpsert).toHaveBeenCalledWith( - expect.objectContaining({ isActive: true, authType: undefined }) - ) - }) - - it('maps a forbidden errorCode from the access-control policy to 403', async () => { - mockPerformUpsert.mockResolvedValue({ - success: false, - error: 'Public file sharing is not allowed based on your permission group settings', - errorCode: 'forbidden', - }) + it('conceals forbidden updates as not found', async () => { + mocks.updateShare.mockRejectedValueOnce(new OrchestrationError('forbidden', 'Access denied')) - const res = await callPut({ workspaceId: WS, isActive: true }) - const body = await res.json() + const response = await callPut({ workspaceId: WORKSPACE_ID, isActive: true }) - expect(res.status).toBe(403) - expect(body.error.code).toBe('FORBIDDEN') - expect(body.error.message).toContain('not allowed') + expect(response.status).toBe(404) + expect((await response.json()).error.code).toBe('NOT_FOUND') }) - it('maps a validation errorCode to 400', async () => { - mockPerformUpsert.mockResolvedValue({ - success: false, - error: 'Password is required for password-protected shares', - errorCode: 'validation', - }) + it('returns the rate-limit response when denied', async () => { + mocks.operationRate.mockResolvedValueOnce({ ...RATE_LIMIT_OK, allowed: false, remaining: 0 }) - const res = await callPut({ workspaceId: WS, isActive: true, authType: 'password' }) + const response = await callPut({ workspaceId: WORKSPACE_ID, isActive: true }) - expect(res.status).toBe(400) - expect((await res.json()).error.message).toBe( - 'Password is required for password-protected shares' - ) + expect(response.status).toBe(429) + expect((await response.json()).error.code).toBe('RATE_LIMITED') + expect(mocks.updateShare).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/v2/files/[fileId]/share/route.ts b/apps/sim/app/api/v2/files/[fileId]/share/route.ts index 22ff6225615..e60d1d35c40 100644 --- a/apps/sim/app/api/v2/files/[fileId]/share/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/share/route.ts @@ -1,84 +1,47 @@ import { v2GetFileShareContract, v2UpsertFileShareContract } from '@/lib/api/contracts/v2/files' -import { messageForOrchestrationError } from '@/lib/core/orchestration/types' import { - performGetWorkspaceFileShare, - performUpsertWorkspaceFileShare, -} from '@/lib/workspace-files/orchestration' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2Data, v2ErrorForOrchestration, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' + defineV2JsonRoute, + v2ApiKeyAuth, + v2FileErrorPolicies, + v2RateLimits, +} from '@/lib/api/server/routes' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { + getWorkspaceFileShare, + updateWorkspaceFileShare, +} from '@/lib/workspace-files/application/share-workspace-file' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** - * GET /api/v2/files/[fileId]/share — Read a file's public share state. - * - * `null` means the file has never been shared. `hasPassword` is the only signal - * carried for a password-gated share; the ciphertext is never exposed. - */ -export const GET = withPublicApiRouteHandler({ +export const GET = defineV2JsonRoute({ contract: v2GetFileShareContract, - rateLimitEndpoint: 'file-share', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { fileId } = input.params - const { workspaceId } = input.query - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - - const result = await performGetWorkspaceFileShare({ workspaceId, fileId }) - - if (!result.success) { - return v2ErrorForOrchestration( - result.errorCode, - messageForOrchestrationError(result, 'Failed to fetch share') - ) - } - - return v2Data({ share: result.share ?? null }, { rateLimit }) - }, + auth: v2ApiKeyAuth, + operation: fileOperations.readShare, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, + mapInput: ({ params, query }) => ({ + fileId: params.fileId, + assertedWorkspaceId: query.workspaceId, + }), + useCase: getWorkspaceFileShare, + present: ({ share }) => ({ data: { share } }), }) -/** - * PUT /api/v2/files/[fileId]/share — Enable or disable a file's public share. - * - * Requires workspace `write`, matching the UI. The share token is always - * server-generated: the internal surface accepts a caller-supplied one so the UI - * can render a link before saving, but over an API key that would mint - * predictable public URLs and collide with the token unique index. - * - * `isActive: false` disables, it does not revoke — the token and the stored - * password / allow-list survive, so re-enabling resurrects the same URL. - */ -export const PUT = withPublicApiRouteHandler({ +export const PUT = defineV2JsonRoute({ contract: v2UpsertFileShareContract, - rateLimitEndpoint: 'file-share', - handler: async ({ request, input, auth: { userId, rateLimit } }) => { - const { fileId } = input.params - const { workspaceId, isActive, authType, password, allowedEmails } = input.body - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - const result = await performUpsertWorkspaceFileShare({ - workspaceId, - fileId, - userId, - isActive, - authType, - password, - allowedEmails, - request, - }) - - if (!result.success || !result.share) { - return v2ErrorForOrchestration( - result.errorCode, - messageForOrchestrationError(result, 'Failed to update share') - ) - } - - return v2Data({ share: result.share }, { rateLimit }) - }, + auth: v2ApiKeyAuth, + operation: fileOperations.updateShare, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, + mapInput: ({ params, body }) => ({ + fileId: params.fileId, + assertedWorkspaceId: body.workspaceId, + isActive: body.isActive, + authType: body.authType, + password: body.password, + allowedEmails: body.allowedEmails, + }), + useCase: updateWorkspaceFileShare, + present: ({ share }) => ({ data: { share } }), }) diff --git a/apps/sim/app/api/v2/files/bulk-delete/route.test.ts b/apps/sim/app/api/v2/files/bulk-delete/route.test.ts index 31795501de6..a66f490f6c2 100644 --- a/apps/sim/app/api/v2/files/bulk-delete/route.test.ts +++ b/apps/sim/app/api/v2/files/bulk-delete/route.test.ts @@ -4,51 +4,64 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockCheckRateLimit, mockResolveWorkspaceAccess, mockPerformDelete } = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockPerformDelete: vi.fn(), +const { mockPreauth, mockOperationRate, mockGate, mockExecute } = vi.hoisted(() => ({ + mockPreauth: vi.fn(), + mockOperationRate: vi.fn(), + mockGate: vi.fn(), + mockExecute: vi.fn(), })) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: vi.fn().mockResolvedValue({ + principal: { kind: 'workspace_api_key', workspaceId: 'workspace-1', keyId: 'key-1' }, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: ['workspace:workspace-1'], + rateLimitSubscription: null, + keyType: 'workspace', + }), + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) - -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mockPreauth + checkRateLimitDirectOrThrow = mockOperationRate + }, + getRateLimit: vi + .fn() + .mockReturnValue({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), })) - -vi.mock('@/lib/workspace-files/orchestration', () => ({ - performDeleteWorkspaceFileItems: mockPerformDelete, +vi.mock('@/lib/api/server/rate-limit-context', () => ({ + recordRateLimitSnapshot: vi.fn(), + getRateLimitHeaders: vi.fn().mockReturnValue(null), +})) +vi.mock('@/lib/core/utils/request', () => ({ + generateRequestId: vi.fn().mockReturnValue('request-1'), + getClientIp: vi.fn().mockReturnValue('127.0.0.1'), +})) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGate })) +vi.mock('@/lib/workspace-files/application/archive-workspace-file-items', () => ({ + archiveWorkspaceFileItemsOperation: { + operation: { id: 'files.delete', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mockExecute, + }, })) import { POST } from '@/app/api/v2/files/bulk-delete/route' const WS = 'workspace-1' - const RATE_LIMIT_OK = { allowed: true, - userId: 'user-1', - keyType: 'workspace', limit: 100, remaining: 99, resetAt: new Date('2024-01-01T01:00:00Z'), -} - -const RATE_LIMIT_DENIED = { - allowed: false, - limit: 100, - remaining: 0, - resetAt: new Date('2024-01-01T01:00:00Z'), - retryAfterMs: 1000, + retryAfterMs: 0, } const callDelete = (body: unknown) => POST( new NextRequest('http://localhost:3000/api/v2/files/bulk-delete', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', 'x-api-key': 'key' }, body: JSON.stringify(body), }) ) @@ -56,42 +69,36 @@ const callDelete = (body: unknown) => describe('POST /api/v2/files/bulk-delete', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockPerformDelete.mockResolvedValue({ success: true, deletedItems: { files: 3, folders: 1 } }) + mockPreauth.mockResolvedValue(RATE_LIMIT_OK) + mockOperationRate.mockResolvedValue(RATE_LIMIT_OK) + mockGate.mockResolvedValue(null) + mockExecute.mockResolvedValue({ deletedItems: { files: 3, folders: 0 } }) }) it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - + mockGate.mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) const res = await callDelete({ workspaceId: WS, fileIds: ['wf_1'] }) - expect(res.status).toBe(404) - expect(mockPerformDelete).not.toHaveBeenCalled() + expect(mockExecute).not.toHaveBeenCalled() }) it('400s when the selection is empty', async () => { const res = await callDelete({ workspaceId: WS, fileIds: [] }) expect(res.status).toBe(400) expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(mockPerformDelete).not.toHaveBeenCalled() + expect(mockExecute).not.toHaveBeenCalled() }) - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', - }) + it('surfaces a forbidden collection operation', async () => { + const { OrchestrationError } = await import('@/lib/core/orchestration/types') + mockExecute.mockRejectedValue(new OrchestrationError('forbidden', 'Access denied')) const res = await callDelete({ workspaceId: WS, fileIds: ['wf_1'] }) expect(res.status).toBe(403) - expect(mockPerformDelete).not.toHaveBeenCalled() }) it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + mockPreauth.mockResolvedValue({ ...RATE_LIMIT_OK, allowed: false, remaining: 0 }) const res = await callDelete({ workspaceId: WS, fileIds: ['wf_1'] }) expect(res.status).toBe(429) expect((await res.json()).error.code).toBe('RATE_LIMITED') @@ -99,27 +106,17 @@ describe('POST /api/v2/files/bulk-delete', () => { it('deletes the selection and reports the file count', async () => { const res = await callDelete({ workspaceId: WS, fileIds: ['wf_1'] }) - const body = await res.json() - expect(res.status).toBe(200) - expect(body.data).toEqual({ deletedItems: { files: 3 } }) - expect(mockPerformDelete).toHaveBeenCalledWith({ - workspaceId: WS, - userId: 'user-1', - fileIds: ['wf_1'], - request: expect.anything(), - }) + expect((await res.json()).data).toEqual({ deletedItems: { files: 3 } }) + expect(mockExecute).toHaveBeenCalledWith( + expect.objectContaining({ input: { workspaceId: WS, fileIds: ['wf_1'] } }) + ) }) - it('maps a not_found errorCode to 404', async () => { - mockPerformDelete.mockResolvedValue({ - success: false, - error: 'File not found', - errorCode: 'not_found', - }) - + it('maps a not-found failure to 404', async () => { + const { OrchestrationError } = await import('@/lib/core/orchestration/types') + mockExecute.mockRejectedValue(new OrchestrationError('not_found', 'File not found')) const res = await callDelete({ workspaceId: WS, fileIds: ['wf_missing'] }) - expect(res.status).toBe(404) expect((await res.json()).error.code).toBe('NOT_FOUND') }) diff --git a/apps/sim/app/api/v2/files/bulk-delete/route.ts b/apps/sim/app/api/v2/files/bulk-delete/route.ts index accd962d9dc..2044c3fb6fc 100644 --- a/apps/sim/app/api/v2/files/bulk-delete/route.ts +++ b/apps/sim/app/api/v2/files/bulk-delete/route.ts @@ -1,40 +1,23 @@ import { v2BulkDeleteFilesContract } from '@/lib/api/contracts/v2/files' -import { messageForOrchestrationError } from '@/lib/core/orchestration/types' -import { performDeleteWorkspaceFileItems } from '@/lib/workspace-files/orchestration' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2Data, v2ErrorForOrchestration, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2FileErrorPolicies, + v2RateLimits, +} from '@/lib/api/server/routes' +import { archiveWorkspaceFileItemsOperation } from '@/lib/workspace-files/application/archive-workspace-file-items' +import { fileOperations } from '@/lib/workspace-files/application/operations' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** - * POST /api/v2/files/bulk-delete — Delete files. Folder deletion is owned by - * `/api/v2/files/folders` so this resource operation never accepts folder ids. - */ -export const POST = withPublicApiRouteHandler({ +export const POST = defineV2JsonRoute({ contract: v2BulkDeleteFilesContract, - rateLimitEndpoint: 'file-bulk-delete', - handler: async ({ request, input, auth: { userId, rateLimit } }) => { - const { workspaceId, fileIds } = input.body - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - const result = await performDeleteWorkspaceFileItems({ - workspaceId, - userId, - fileIds, - request, - }) - - if (!result.success || !result.deletedItems) { - return v2ErrorForOrchestration( - result.errorCode, - messageForOrchestrationError(result, 'Failed to delete files') - ) - } - - return v2Data({ deletedItems: { files: result.deletedItems.files } }, { rateLimit }) - }, + auth: v2ApiKeyAuth, + operation: fileOperations.delete, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.default, + mapInput: ({ body }) => ({ workspaceId: body.workspaceId, fileIds: body.fileIds }), + useCase: archiveWorkspaceFileItemsOperation, + present: ({ deletedItems }) => ({ data: { deletedItems: { files: deletedItems.files } } }), }) diff --git a/apps/sim/app/api/v2/files/folders/route.test.ts b/apps/sim/app/api/v2/files/folders/route.test.ts new file mode 100644 index 00000000000..f255960ce04 --- /dev/null +++ b/apps/sim/app/api/v2/files/folders/route.test.ts @@ -0,0 +1,223 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mocks, MockV2ApiKeyUnauthenticatedError } = vi.hoisted(() => { + class MockV2ApiKeyUnauthenticatedError extends Error {} + return { + mocks: { + authenticate: vi.fn(), + preauthRate: vi.fn(), + operationRate: vi.fn(), + gate: vi.fn(), + listFolders: vi.fn(), + createFolder: vi.fn(), + updateFolder: vi.fn(), + deleteFolder: vi.fn(), + }, + MockV2ApiKeyUnauthenticatedError, + } +}) + +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticate, + V2ApiKeyUnauthenticatedError: MockV2ApiKeyUnauthenticatedError, +})) +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mocks.preauthRate + checkRateLimitDirectOrThrow = mocks.operationRate + }, + getRateLimit: vi.fn().mockReturnValue({ + maxTokens: 100, + refillRate: 100, + refillIntervalMs: 60_000, + }), +})) +vi.mock('@/lib/api/server/rate-limit-context', () => ({ + recordRateLimitSnapshot: vi.fn(), + getRateLimitHeaders: vi.fn().mockReturnValue(null), +})) +vi.mock('@/lib/core/utils/request', () => ({ + generateRequestId: vi.fn().mockReturnValue('request-1'), + getClientIp: vi.fn().mockReturnValue('127.0.0.1'), +})) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) +vi.mock('@/lib/workspace-files/application/workspace-file-folders', () => ({ + listWorkspaceFileFoldersOperation: { + operation: { id: 'files.folders.list', minimumRole: 'read', workspaceApiKey: 'allow' }, + execute: mocks.listFolders, + }, + createWorkspaceFileFolderOperation: { + operation: { id: 'files.folders.create', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.createFolder, + }, + updateWorkspaceFileFolderOperation: { + operation: { id: 'files.folders.update', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.updateFolder, + }, + deleteWorkspaceFileFolderOperation: { + operation: { id: 'files.folders.delete', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.deleteFolder, + }, +})) + +import { DELETE, GET, PATCH, POST } from '@/app/api/v2/files/folders/route' + +const WORKSPACE_ID = 'workspace-1' +const PRINCIPAL = { kind: 'workspace_api_key' as const, workspaceId: WORKSPACE_ID, keyId: 'key-1' } +const AUTH = { + principal: PRINCIPAL, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: ['workspace:workspace-1'] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, +} +const RATE_LIMIT_OK = { + allowed: true, + limit: 100, + remaining: 99, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 0, +} +const folder = { + id: 'folder-1', + workspaceId: WORKSPACE_ID, + userId: 'owner-1', + name: 'Reports', + parentId: null, + path: '/Reports', + sortOrder: 0, + deletedAt: null, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), +} +const context = undefined + +function request(method: 'GET' | 'POST' | 'PATCH' | 'DELETE', url: string, body?: unknown) { + return new NextRequest(`http://localhost:3000${url}`, { + method, + headers: { + 'x-api-key': 'key', + ...(body === undefined ? {} : { 'Content-Type': 'application/json' }), + }, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }) +} + +describe('/api/v2/files/folders', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.authenticate.mockResolvedValue(AUTH) + mocks.preauthRate.mockResolvedValue(RATE_LIMIT_OK) + mocks.operationRate.mockResolvedValue(RATE_LIMIT_OK) + mocks.gate.mockResolvedValue(null) + mocks.listFolders.mockResolvedValue({ folders: [folder] }) + mocks.createFolder.mockResolvedValue({ folder }) + mocks.updateFolder.mockResolvedValue({ folder }) + mocks.deleteFolder.mockResolvedValue({ + deletedItems: { folders: 1, files: 2 }, + path: '/Reports', + }) + }) + + it('lists folders through the shared operation and v2 presenter', async () => { + const response = await GET( + request('GET', `/api/v2/files/folders?workspaceId=${WORKSPACE_ID}`), + context + ) + + expect(response.status).toBe(200) + expect((await response.json()).data).toEqual([ + { + name: 'Reports', + path: '/Reports', + parentPath: '/', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }, + ]) + expect(mocks.listFolders).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + workspaceId: WORKSPACE_ID, + parentPath: undefined, + search: undefined, + sortBy: 'name', + sortOrder: 'asc', + }, + request: expect.anything(), + }) + }) + + it('creates a folder from its canonical path', async () => { + const response = await POST( + request('POST', '/api/v2/files/folders', { workspaceId: WORKSPACE_ID, path: '/Reports' }), + context + ) + + expect(response.status).toBe(200) + expect((await response.json()).data.folder).toEqual({ + name: 'Reports', + path: '/Reports', + parentPath: '/', + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }) + expect(mocks.createFolder).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { workspaceId: WORKSPACE_ID, path: '/Reports' }, + request: expect.anything(), + }) + }) + + it('relocates a folder through the shared operation', async () => { + const response = await PATCH( + request('PATCH', '/api/v2/files/folders', { + workspaceId: WORKSPACE_ID, + path: '/Reports', + destinationPath: '/Archive/Reports', + }), + context + ) + + expect(response.status).toBe(200) + expect(mocks.updateFolder).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + workspaceId: WORKSPACE_ID, + path: '/Reports', + destinationPath: '/Archive/Reports', + }, + request: expect.anything(), + }) + }) + + it('deletes a folder and returns the v2 deletion result', async () => { + const response = await DELETE( + request( + 'DELETE', + `/api/v2/files/folders?workspaceId=${WORKSPACE_ID}&path=%2FReports&recursive=true` + ), + context + ) + + expect(response.status).toBe(200) + expect((await response.json()).data).toEqual({ + path: '/Reports', + deleted: true, + deletedItems: { folders: 1, files: 2 }, + }) + }) + + it('authenticates before parsing folder input', async () => { + mocks.authenticate.mockRejectedValueOnce(new MockV2ApiKeyUnauthenticatedError()) + + const response = await POST(request('POST', '/api/v2/files/folders', {}), context) + + expect(response.status).toBe(401) + expect(mocks.createFolder).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/v2/files/folders/route.ts b/apps/sim/app/api/v2/files/folders/route.ts index 7ed1d0b4aee..99dfc4f44de 100644 --- a/apps/sim/app/api/v2/files/folders/route.ts +++ b/apps/sim/app/api/v2/files/folders/route.ts @@ -4,109 +4,95 @@ import { v2ListFileFoldersContract, v2RelocateFileFolderContract, } from '@/lib/api/contracts/v2/files' -import { toFolderPathView } from '@/lib/folders/paths' -import { listActiveFolderRows, loadActiveFolderPathIndex } from '@/lib/folders/queries' import { - performCreateWorkspaceFileFolderAtPath, - performDeleteWorkspaceFileFolderByPath, - performRelocateWorkspaceFileFolderByPath, -} from '@/lib/workspace-files/orchestration/file-folder-lifecycle' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' + defineV2JsonRoute, + v2ApiKeyAuth, + v2FileErrorPolicies, + v2RateLimits, +} from '@/lib/api/server/routes' +import { fileOperations } from '@/lib/workspace-files/application/operations' import { - resolveFolderPathId, - toV2PathFolder, - v2FolderPathMutationError, -} from '@/app/api/v2/lib/folders' -import { v2CursorList, v2Data, v2Error, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' + createWorkspaceFileFolderOperation, + deleteWorkspaceFileFolderOperation, + listWorkspaceFileFoldersOperation, + updateWorkspaceFileFolderOperation, +} from '@/lib/workspace-files/application/workspace-file-folders' export const dynamic = 'force-dynamic' export const revalidate = 0 -export const GET = withPublicApiRouteHandler({ - contract: v2ListFileFoldersContract, - rateLimitEndpoint: 'files', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { workspaceId, parentPath, search, sortBy, sortOrder } = input.query - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) +function toV2Folder(folder: { name: string; path: string; createdAt: Date; updatedAt: Date }) { + const path = folder.path.startsWith('/') ? folder.path : `/${folder.path}` + const parentPath = path.includes('/') ? path.slice(0, path.lastIndexOf('/')) || '/' : '/' + return { + name: folder.name, + path, + parentPath, + createdAt: folder.createdAt.toISOString(), + updatedAt: folder.updatedAt.toISOString(), + } +} - const index = await loadActiveFolderPathIndex(workspaceId, 'file') - const parentId = parentPath === undefined ? undefined : resolveFolderPathId(index, parentPath) - if (parentPath !== undefined && parentId === undefined) { - return v2Error('NOT_FOUND', 'Folder not found') - } - const rows = await listActiveFolderRows(workspaceId, 'file', { - parentId, - search, - sortBy, - sortOrder, - }) - return v2CursorList( - rows.map((row) => toV2PathFolder(row, index, false)), - null, - { rateLimit } - ) - }, +export const GET = defineV2JsonRoute({ + contract: v2ListFileFoldersContract, + auth: v2ApiKeyAuth, + operation: fileOperations.listFolders, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.default, + mapInput: ({ query }) => ({ + workspaceId: query.workspaceId, + parentPath: query.parentPath, + search: query.search, + sortBy: query.sortBy, + sortOrder: query.sortOrder, + }), + useCase: listWorkspaceFileFoldersOperation, + present: ({ folders }) => ({ data: folders.map(toV2Folder), nextCursor: null }), }) -export const POST = withPublicApiRouteHandler({ +export const POST = defineV2JsonRoute({ contract: v2CreateFileFolderContract, - rateLimitEndpoint: 'files', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { workspaceId, path } = input.body - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - const result = await performCreateWorkspaceFileFolderAtPath({ workspaceId, userId, path }) - if (!result.success || !result.folder || !result.path) { - return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to create folder') - } - return v2Data( - { folder: toFolderPathView(result.folder, result.path) }, - { rateLimit, status: 201 } - ) - }, + auth: v2ApiKeyAuth, + operation: fileOperations.createFolder, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.default, + mapInput: ({ body }) => ({ workspaceId: body.workspaceId, path: body.path }), + useCase: createWorkspaceFileFolderOperation, + present: ({ folder }) => ({ data: { folder: toV2Folder(folder) } }), }) -export const PATCH = withPublicApiRouteHandler({ +export const PATCH = defineV2JsonRoute({ contract: v2RelocateFileFolderContract, - rateLimitEndpoint: 'files', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { workspaceId, path, destinationPath } = input.body - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - const result = await performRelocateWorkspaceFileFolderByPath({ - workspaceId, - userId, - path, - destinationPath, - }) - if (!result.success || !result.folder || !result.path) { - return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to move folder') - } - return v2Data({ folder: toFolderPathView(result.folder, result.path) }, { rateLimit }) - }, + auth: v2ApiKeyAuth, + operation: fileOperations.updateFolder, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.default, + mapInput: ({ body }) => ({ + workspaceId: body.workspaceId, + path: body.path, + destinationPath: body.destinationPath, + }), + useCase: updateWorkspaceFileFolderOperation, + present: ({ folder }) => ({ data: { folder: toV2Folder(folder) } }), }) -export const DELETE = withPublicApiRouteHandler({ +export const DELETE = defineV2JsonRoute({ contract: v2DeleteFileFolderContract, - rateLimitEndpoint: 'files', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { workspaceId, path, recursive } = input.query - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - const result = await performDeleteWorkspaceFileFolderByPath({ - workspaceId, - userId, - path, - recursive, - }) - if (!result.success || !result.deletedItems) { - return v2FolderPathMutationError(result.errorCode, result.error ?? 'Failed to delete folder') - } - return v2Data( - { path, deleted: true as const, deletedItems: result.deletedItems }, - { rateLimit } - ) - }, + auth: v2ApiKeyAuth, + operation: fileOperations.deleteFolder, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.default, + mapInput: ({ query }) => ({ + workspaceId: query.workspaceId, + path: query.path, + recursive: query.recursive, + }), + useCase: deleteWorkspaceFileFolderOperation, + present: ({ deletedItems, path }) => ({ + data: { + path: path ?? '/', + deleted: true as const, + deletedItems, + }, + }), }) diff --git a/apps/sim/app/api/v2/files/move/route.test.ts b/apps/sim/app/api/v2/files/move/route.test.ts index b6651476b8f..d192bf356b1 100644 --- a/apps/sim/app/api/v2/files/move/route.test.ts +++ b/apps/sim/app/api/v2/files/move/route.test.ts @@ -4,51 +4,65 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockCheckRateLimit, mockResolveWorkspaceAccess, mockPerformMove } = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockPerformMove: vi.fn(), +const { mockPreauth, mockOperationRate, mockGate, mockExecute } = vi.hoisted(() => ({ + mockPreauth: vi.fn(), + mockOperationRate: vi.fn(), + mockGate: vi.fn(), + mockExecute: vi.fn(), })) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: vi.fn().mockResolvedValue({ + principal: { kind: 'workspace_api_key', workspaceId: 'workspace-1', keyId: 'key-1' }, + rolloutUserId: 'owner-1', + rateLimitSubjectIds: ['workspace:workspace-1'], + rateLimitSubscription: null, + keyType: 'workspace', + }), + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) - -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +vi.mock('@/lib/core/rate-limiter', () => ({ + RateLimiter: class { + checkRateLimitDirect = mockPreauth + checkRateLimitDirectOrThrow = mockOperationRate + }, + getRateLimit: vi + .fn() + .mockReturnValue({ maxTokens: 100, refillRate: 100, refillIntervalMs: 60_000 }), })) - -vi.mock('@/lib/workspace-files/orchestration', () => ({ - performMoveWorkspaceFileItems: mockPerformMove, +vi.mock('@/lib/api/server/rate-limit-context', () => ({ + recordRateLimitSnapshot: vi.fn(), + getRateLimitHeaders: vi.fn().mockReturnValue(null), +})) +vi.mock('@/lib/core/utils/request', () => ({ + generateRequestId: vi.fn().mockReturnValue('request-1'), + getClientIp: vi.fn().mockReturnValue('127.0.0.1'), +})) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mockGate })) +vi.mock('@/lib/workspace-files/application/move-workspace-file-items', () => ({ + moveWorkspaceFileItemsOperation: { + operation: { id: 'files.move', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mockExecute, + }, })) import { POST } from '@/app/api/v2/files/move/route' const WS = 'workspace-1' - const RATE_LIMIT_OK = { allowed: true, - userId: 'user-1', - keyType: 'workspace', limit: 100, remaining: 99, resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 0, } - -const RATE_LIMIT_DENIED = { - allowed: false, - limit: 100, - remaining: 0, - resetAt: new Date('2024-01-01T01:00:00Z'), - retryAfterMs: 1000, -} +const RATE_LIMIT_DENIED = { ...RATE_LIMIT_OK, allowed: false, remaining: 0, retryAfterMs: 1000 } const callMove = (body: unknown) => POST( new NextRequest('http://localhost:3000/api/v2/files/move', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, + headers: { 'Content-Type': 'application/json', 'x-api-key': 'key' }, body: JSON.stringify(body), }) ) @@ -56,42 +70,37 @@ const callMove = (body: unknown) => describe('POST /api/v2/files/move', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockPerformMove.mockResolvedValue({ success: true, movedItems: { files: 2, folders: 0 } }) + mockPreauth.mockResolvedValue(RATE_LIMIT_OK) + mockOperationRate.mockResolvedValue(RATE_LIMIT_OK) + mockGate.mockResolvedValue(null) + mockExecute.mockResolvedValue({ movedItems: { files: 2, folders: 0 } }) }) it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - + mockGate.mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) const res = await callMove({ workspaceId: WS, fileIds: ['wf_1'] }) - expect(res.status).toBe(404) - expect(mockPerformMove).not.toHaveBeenCalled() + expect(mockExecute).not.toHaveBeenCalled() }) it('400s when the selection is empty', async () => { const res = await callMove({ workspaceId: WS }) expect(res.status).toBe(400) expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(mockPerformMove).not.toHaveBeenCalled() + expect(mockExecute).not.toHaveBeenCalled() }) - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', - }) + it('surfaces a forbidden collection operation', async () => { + const { OrchestrationError } = await import('@/lib/core/orchestration/types') + mockExecute.mockRejectedValue(new OrchestrationError('forbidden', 'Access denied')) const res = await callMove({ workspaceId: WS, fileIds: ['wf_1'] }) expect(res.status).toBe(403) - expect(mockPerformMove).not.toHaveBeenCalled() + expect(mockExecute).toHaveBeenCalledOnce() }) it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + mockPreauth.mockResolvedValue(RATE_LIMIT_DENIED) const res = await callMove({ workspaceId: WS, fileIds: ['wf_1'] }) expect(res.status).toBe(429) expect((await res.json()).error.code).toBe('RATE_LIMITED') @@ -103,40 +112,27 @@ describe('POST /api/v2/files/move', () => { fileIds: ['wf_1', 'wf_2'], targetFolderPath: '/Reports', }) - const body = await res.json() - expect(res.status).toBe(200) - expect(body.data).toEqual({ movedItems: { files: 2 } }) - expect(mockPerformMove).toHaveBeenCalledWith({ - workspaceId: WS, - userId: 'user-1', - fileIds: ['wf_1', 'wf_2'], - targetFolderPath: '/Reports', - }) + expect((await res.json()).data).toEqual({ movedItems: { files: 2 } }) + expect(mockExecute).toHaveBeenCalledWith( + expect.objectContaining({ + input: { workspaceId: WS, fileIds: ['wf_1', 'wf_2'], targetFolderPath: '/Reports' }, + }) + ) }) it('treats an omitted targetFolderPath as the workspace root', async () => { await callMove({ workspaceId: WS, fileIds: ['wf_1'] }) - expect(mockPerformMove).toHaveBeenCalledWith( - expect.objectContaining({ fileIds: ['wf_1'], targetFolderPath: '/' }) + expect(mockExecute).toHaveBeenCalledWith( + expect.objectContaining({ input: expect.objectContaining({ targetFolderPath: '/' }) }) ) }) - it('maps a conflict errorCode to 409 without partially applying', async () => { - mockPerformMove.mockResolvedValue({ - success: false, - error: 'A file named "data.csv" already exists in the destination folder', - errorCode: 'conflict', - }) - - const res = await callMove({ - workspaceId: WS, - fileIds: ['wf_1'], - targetFolderPath: '/Reports', - }) - const body = await res.json() - + it('maps a conflict error to 409', async () => { + const { OrchestrationError } = await import('@/lib/core/orchestration/types') + mockExecute.mockRejectedValue(new OrchestrationError('conflict', 'Name collision')) + const res = await callMove({ workspaceId: WS, fileIds: ['wf_1'] }) expect(res.status).toBe(409) - expect(body.error.code).toBe('CONFLICT') + expect((await res.json()).error.code).toBe('CONFLICT') }) }) diff --git a/apps/sim/app/api/v2/files/move/route.ts b/apps/sim/app/api/v2/files/move/route.ts index 9d306d1269b..6049c88ea56 100644 --- a/apps/sim/app/api/v2/files/move/route.ts +++ b/apps/sim/app/api/v2/files/move/route.ts @@ -1,44 +1,27 @@ import { v2MoveFileItemsContract } from '@/lib/api/contracts/v2/files' -import { messageForOrchestrationError } from '@/lib/core/orchestration/types' -import { performMoveWorkspaceFileItems } from '@/lib/workspace-files/orchestration' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { v2Data, v2ErrorForOrchestration, v2WorkspaceAccessError } from '@/app/api/v2/lib/response' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2FileErrorPolicies, + v2RateLimits, +} from '@/lib/api/server/routes' +import { moveWorkspaceFileItemsOperation } from '@/lib/workspace-files/application/move-workspace-file-items' +import { fileOperations } from '@/lib/workspace-files/application/operations' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** - * POST /api/v2/files/move — Move files into a folder. - * - * An omitted `targetFolderPath` moves the selection to the - * workspace root. The whole selection moves under one advisory lock, so a name - * collision at the destination fails the request as `CONFLICT` rather than - * partially applying. - */ -export const POST = withPublicApiRouteHandler({ +export const POST = defineV2JsonRoute({ contract: v2MoveFileItemsContract, - rateLimitEndpoint: 'file-move', - handler: async ({ input, auth: { userId, rateLimit } }) => { - const { workspaceId, fileIds, targetFolderPath } = input.body - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - const result = await performMoveWorkspaceFileItems({ - workspaceId, - userId, - fileIds, - targetFolderPath: targetFolderPath ?? '/', - }) - - if (!result.success || !result.movedItems) { - return v2ErrorForOrchestration( - result.errorCode, - messageForOrchestrationError(result, 'Failed to move file items') - ) - } - - return v2Data({ movedItems: { files: result.movedItems.files } }, { rateLimit }) - }, + auth: v2ApiKeyAuth, + operation: fileOperations.move, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.default, + mapInput: ({ body }) => ({ + workspaceId: body.workspaceId, + fileIds: body.fileIds, + targetFolderPath: body.targetFolderPath ?? '/', + }), + useCase: moveWorkspaceFileItemsOperation, + present: ({ movedItems }) => ({ data: { movedItems: { files: movedItems.files } } }), }) diff --git a/apps/sim/app/api/v2/files/route.test.ts b/apps/sim/app/api/v2/files/route.test.ts index 7bf7aa9f384..4b6508d5c20 100644 --- a/apps/sim/app/api/v2/files/route.test.ts +++ b/apps/sim/app/api/v2/files/route.test.ts @@ -4,521 +4,220 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockPerformCreateWorkspaceFile, - mockQueryWorkspaceFiles, - mockResolveWorkspaceAccess, - mockV2ApiGateError, - mockLoadActiveFolderPathIndex, - mockGetUserEmailsByIds, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockPerformCreateWorkspaceFile: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockQueryWorkspaceFiles: vi.fn(), - mockV2ApiGateError: vi.fn().mockResolvedValue(null), - mockLoadActiveFolderPathIndex: vi.fn(), - mockGetUserEmailsByIds: vi.fn(), +const mocks = vi.hoisted(() => ({ + authenticateV2ApiKey: vi.fn(), + checkRateLimitDirect: vi.fn(), + checkRateLimitDirectOrThrow: vi.fn(), + createFile: vi.fn(), + queryFiles: vi.fn(), + getUserEmailsByIds: vi.fn(), + gate: vi.fn(), })) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, +vi.mock('@/lib/workspace-files/application/create-workspace-file', () => ({ + createWorkspaceFile: { + operation: { id: 'files.create', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.createFile, + }, })) -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: mockV2ApiGateError, +vi.mock('@/lib/workspace-files/application/list-workspace-files', () => ({ + queryWorkspaceFilePage: { + operation: { id: 'files.list', minimumRole: 'read', workspaceApiKey: 'allow' }, + execute: mocks.queryFiles, + }, })) -vi.mock('@/lib/uploads/contexts/workspace', () => ({ - queryWorkspaceFiles: mockQueryWorkspaceFiles, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticateV2ApiKey, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) -vi.mock('@/lib/folders/queries', () => ({ - loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex, +vi.mock('@/lib/core/rate-limiter', () => ({ + getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }), + RateLimiter: class RateLimiter { + checkRateLimitDirect = mocks.checkRateLimitDirect + checkRateLimitDirectOrThrow = mocks.checkRateLimitDirectOrThrow + }, })) -vi.mock('@/lib/workspace-files/orchestration', () => ({ - MAX_WORKSPACE_FILE_INLINE_BODY_BYTES: 70 * 1024 * 1024, - performCreateWorkspaceFile: mockPerformCreateWorkspaceFile, -})) +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) vi.mock('@/lib/users/queries', () => ({ - getUserEmailsByIds: mockGetUserEmailsByIds, + getUserEmailsByIds: mocks.getUserEmailsByIds, requireResolvedUserEmail: (emails: Map, userId: string) => emails.get(userId)!, })) import { OrchestrationError } from '@/lib/core/orchestration/types' -import { MAX_WORKSPACE_FILE_INLINE_BODY_BYTES } from '@/lib/workspace-files/orchestration' import { GET, POST } from '@/app/api/v2/files/route' -const WS = 'workspace-1' -const FOLDER_ID = 'fold_1' - -const RATE_LIMIT_OK = { - allowed: true, - userId: 'user-1', - keyType: 'workspace', - limit: 100, - remaining: 99, - resetAt: new Date('2024-01-01T01:00:00Z'), -} - -const RATE_LIMIT_DENIED = { - allowed: false, - limit: 100, - remaining: 0, - resetAt: new Date('2024-01-01T01:00:00Z'), - retryAfterMs: 1000, +const WORKSPACE_ID = 'workspace-1' +const auth = { + principal: { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', + }, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, } - -function buildRecord(overrides: Record = {}) { - return { - id: 'wf_1', - workspaceId: WS, - name: 'data.csv', - key: 'workspace/ws/1-x-data.csv', - path: '/api/files/serve/x', - size: 1024, - type: 'text/csv', - uploadedBy: 'user-1', - folderId: null, - folderPath: null, - uploadedAt: new Date('2024-01-01T00:00:00Z'), - updatedAt: new Date('2024-01-02T00:00:00Z'), - ...overrides, - } -} - -/** What the route forwards for a bare `?workspaceId=` list. */ -const DEFAULT_LIST_ARGS = { - folderId: undefined, - search: undefined, - sortBy: 'uploadedAt', - sortOrder: 'asc', - limit: 100, - after: undefined, +const FILE = { + id: 'wf_1', + workspaceId: WORKSPACE_ID, + name: 'notes.md', + key: `workspace/${WORKSPACE_ID}/notes.md`, + path: '/api/files/serve/notes.md?context=workspace', + size: 0, + type: 'text/markdown', + uploadedBy: 'user-1', + folderId: null, + folderPath: null, + uploadedAt: new Date('2026-08-04T00:00:00.000Z'), + updatedAt: new Date('2026-08-05T00:00:00.000Z'), } -const callList = (query: string) => - GET(new NextRequest(`http://localhost:3000/api/v2/files?${query}`)) - -function createRequest(body: Record) { +function createRequest(body: unknown): NextRequest { return new NextRequest('http://localhost:3000/api/v2/files', { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), + headers: { 'content-type': 'application/json', 'x-api-key': 'secret' }, + body: typeof body === 'string' ? body : JSON.stringify(body), }) } -describe('GET /api/v2/files', () => { +describe('/api/v2/files', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockQueryWorkspaceFiles.mockResolvedValue({ files: [buildRecord()], nextKeys: null }) - mockGetUserEmailsByIds.mockResolvedValue(new Map([['user-1', 'ada@example.com']])) - mockLoadActiveFolderPathIndex.mockResolvedValue({ - rowById: new Map([['fold_1', { id: 'fold_1', name: 'Reports', parentId: null }]]), - pathById: new Map([['fold_1', '/Reports']]), - idByPath: new Map([ - ['/Reports', 'fold_1'], - ['/Fixtures', 'fold_1'], - ]), + mocks.authenticateV2ApiKey.mockResolvedValue(auth) + mocks.gate.mockResolvedValue(null) + mocks.checkRateLimitDirect.mockResolvedValue({ + allowed: true, + remaining: 599, + resetAt: new Date('2026-08-04T01:00:00.000Z'), }) - }) - - it('returns 404 when the v2 API surface flag is off', async () => { - const { v2ApiGateError } = await import('@/app/api/v2/lib/gate') - const { v2Error } = await import('@/app/api/v2/lib/response') - vi.mocked(v2ApiGateError).mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const res = await callList(`workspaceId=${WS}`) - - expect(res.status).toBe(404) - expect((await res.json()).error.code).toBe('NOT_FOUND') - expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled() - }) - - it('400s when workspaceId is missing', async () => { - const res = await callList('limit=10') - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled() - }) - - it('400s on a scope outside the enum', async () => { - const res = await callList(`workspaceId=${WS}&scope=everything`) - expect(res.status).toBe(400) - expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled() - }) - - it('surfaces an access-denied failure in the v2 error envelope', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', + mocks.checkRateLimitDirectOrThrow.mockResolvedValue({ + allowed: true, + remaining: 99, + resetAt: new Date('2026-08-04T01:00:00.000Z'), }) - const res = await callList(`workspaceId=${WS}`) - expect(res.status).toBe(403) - expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled() - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callList(`workspaceId=${WS}`) - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') - }) - - it('returns the public file shape including folder and updatedAt', async () => { - mockQueryWorkspaceFiles.mockResolvedValue({ - files: [buildRecord({ folderId: FOLDER_ID, folderPath: 'Reports/Q1' })], - nextKeys: null, + mocks.queryFiles.mockResolvedValue({ + files: [FILE], + nextKeys: undefined, + cursorSort: 'name:asc', }) - - const res = await callList(`workspaceId=${WS}`) - const body = await res.json() - - expect(res.status).toBe(200) - expect(body.nextCursor).toBeNull() - expect(body.data).toEqual([ - { - id: 'wf_1', - name: 'data.csv', - size: 1024, - type: 'text/csv', - key: 'workspace/ws/1-x-data.csv', - folderPath: '/Reports/Q1', - uploadedByEmail: 'ada@example.com', - uploadedAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-02T00:00:00.000Z', - }, - ]) - expect(mockQueryWorkspaceFiles).toHaveBeenCalledWith(WS, DEFAULT_LIST_ARGS) + mocks.createFile.mockResolvedValue({ file: FILE }) + mocks.getUserEmailsByIds.mockResolvedValue(new Map([['user-1', 'ada@example.com']])) }) - it('lists active files only and rejects the removed archived scope', async () => { - await callList(`workspaceId=${WS}`) - expect(mockQueryWorkspaceFiles).toHaveBeenCalledWith(WS, DEFAULT_LIST_ARGS) + it('authenticates and charges before validating list input', async () => { + const response = await GET(new NextRequest('http://localhost:3000/api/v2/files')) - const res = await callList(`workspaceId=${WS}&scope=archived`) - expect(res.status).toBe(400) + expect(response.status).toBe(400) + expect(mocks.authenticateV2ApiKey).toHaveBeenCalled() + expect(mocks.checkRateLimitDirectOrThrow).toHaveBeenCalledTimes(2) + expect(mocks.queryFiles).not.toHaveBeenCalled() }) - it('forwards search, folder, and sort into the query rather than filtering the result', async () => { - await callList( - `workspaceId=${WS}&search=report&folderPath=${encodeURIComponent('/Reports')}&sortBy=name&sortOrder=desc` + it('lists through the shared use case and v2 presenter', async () => { + const request = new NextRequest( + `http://localhost:3000/api/v2/files?workspaceId=${WORKSPACE_ID}&sortBy=name` ) - - expect(mockQueryWorkspaceFiles).toHaveBeenCalledWith(WS, { - ...DEFAULT_LIST_ARGS, - folderId: FOLDER_ID, - search: 'report', - sortBy: 'name', - sortOrder: 'desc', - }) - }) - - it('treats folderPath=/ as root-only while omission lists every folder', async () => { - await callList(`workspaceId=${WS}&folderPath=%2F`) - - expect(mockQueryWorkspaceFiles).toHaveBeenCalledWith(WS, { - ...DEFAULT_LIST_ARGS, - folderId: null, - }) - }) - - it('400s on a sort field outside the enum instead of passing it toward the query', async () => { - const res = await callList(`workspaceId=${WS}&sortBy=name;DROP TABLE workspace_files`) - - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled() - }) - - it('400s on an empty search rather than treating it as unsearched', async () => { - const res = await callList(`workspaceId=${WS}&search=`) - - expect(res.status).toBe(400) - expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled() - }) - - it('emits a cursor stamped with the sort and resumes from its keys', async () => { - mockQueryWorkspaceFiles.mockResolvedValue({ - files: [buildRecord()], - nextKeys: ['data.csv', 'wf_1'], - }) - - const first = await callList(`workspaceId=${WS}&sortBy=name`) - const { nextCursor } = await first.json() - expect(nextCursor).not.toBeNull() - - await callList(`workspaceId=${WS}&sortBy=name&cursor=${encodeURIComponent(nextCursor)}`) - - expect(mockQueryWorkspaceFiles).toHaveBeenLastCalledWith(WS, { - ...DEFAULT_LIST_ARGS, - sortBy: 'name', - after: ['data.csv', 'wf_1'], - }) - }) - - it('400s when a cursor is replayed under a different sort', async () => { - mockQueryWorkspaceFiles.mockResolvedValue({ - files: [buildRecord()], - nextKeys: ['data.csv', 'wf_1'], + const response = await GET(request) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + data: [ + { + id: FILE.id, + name: 'notes.md', + size: 0, + type: 'text/markdown', + key: FILE.key, + folderPath: '/', + uploadedByEmail: 'ada@example.com', + uploadedAt: '2026-08-04T00:00:00.000Z', + updatedAt: '2026-08-05T00:00:00.000Z', + }, + ], + nextCursor: null, + }) + expect(mocks.queryFiles).toHaveBeenCalledWith({ + principal: auth.principal, + input: expect.objectContaining({ + workspaceId: WORKSPACE_ID, + sortBy: 'name', + sortOrder: 'asc', + limit: 100, + }), + request, }) - - const first = await callList(`workspaceId=${WS}&sortBy=name`) - const { nextCursor } = await first.json() - mockQueryWorkspaceFiles.mockClear() - - const res = await callList( - `workspaceId=${WS}&sortBy=size&cursor=${encodeURIComponent(nextCursor)}` - ) - - expect(res.status).toBe(400) - expect((await res.json()).error.message).toMatch(/cursor does not match/i) - expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled() - }) - - it('400s on a malformed cursor instead of silently restarting from page one', async () => { - const res = await callList(`workspaceId=${WS}&cursor=not-a-cursor`) - - expect(res.status).toBe(400) - expect(mockQueryWorkspaceFiles).not.toHaveBeenCalled() }) - it('400s when the cursor carries values the sort cannot hold', async () => { - mockQueryWorkspaceFiles.mockRejectedValue( - new OrchestrationError('validation', 'cursor does not match the requested sortBy/sortOrder.') + it('rejects malformed cursors before the application service', async () => { + const response = await GET( + new NextRequest( + `http://localhost:3000/api/v2/files?workspaceId=${WORKSPACE_ID}&cursor=not-a-cursor` + ) ) - const cursor = Buffer.from( - JSON.stringify({ sort: 'uploadedAt:asc', keys: ['not-a-date', 'wf_1'] }) - ).toString('base64') - - const res = await callList(`workspaceId=${WS}&cursor=${encodeURIComponent(cursor)}`) - - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - }) - - it('terminates pagination when the query reports no further keys', async () => { - mockQueryWorkspaceFiles.mockResolvedValue({ files: [buildRecord()], nextKeys: null }) - - const res = await callList(`workspaceId=${WS}&search=data`) - - expect((await res.json()).nextCursor).toBeNull() - }) -}) - -describe('POST /api/v2/files', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockV2ApiGateError.mockResolvedValue(null) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockPerformCreateWorkspaceFile.mockResolvedValue({ - success: true, - file: buildRecord({ name: 'untitled.md', size: 0, type: 'text/markdown' }), - }) - mockGetUserEmailsByIds.mockResolvedValue(new Map([['user-1', 'ada@example.com']])) - }) - - it('creates an empty exact-name file with an inferred MIME type', async () => { - const request = createRequest({ workspaceId: WS, name: 'untitled.md' }) - - const response = await POST(request) - expect(response.status).toBe(201) - await expect(response.json()).resolves.toMatchObject({ - data: { id: 'wf_1', name: 'untitled.md', size: 0, type: 'text/markdown' }, - }) - expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith(RATE_LIMIT_OK, 'user-1', WS, 'write') - expect(mockPerformCreateWorkspaceFile).toHaveBeenCalledWith({ - workspaceId: WS, - userId: 'user-1', - name: 'untitled.md', - contentType: 'text/markdown', - folderPath: '/', - content: Buffer.alloc(0), - exactName: true, - request, - }) + expect(response.status).toBe(400) + expect(mocks.queryFiles).not.toHaveBeenCalled() }) - it('decodes initialized base64 content before orchestration', async () => { - mockPerformCreateWorkspaceFile.mockResolvedValue({ - success: true, - file: buildRecord({ - name: 'seed.bin', - size: 3, - type: 'application/octet-stream', - folderId: FOLDER_ID, - folderPath: 'Fixtures', - }), - }) + it('creates through the workspace-key principal without human analytics', async () => { const request = createRequest({ - workspaceId: WS, - name: 'seed.bin', - contentType: 'application/octet-stream', - folderPath: '/Fixtures', - content: Buffer.from([1, 2, 3]).toString('base64'), + workspaceId: WORKSPACE_ID, + name: 'notes.md', + content: 'TQ==', encoding: 'base64', }) - const response = await POST(request) expect(response.status).toBe(201) - expect(mockPerformCreateWorkspaceFile).toHaveBeenCalledWith( - expect.objectContaining({ - workspaceId: WS, - name: 'seed.bin', - contentType: 'application/octet-stream', - folderPath: '/Fixtures', - content: Buffer.from([1, 2, 3]), + expect((await response.json()).data.name).toBe('notes.md') + expect(mocks.createFile).toHaveBeenCalledWith({ + principal: auth.principal, + input: { + workspaceId: WORKSPACE_ID, + name: 'notes.md', + contentType: 'text/markdown', + content: 'TQ==', + encoding: 'base64', + folderPath: '/', exactName: true, - }) - ) + }, + request, + }) }) - it('rejects malformed base64 before workspace access or orchestration', async () => { + it('rejects malformed base64 after authentication and rate limiting', async () => { const response = await POST( createRequest({ - workspaceId: WS, - name: 'seed.bin', + workspaceId: WORKSPACE_ID, + name: 'notes.md', content: 'not-base64!', encoding: 'base64', }) ) expect(response.status).toBe(400) - await expect(response.json()).resolves.toMatchObject({ - error: { code: 'BAD_REQUEST' }, - }) - expect(mockResolveWorkspaceAccess).not.toHaveBeenCalled() - expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled() + expect(mocks.authenticateV2ApiKey).toHaveBeenCalled() + expect(mocks.checkRateLimitDirectOrThrow).toHaveBeenCalledTimes(2) + expect(mocks.createFile).not.toHaveBeenCalled() }) - it('accepts empty base64 as a zero-byte file', async () => { - const response = await POST( - createRequest({ workspaceId: WS, name: 'empty.bin', content: '', encoding: 'base64' }) - ) - - expect(response.status).toBe(201) - expect(mockPerformCreateWorkspaceFile).toHaveBeenCalledWith( - expect.objectContaining({ content: Buffer.alloc(0) }) - ) - }) + it('renders typed conflicts and hides unknown errors', async () => { + mocks.createFile.mockRejectedValueOnce(new OrchestrationError('conflict', 'Name exists')) + const conflict = await POST(createRequest({ workspaceId: WORKSPACE_ID, name: 'notes.md' })) + expect(conflict.status).toBe(409) + expect((await conflict.json()).error.code).toBe('CONFLICT') - it('returns the canonical v2 envelope when the JSON body exceeds the inline limit', async () => { - const request = new NextRequest('http://localhost:3000/api/v2/files', { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'Content-Length': String(MAX_WORKSPACE_FILE_INLINE_BODY_BYTES + 1), - }, - body: '{}', + mocks.createFile.mockRejectedValueOnce(new Error('database details')) + const unexpected = await POST(createRequest({ workspaceId: WORKSPACE_ID, name: 'notes.md' })) + expect(unexpected.status).toBe(500) + expect(await unexpected.json()).toMatchObject({ + error: { code: 'INTERNAL_ERROR', message: 'Internal server error' }, }) - - const response = await POST(request) - - expect(response.status).toBe(413) - await expect(response.json()).resolves.toMatchObject({ - error: { code: 'PAYLOAD_TOO_LARGE', message: 'Request body is too large' }, - }) - expect(mockResolveWorkspaceAccess).not.toHaveBeenCalled() - expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled() - }) - - it('returns the canonical v2 envelope for malformed JSON', async () => { - const request = new NextRequest('http://localhost:3000/api/v2/files', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: '{not-json', - }) - - const response = await POST(request) - - expect(response.status).toBe(400) - await expect(response.json()).resolves.toMatchObject({ - error: { code: 'BAD_REQUEST', message: 'Request body must be valid JSON' }, - }) - expect(mockResolveWorkspaceAccess).not.toHaveBeenCalled() - expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled() - }) - - it.each([ - { - label: 'name conflict', - result: { - success: false, - error: 'A file with this name already exists', - errorCode: 'conflict', - }, - status: 409, - code: 'CONFLICT', - message: 'A file with this name already exists', - }, - { - label: 'internal orchestration failure', - result: { success: false, error: 'database connection details', errorCode: 'internal' }, - status: 500, - code: 'INTERNAL_ERROR', - message: 'Internal server error', - }, - ])('maps a $label into the v2 error envelope', async ({ result, status, code, message }) => { - mockPerformCreateWorkspaceFile.mockResolvedValue(result) - - const response = await POST(createRequest({ workspaceId: WS, name: 'untitled.md' })) - - expect(response.status).toBe(status) - await expect(response.json()).resolves.toMatchObject({ error: { code, message } }) - }) - - it('returns the auth failure before gating, access checks, or orchestration', async () => { - mockCheckRateLimit.mockResolvedValue({ - allowed: false, - error: 'Invalid API key', - limit: 100, - remaining: 0, - resetAt: RATE_LIMIT_OK.resetAt, - }) - - const response = await POST(createRequest({ workspaceId: WS, name: 'untitled.md' })) - - expect(response.status).toBe(401) - await expect(response.json()).resolves.toMatchObject({ - error: { code: 'UNAUTHORIZED', message: 'Invalid API key' }, - }) - expect(mockV2ApiGateError).not.toHaveBeenCalled() - expect(mockResolveWorkspaceAccess).not.toHaveBeenCalled() - expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled() - }) - - it('returns the v2 gate failure before access checks or orchestration', async () => { - const { v2Error } = await import('@/app/api/v2/lib/response') - mockV2ApiGateError.mockResolvedValueOnce(v2Error('NOT_FOUND', 'Not found')) - - const response = await POST(createRequest({ workspaceId: WS, name: 'untitled.md' })) - - expect(response.status).toBe(404) - expect(mockResolveWorkspaceAccess).not.toHaveBeenCalled() - expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled() - }) - - it('requires workspace write access before orchestration', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', - }) - - const response = await POST(createRequest({ workspaceId: WS, name: 'untitled.md' })) - - expect(response.status).toBe(403) - expect(mockResolveWorkspaceAccess).toHaveBeenCalledWith(RATE_LIMIT_OK, 'user-1', WS, 'write') - expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/v2/files/route.ts b/apps/sim/app/api/v2/files/route.ts index bd8fa79e89a..97b87d8aa29 100644 --- a/apps/sim/app/api/v2/files/route.ts +++ b/apps/sim/app/api/v2/files/route.ts @@ -3,117 +3,82 @@ import { v2CreateFileContract, v2ListFilesContract, } from '@/lib/api/contracts/v2/files' -import { messageForOrchestrationError } from '@/lib/core/orchestration/types' -import { loadActiveFolderPathIndex } from '@/lib/folders/queries' -import { queryWorkspaceFiles } from '@/lib/uploads/contexts/workspace' -import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' +import { INVALID_CURSOR_MESSAGE } from '@/lib/api/list-query' import { - MAX_WORKSPACE_FILE_INLINE_BODY_BYTES, - performCreateWorkspaceFile, -} from '@/lib/workspace-files/orchestration' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' + defineV2JsonRoute, + v2ApiKeyAuth, + v2FileErrorPolicies, + v2RateLimits, +} from '@/lib/api/server/routes' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' +import { createWorkspaceFile } from '@/lib/workspace-files/application/create-workspace-file' +import { queryWorkspaceFilePage } from '@/lib/workspace-files/application/list-workspace-files' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { MAX_WORKSPACE_FILE_INLINE_BODY_BYTES } from '@/lib/workspace-files/orchestration' import { toV2File, toV2Files } from '@/app/api/v2/files/utils' -import { resolveFolderPathId } from '@/app/api/v2/lib/folders' import { cursorSortKey, decodeSortedCursor, encodeSortedCursor, - v2CaughtOrchestrationError, - v2CursorList, - v2CursorSortError, - v2Data, v2Error, - v2ErrorForOrchestration, - v2WorkspaceAccessError, } from '@/app/api/v2/lib/response' export const dynamic = 'force-dynamic' export const revalidate = 0 -/** - * GET /api/v2/files — List files in a workspace with search, sort, and cursor - * pagination. - * - * Filtering, ordering, and the page slice all run inside - * {@link queryWorkspaceFiles}' query. The route only translates the validated - * params and the opaque cursor, so a `search` never costs a full-workspace read. - */ -export const GET = withPublicApiRouteHandler({ +/** GET /api/v2/files — List files with search, sort, and cursor pagination. */ +export const GET = defineV2JsonRoute({ contract: v2ListFilesContract, - rateLimitEndpoint: 'files', - handler: async ({ input, auth: { userId, rateLimit } }) => { - try { - const { workspaceId, folderPath, search, sortBy, sortOrder, limit, cursor } = input.query - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - - const folderIndex = await loadActiveFolderPathIndex(workspaceId, 'file') - const folderId = - folderPath === undefined ? undefined : resolveFolderPathId(folderIndex, folderPath) - if (folderPath !== undefined && folderId === undefined) { - return v2Error('NOT_FOUND', 'Folder not found') - } - - const sort = cursorSortKey(sortBy, sortOrder) - const decoded = decodeSortedCursor(cursor, sort) - if (decoded.status === 'invalid') return v2CursorSortError() - - const { files, nextKeys } = await queryWorkspaceFiles(workspaceId, { - folderId, - search, - sortBy, - sortOrder, - limit, - after: decoded.status === 'ok' ? decoded.keys : undefined, - }) - - const items: V2File[] = await toV2Files(files) - const nextCursor = nextKeys ? encodeSortedCursor(sort, nextKeys) : null - - return v2CursorList(items, nextCursor, { rateLimit }) - } catch (error) { - // A cursor that doesn't fit the requested sort arrives classified as `validation` → 400. - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - - throw error + auth: v2ApiKeyAuth, + operation: fileOperations.list, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.default, + mapInput: ({ query }) => { + const cursorSort = cursorSortKey(query.sortBy, query.sortOrder) + const decoded = decodeSortedCursor(query.cursor, cursorSort) + if (decoded.status === 'invalid') { + throw new OrchestrationError('validation', INVALID_CURSOR_MESSAGE) + } + return { + workspaceId: query.workspaceId, + folderPath: query.folderPath, + search: query.search, + sortBy: query.sortBy, + sortOrder: query.sortOrder, + limit: query.limit, + after: decoded.status === 'ok' ? decoded.keys : undefined, + cursorSort, } }, + useCase: queryWorkspaceFilePage, + present: async ({ files, nextKeys, cursorSort }) => { + const items: V2File[] = await toV2Files(files) + return { data: items, nextCursor: nextKeys ? encodeSortedCursor(cursorSort, nextKeys) : null } + }, }) -/** POST /api/v2/files — Create an authored workspace file, optionally with initial content. */ -export const POST = withPublicApiRouteHandler({ +/** POST /api/v2/files — Create an authored workspace file. */ +export const POST = defineV2JsonRoute({ contract: v2CreateFileContract, - rateLimitEndpoint: 'files', + auth: v2ApiKeyAuth, + operation: fileOperations.create, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.default, parseOptions: { invalidJsonResponse: () => v2Error('BAD_REQUEST', 'Request body must be valid JSON'), maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES, payloadTooLargeResponse: () => v2Error('PAYLOAD_TOO_LARGE', 'Request body is too large'), }, - handler: async ({ request, input, auth: { userId, rateLimit } }) => { - const { workspaceId, name, contentType, folderPath, content, encoding } = input.body - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - const result = await performCreateWorkspaceFile({ - workspaceId, - userId, - name, - contentType: contentType ?? getMimeTypeFromExtension(getFileExtension(name)), - folderPath: folderPath ?? '/', - content: Buffer.from(content, encoding), - exactName: true, - request, - }) - if (!result.success || !result.file) { - return v2ErrorForOrchestration( - result.errorCode, - messageForOrchestrationError(result, 'Failed to create file') - ) - } - - return v2Data(await toV2File(result.file), { rateLimit, status: 201 }) - }, + mapInput: ({ body }) => ({ + workspaceId: body.workspaceId, + name: body.name, + contentType: body.contentType ?? getMimeTypeFromExtension(getFileExtension(body.name)), + content: body.content, + encoding: body.encoding, + folderPath: body.folderPath ?? '/', + exactName: true, + }), + useCase: createWorkspaceFile, + present: async ({ file }) => ({ data: await toV2File(file) }), }) diff --git a/apps/sim/app/api/v2/files/uploads/[uploadId]/complete/route.ts b/apps/sim/app/api/v2/files/uploads/[uploadId]/complete/route.ts index 87d477083ab..42cee5756b3 100644 --- a/apps/sim/app/api/v2/files/uploads/[uploadId]/complete/route.ts +++ b/apps/sim/app/api/v2/files/uploads/[uploadId]/complete/route.ts @@ -1,48 +1,22 @@ import { v2CompleteFileUploadContract } from '@/lib/api/contracts/v2/files' -import { completeUploadSession, getOwnedUploadSession } from '@/lib/uploads/upload-session/service' -import { finalizeWorkspaceFileUpload } from '@/app/api/files/uploads/finalizers' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { toV2FileUpload } from '@/app/api/v2/files/uploads/utils' -import { - v2CaughtOrchestrationError, - v2Data, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { completeWorkspaceFileUploadOperation } from '@/lib/uploads/upload-session/application' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { toV2FileUpload, v2UploadControlError } from '@/app/api/v2/files/uploads/utils' -export const POST = withPublicApiRouteHandler({ +export const POST = defineV2JsonRoute({ contract: v2CompleteFileUploadContract, - rateLimitEndpoint: 'files', - handler: async ({ request, input, auth: { userId, rateLimit } }) => { - try { - const { uploadId } = input.params - const { workspaceId } = input.query - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - const session = await getOwnedUploadSession({ - uploadId, - workspaceId, - userId, - purpose: 'workspace_file', - uploadToken: input.headers['upload-token'], - }) - const result = await completeUploadSession({ - session, - finalize: async (claimed) => { - const finalized = await finalizeWorkspaceFileUpload({ - session: claimed, - actor: { id: userId }, - request, - source: 'api', - }) - return { value: finalized.file, completedFileId: finalized.file.id } - }, - }) - return v2Data(await toV2FileUpload(result.session, result.value), { rateLimit }) - } catch (error) { - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - throw error - } - }, + auth: v2ApiKeyAuth, + operation: fileOperations.uploadComplete, + rateLimit: v2RateLimits.publicApi, + errorPolicy: { render: v2UploadControlError }, + mapInput: ({ params, query, headers }) => ({ + uploadId: params.uploadId, + workspaceId: query.workspaceId, + uploadToken: headers['upload-token'], + }), + useCase: completeWorkspaceFileUploadOperation, + present: async (result) => ({ + data: await toV2FileUpload(result.session, result.value), + }), }) diff --git a/apps/sim/app/api/v2/files/uploads/[uploadId]/parts/route.ts b/apps/sim/app/api/v2/files/uploads/[uploadId]/parts/route.ts index 926feab0e97..9261a7fd471 100644 --- a/apps/sim/app/api/v2/files/uploads/[uploadId]/parts/route.ts +++ b/apps/sim/app/api/v2/files/uploads/[uploadId]/parts/route.ts @@ -1,39 +1,21 @@ import { v2CreateFileUploadPartUrlsContract } from '@/lib/api/contracts/v2/files' -import { createUploadPartUrls, getOwnedUploadSession } from '@/lib/uploads/upload-session/service' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { - v2CaughtOrchestrationError, - v2Data, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { issueWorkspaceFileUploadPartsOperation } from '@/lib/uploads/upload-session/application' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { v2UploadControlError } from '@/app/api/v2/files/uploads/utils' -export const POST = withPublicApiRouteHandler({ +export const POST = defineV2JsonRoute({ contract: v2CreateFileUploadPartUrlsContract, - rateLimitEndpoint: 'files', - handler: async ({ request, input, auth: { userId, rateLimit } }) => { - try { - const { uploadId } = input.params - const { workspaceId } = input.query - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - const session = await getOwnedUploadSession({ - uploadId, - workspaceId, - userId, - purpose: 'workspace_file', - uploadToken: input.headers['upload-token'], - }) - const parts = await createUploadPartUrls({ - session, - partNumbers: input.body.partNumbers, - localOrigin: request.nextUrl.origin, - }) - return v2Data({ parts }, { rateLimit }) - } catch (error) { - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - throw error - } - }, + auth: v2ApiKeyAuth, + operation: fileOperations.uploadParts, + rateLimit: v2RateLimits.publicApi, + errorPolicy: { render: v2UploadControlError }, + mapInput: ({ params, query, headers, body }) => ({ + uploadId: params.uploadId, + workspaceId: query.workspaceId, + uploadToken: headers['upload-token'], + partNumbers: body.partNumbers, + }), + useCase: issueWorkspaceFileUploadPartsOperation, + present: ({ parts }) => ({ data: { parts } }), }) diff --git a/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts b/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts index 49d085ffe68..f55e531f199 100644 --- a/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts +++ b/apps/sim/app/api/v2/files/uploads/[uploadId]/route.ts @@ -1,36 +1,20 @@ import { v2AbortFileUploadContract } from '@/lib/api/contracts/v2/files' -import { abortUploadSession, getOwnedUploadSession } from '@/lib/uploads/upload-session/service' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { toV2FileUpload } from '@/app/api/v2/files/uploads/utils' -import { - v2CaughtOrchestrationError, - v2Data, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { abortWorkspaceFileUploadOperation } from '@/lib/uploads/upload-session/application' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { toV2FileUpload, v2UploadControlError } from '@/app/api/v2/files/uploads/utils' -export const DELETE = withPublicApiRouteHandler({ +export const DELETE = defineV2JsonRoute({ contract: v2AbortFileUploadContract, - rateLimitEndpoint: 'files', - handler: async ({ input, auth: { userId, rateLimit } }) => { - try { - const { uploadId } = input.params - const { workspaceId } = input.query - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - const session = await getOwnedUploadSession({ - uploadId, - workspaceId, - userId, - purpose: 'workspace_file', - uploadToken: input.headers['upload-token'], - }) - const aborted = await abortUploadSession(session) - return v2Data(await toV2FileUpload(aborted, null), { rateLimit }) - } catch (error) { - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - throw error - } - }, + auth: v2ApiKeyAuth, + operation: fileOperations.uploadCancel, + rateLimit: v2RateLimits.publicApi, + errorPolicy: { render: v2UploadControlError }, + mapInput: ({ params, query, headers }) => ({ + uploadId: params.uploadId, + workspaceId: query.workspaceId, + uploadToken: headers['upload-token'], + }), + useCase: abortWorkspaceFileUploadOperation, + present: async (session) => ({ data: await toV2FileUpload(session, null) }), }) diff --git a/apps/sim/app/api/v2/files/uploads/route.test.ts b/apps/sim/app/api/v2/files/uploads/route.test.ts index 884dfbe87ce..27fdc5fca91 100644 --- a/apps/sim/app/api/v2/files/uploads/route.test.ts +++ b/apps/sim/app/api/v2/files/uploads/route.test.ts @@ -4,209 +4,151 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockCheckRateLimit, - mockResolveWorkspaceAccess, - mockCreateUploadSession, - mockLoadActiveFolderPathIndex, - mockWithFolderTreeLock, -} = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockCreateUploadSession: vi.fn(), - mockLoadActiveFolderPathIndex: vi.fn(), - mockWithFolderTreeLock: vi.fn(), +const mocks = vi.hoisted(() => ({ + authenticateV2ApiKey: vi.fn(), + checkRateLimitDirect: vi.fn(), + checkRateLimitDirectOrThrow: vi.fn(), + createUpload: vi.fn(), + gate: vi.fn(), })) -vi.mock('@/app/api/v1/middleware', () => ({ - checkRateLimit: mockCheckRateLimit, - resolveWorkspaceAccess: mockResolveWorkspaceAccess, -})) - -vi.mock('@/app/api/v2/lib/gate', () => ({ - v2ApiGateError: vi.fn().mockResolvedValue(null), +vi.mock('@/lib/uploads/upload-session/application', () => ({ + createWorkspaceFileUploadOperation: { + operation: { id: 'files.upload.create', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.createUpload, + }, })) -vi.mock('@/lib/folders/queries', () => ({ - loadActiveFolderPathIndex: mockLoadActiveFolderPathIndex, +vi.mock('@/lib/api/server/routes/v2-api-key-auth', () => ({ + authenticateV2ApiKey: mocks.authenticateV2ApiKey, + V2ApiKeyUnauthenticatedError: class V2ApiKeyUnauthenticatedError extends Error {}, })) -vi.mock('@/lib/folders/locks', () => ({ - withFolderTreeLock: mockWithFolderTreeLock, +vi.mock('@/lib/core/rate-limiter', () => ({ + getRateLimit: () => ({ maxTokens: 100, refillRate: 50, refillIntervalMs: 60_000 }), + RateLimiter: class RateLimiter { + checkRateLimitDirect = mocks.checkRateLimitDirect + checkRateLimitDirectOrThrow = mocks.checkRateLimitDirectOrThrow + }, })) -vi.mock('@/lib/uploads/upload-session/service', () => ({ - createUploadSession: mockCreateUploadSession, +vi.mock('@/app/api/v2/lib/gate', () => ({ v2ApiGateError: mocks.gate })) + +vi.mock('@/app/api/v2/files/uploads/utils', () => ({ + toV2FileUpload: vi.fn(async () => ({ + id: 'upload-1', + status: 'uploading', + name: 'file.csv', + contentType: 'text/csv', + size: 10, + expiresAt: '2026-08-04T21:00:00.000Z', + error: null, + file: null, + })), })) import { POST } from '@/app/api/v2/files/uploads/route' const WORKSPACE_ID = '6fc7631d-88cd-46f8-9f0a-d4764daef7f8' -const RATE_LIMIT = { - allowed: true, - userId: 'user-1', - keyType: 'workspace', - limit: 100, - remaining: 99, - resetAt: new Date('2026-08-03T22:00:00.000Z'), +const PRINCIPAL = { + kind: 'workspace_api_key' as const, + workspaceId: WORKSPACE_ID, + keyId: 'key-1', +} +const AUTH = { + principal: PRINCIPAL, + rolloutUserId: 'billing-owner-1', + rateLimitSubjectIds: ['api-key:key-1', `workspace:${WORKSPACE_ID}`] as const, + rateLimitSubscription: null, + keyType: 'workspace' as const, } const UPLOAD_SESSION = { id: 'upload-1', - workspaceId: WORKSPACE_ID, - userId: 'user-1', - knowledgeBaseId: null, - workflowId: null, - executionId: null, - purpose: 'workspace_file', - method: 'put', - storageContext: 'workspace', - storageKey: `${WORKSPACE_ID}/file.csv`, - finalKey: `${WORKSPACE_ID}/file.csv`, - storageProvider: 's3', - providerUploadId: null, - providerObjectVersion: null, - fileName: 'file.csv', - contentType: 'text/csv', - fileSize: 10, - partSize: null, - partCount: null, - status: 'uploading', uploadToken: 'signed-upload-token', - metadata: {}, - completedFileId: null, - error: null, - expiresAt: new Date('2026-08-04T21:00:00.000Z'), - createdAt: new Date('2026-08-03T21:00:00.000Z'), - updatedAt: new Date('2026-08-03T21:00:00.000Z'), - completedAt: null, transfer: { - method: 'put', + method: 'put' as const, url: 'https://storage.example/upload', headers: { 'content-type': 'text/csv' }, }, } function request(body: Record) { - return POST( - new NextRequest('http://localhost:3000/api/v2/files/uploads', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) - ) + const request = new NextRequest('http://localhost:3000/api/v2/files/uploads', { + method: 'POST', + headers: { 'Content-Type': 'application/json', 'x-api-key': 'secret' }, + body: JSON.stringify(body), + }) + return { request, response: POST(request) } } describe('POST /api/v2/files/uploads', () => { beforeEach(() => { vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockWithFolderTreeLock.mockImplementation(async (_workspaceId, _resourceType, operation) => - operation({}) - ) - mockLoadActiveFolderPathIndex.mockResolvedValue({ - rowById: new Map(), - pathById: new Map(), - idByPath: new Map([['/Reports', 'folder-reports']]), + mocks.authenticateV2ApiKey.mockResolvedValue(AUTH) + mocks.gate.mockResolvedValue(null) + mocks.checkRateLimitDirect.mockResolvedValue({ + allowed: true, + remaining: 599, + resetAt: new Date('2026-08-04T21:00:00.000Z'), }) - mockCreateUploadSession.mockResolvedValue(UPLOAD_SESSION) + mocks.checkRateLimitDirectOrThrow.mockResolvedValue({ + allowed: true, + remaining: 99, + resetAt: new Date('2026-08-04T21:00:00.000Z'), + }) + mocks.createUpload.mockResolvedValue(UPLOAD_SESSION) }) - it('creates one signed PUT session for a small file', async () => { - const response = await request({ + it('creates a signed upload through the workspace principal pipeline', async () => { + const call = request({ workspaceId: WORKSPACE_ID, name: 'file.csv', contentType: 'text/csv', size: 10, }) + const response = await call.response expect(response.status).toBe(201) - const { data } = await response.json() - expect(data).toMatchObject({ - session: { id: 'upload-1', status: 'uploading', file: null }, - uploadToken: 'signed-upload-token', - transfer: { method: 'put', url: 'https://storage.example/upload' }, + expect(await response.json()).toMatchObject({ + data: { + session: { id: 'upload-1', status: 'uploading', file: null }, + uploadToken: 'signed-upload-token', + transfer: { method: 'put', url: 'https://storage.example/upload' }, + }, }) - expect(data.session).not.toHaveProperty('uploadToken') - expect(data.session).not.toHaveProperty('transfer') - expect(data.session).not.toHaveProperty('partSize') - expect(data.session).not.toHaveProperty('partCount') - expect(mockCreateUploadSession).toHaveBeenCalledWith({ - workspaceId: WORKSPACE_ID, - userId: 'user-1', - purpose: 'workspace_file', - fileName: 'file.csv', - contentType: 'text/csv', - fileSize: 10, - metadata: { folderId: null }, - localOrigin: 'http://localhost:3000', + expect(mocks.createUpload).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + workspaceId: WORKSPACE_ID, + name: 'file.csv', + contentType: 'text/csv', + size: 10, + folderPath: '/', + }, + request: call.request, }) }) - it('authorizes workspace write access before creating provider state', async () => { - mockResolveWorkspaceAccess.mockResolvedValue({ - status: 403, - code: 'FORBIDDEN', - message: 'Access denied', - }) - - const response = await request({ - workspaceId: WORKSPACE_ID, - name: 'file.csv', - contentType: 'text/csv', - size: 10, - }) + it('authenticates and rate limits before request validation', async () => { + const response = await request({ workspaceId: WORKSPACE_ID }).response - expect(response.status).toBe(403) - expect(mockLoadActiveFolderPathIndex).not.toHaveBeenCalled() - expect(mockCreateUploadSession).not.toHaveBeenCalled() + expect(response.status).toBe(400) + expect(mocks.authenticateV2ApiKey).toHaveBeenCalledTimes(1) + expect(mocks.checkRateLimitDirectOrThrow).toHaveBeenCalledTimes(2) + expect(mocks.createUpload).not.toHaveBeenCalled() }) - it('creates an upload session for an empty workspace file', async () => { - const response = await request({ + it('does not run a second creator-based authentication path', async () => { + await request({ workspaceId: WORKSPACE_ID, - name: 'file.csv', - contentType: 'text/csv', + name: 'empty.txt', + contentType: 'text/plain', size: 0, - }) - - expect(response.status).toBe(201) - expect(mockCreateUploadSession).toHaveBeenCalledWith( - expect.objectContaining({ purpose: 'workspace_file', fileSize: 0 }) - ) - }) + }).response - it('releases the folder tree lock before creating an upload session', async () => { - let lockHeld = false - mockWithFolderTreeLock.mockImplementation(async (_workspaceId, _resourceType, operation) => { - lockHeld = true - try { - return await operation({}) - } finally { - lockHeld = false - } - }) - mockCreateUploadSession.mockImplementationOnce(async () => { - expect(lockHeld).toBe(false) - return UPLOAD_SESSION - }) - - const response = await request({ - workspaceId: WORKSPACE_ID, - name: 'file.csv', - contentType: 'text/csv', - size: 10, - folderPath: '/Reports', - }) - - expect(response.status).toBe(201) - expect(mockLoadActiveFolderPathIndex).toHaveBeenCalledWith( - WORKSPACE_ID, - 'file', - expect.any(Object) - ) - expect(mockCreateUploadSession).toHaveBeenCalledWith( - expect.objectContaining({ metadata: { folderId: 'folder-reports' } }) + expect(mocks.authenticateV2ApiKey).toHaveBeenCalledTimes(1) + expect(mocks.createUpload).toHaveBeenCalledWith( + expect.objectContaining({ principal: PRINCIPAL }) ) }) }) diff --git a/apps/sim/app/api/v2/files/uploads/route.ts b/apps/sim/app/api/v2/files/uploads/route.ts index 60ef13d4bd0..78b0deb6a35 100644 --- a/apps/sim/app/api/v2/files/uploads/route.ts +++ b/apps/sim/app/api/v2/files/uploads/route.ts @@ -1,52 +1,34 @@ import { v2CreateFileUploadContract } from '@/lib/api/contracts/v2/files' -import { createUploadSession } from '@/lib/uploads/upload-session/service' -import { withPublicApiRouteHandler } from '@/app/api/public-api-route-handler' -import { resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { toV2FileUpload } from '@/app/api/v2/files/uploads/utils' -import { resolveFolderPathIdentity } from '@/app/api/v2/lib/folders' import { - v2CaughtOrchestrationError, - v2Data, - v2Error, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' + defineV2JsonRoute, + v2ApiKeyAuth, + v2FileErrorPolicies, + v2RateLimits, +} from '@/lib/api/server/routes' +import { ROOT_FOLDER_PATH } from '@/lib/folders/paths' +import { createWorkspaceFileUploadOperation } from '@/lib/uploads/upload-session/application' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { toV2FileUpload } from '@/app/api/v2/files/uploads/utils' -export const POST = withPublicApiRouteHandler({ +export const POST = defineV2JsonRoute({ contract: v2CreateFileUploadContract, - rateLimitEndpoint: 'files', - handler: async ({ request, input, auth: { userId, rateLimit } }) => { - try { - const { workspaceId, name, contentType, size, folderPath } = input.body - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - const resolution = await resolveFolderPathIdentity({ - workspaceId, - resourceType: 'file', - path: folderPath ?? '/', - }) - if (!resolution.found) return v2Error('NOT_FOUND', 'Folder not found') - const session = await createUploadSession({ - workspaceId, - userId, - purpose: 'workspace_file', - fileName: name, - contentType, - fileSize: size, - metadata: { folderId: resolution.folderId }, - localOrigin: request.nextUrl.origin, - }) - return v2Data( - { - session: await toV2FileUpload(session, null), - uploadToken: session.uploadToken, - transfer: session.transfer, - }, - { rateLimit, status: 201 } - ) - } catch (error) { - const classified = v2CaughtOrchestrationError(error) - if (classified) return classified - throw error - } - }, + auth: v2ApiKeyAuth, + operation: fileOperations.uploadCreate, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.default, + mapInput: ({ body }) => ({ + workspaceId: body.workspaceId, + name: body.name, + contentType: body.contentType, + size: body.size, + folderPath: body.folderPath ?? ROOT_FOLDER_PATH, + }), + useCase: createWorkspaceFileUploadOperation, + present: async (session) => ({ + data: { + session: await toV2FileUpload(session, null), + uploadToken: session.uploadToken, + transfer: session.transfer, + }, + }), }) diff --git a/apps/sim/app/api/v2/files/uploads/utils.ts b/apps/sim/app/api/v2/files/uploads/utils.ts index dad280cfca8..45d60afd0c5 100644 --- a/apps/sim/app/api/v2/files/uploads/utils.ts +++ b/apps/sim/app/api/v2/files/uploads/utils.ts @@ -35,3 +35,21 @@ function uploadStatus(status: string): V2UploadStatus { } return status } + +import type { Principal } from '@sim/auth/principal' +import type { NextRequest, NextResponse } from 'next/server' +import { authenticateV2ApiKey } from '@/lib/api/server/routes/v2-api-key-auth' +import { v2CaughtOrchestrationError, v2Error } from '@/app/api/v2/lib/response' + +/** Re-authenticates the API key for each upload control leg. */ +export async function authenticateUploadPrincipal(request: NextRequest): Promise { + const auth = await authenticateV2ApiKey(request.headers.get('x-api-key')) + return auth.principal +} + +/** Resource-ID upload controls conceal authorization failures as absence. */ +export function v2UploadControlError(error: unknown): NextResponse | null { + const response = v2CaughtOrchestrationError(error) + if (!response) return null + return response.status === 403 ? v2Error('NOT_FOUND', 'Upload session not found') : response +} diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts index da54edf8957..b26c7022465 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts @@ -1,124 +1,42 @@ -import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' +import { NextResponse } from 'next/server' import { workspaceFileCompiledCheckContract } from '@/lib/api/contracts/workspace-files' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { getE2BDocFormat } from '@/lib/copilot/tools/server/files/doc-compile' -import { runE2BCompiledCheck } from '@/lib/copilot/tools/server/files/doc-recalc' -import { isDocSandboxEnabled } from '@/lib/core/config/env-flags' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { BINARY_DOC_TASKS, MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants' -import { runSandboxTask, SandboxUserCodeError } from '@/lib/execution/sandbox/run-task' -import { validateMermaidSource } from '@/lib/mermaid/validate' -import { fetchWorkspaceFileBuffer, getWorkspaceFile } from '@/lib/uploads/contexts/workspace' -import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' +import { + defineInternalJsonRoute, + internalPlainFileErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + CompiledCheckTooLargeError, + CompiledCheckUnsupportedError, + compiledCheckWorkspaceFile, +} from '@/lib/workspace-files/application/compiled-check-workspace-file' export const dynamic = 'force-dynamic' export const runtime = 'nodejs' -const logger = createLogger('WorkspaceFileCompiledCheckAPI') - -/** - * GET /api/workspaces/[id]/files/[fileId]/compiled-check - * - * Compiles or validates the saved source for generated document-like files and - * returns whether it succeeds. Used by the file agent to self-verify generated - * code or diagram syntax before finalising an edit. - * - * Returns: - * 200 { ok: true } - * 200 { ok: false, error: string, errorName: string } — user code error - * 4xx on auth / missing file / unsupported extension - * 500 on system (sandbox infra) failure - */ -export const GET = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => { - const parsed = await parseRequest(workspaceFileCompiledCheckContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId, fileId } = parsed.data.params - - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const membership = await verifyWorkspaceMembership(session.user.id, workspaceId) - if (!membership) { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - const fileRecord = await getWorkspaceFile(workspaceId, fileId) - if (!fileRecord) { - return NextResponse.json({ error: 'File not found' }, { status: 404 }) - } - - const ext = fileRecord.name.split('.').pop()?.toLowerCase() ?? '' - // In the E2B regime ALL four formats compile in the doc sandbox (Node for - // pptx/docx, Python for pdf/xlsx). Gate on the flag (not the stored MIME) so - // a stale file can't trigger an E2B compile when the sandbox is disabled. - const e2bFmt = isDocSandboxEnabled ? await getE2BDocFormat(fileRecord.name) : null - const taskId = BINARY_DOC_TASKS[ext] - const isMermaidFile = ext === 'mmd' || ext === 'mermaid' - if (!e2bFmt && !taskId && !isMermaidFile) { - return NextResponse.json( - { error: `Compiled check only supports .docx, .pptx, .pdf, .xlsx, and .mmd files` }, - { status: 422 } - ) - } - - let buffer: Buffer - try { - buffer = await fetchWorkspaceFileBuffer(fileRecord) - } catch (err) { - logger.error('Failed to download file for compiled check', { - fileId, - error: toError(err).message, - }) - return NextResponse.json({ error: 'Failed to read file' }, { status: 500 }) - } - - const code = buffer.toString('utf-8') - - if (Buffer.byteLength(code, 'utf-8') > MAX_DOCUMENT_PREVIEW_CODE_BYTES) { - return NextResponse.json({ error: 'File source exceeds maximum size' }, { status: 413 }) - } - - if (isMermaidFile) { - return NextResponse.json(await validateMermaidSource(code)) - } - - if (e2bFmt) { - // Loads the compile-once artifact if present, else compiles via E2B once - // (and recalc-scans xlsx formulas). Only a script error is { ok: false }; - // infra failures rethrow → 500, so the agent isn't told to "fix its script" - // during an E2B/S3 outage. - const result = await runE2BCompiledCheck({ - source: code, - fileName: fileRecord.name, - workspaceId, - ext, - }) - return NextResponse.json(result) - } - - try { - if (!taskId) { - return NextResponse.json({ error: 'Unsupported compiled check target' }, { status: 422 }) - } - await runSandboxTask(taskId, { code, workspaceId }, { ownerKey: `user:${session.user.id}` }) - return NextResponse.json({ ok: true }) - } catch (err) { - if (err instanceof SandboxUserCodeError) { - logger.info('Compiled check failed with user code error', { - fileId, - taskId, - error: toError(err).message, - errorName: err.name, - }) - return NextResponse.json({ ok: false, error: toError(err).message, errorName: err.name }) - } - throw err - } - } -) +const compiledCheckErrorPolicy = { + ...internalPlainFileErrorPolicy, + render(error: unknown) { + if (error instanceof CompiledCheckUnsupportedError) { + return NextResponse.json({ error: error.message }, { status: 422 }) + } + if (error instanceof CompiledCheckTooLargeError) { + return NextResponse.json({ error: error.message }, { status: 413 }) + } + return internalPlainFileErrorPolicy.render(error) + }, +} + +export const GET = defineInternalJsonRoute({ + contract: workspaceFileCompiledCheckContract, + auth: internalSessionAuth, + operation: compiledCheckWorkspaceFile.operation, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal compiled-check behavior', + }), + errorPolicy: compiledCheckErrorPolicy, + mapInput: ({ params }) => ({ fileId: params.fileId, assertedWorkspaceId: params.id }), + useCase: compiledCheckWorkspaceFile, + present: (result) => result, +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.test.ts index 78f5cdb124f..961dfd91db5 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.test.ts @@ -5,25 +5,27 @@ import { authMockFns } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetUserEntityPermissions, mockPerformUpdateContent } = vi.hoisted(() => ({ - mockGetUserEntityPermissions: vi.fn(), - mockPerformUpdateContent: vi.fn(), -})) +const mocks = vi.hoisted(() => ({ admit: vi.fn(), updateContent: vi.fn() })) vi.mock('@/lib/workspace-files/orchestration', () => ({ MAX_WORKSPACE_FILE_INLINE_BODY_BYTES: 70 * 1024 * 1024, - performUpdateWorkspaceFileContent: mockPerformUpdateContent, })) -vi.mock('@/lib/workspaces/permissions/utils', () => ({ - getUserEntityPermissions: mockGetUserEntityPermissions, +vi.mock('@/lib/workspace-files/application/update-workspace-file-content', () => ({ + updateWorkspaceFileContent: { + operation: { id: 'files.update_content', minimumRole: 'write', workspaceApiKey: 'allow' }, + admit: mocks.admit, + execute: mocks.updateContent, + }, })) +import { OrchestrationError } from '@/lib/core/orchestration/types' import { PUT } from '@/app/api/workspaces/[id]/files/[fileId]/content/route' const WORKSPACE_ID = 'workspace-1' const FILE_ID = 'wf_1' const USER = { id: 'user-1', name: 'Test User', email: 'test@sim.ai' } +const PRINCIPAL = { kind: 'session' as const, userId: USER.id, sessionId: 'session-1' } const RECORD = { id: FILE_ID, workspaceId: WORKSPACE_ID, @@ -34,7 +36,6 @@ const RECORD = { type: 'text/markdown', uploadedBy: USER.id, folderId: null, - folderPath: null, uploadedAt: new Date('2026-08-04T00:00:00.000Z'), updatedAt: new Date('2026-08-04T00:00:00.000Z'), } @@ -58,9 +59,9 @@ function createRequest(body: unknown, contentLength?: number): NextRequest { describe('PUT /api/workspaces/[id]/files/[fileId]/content', () => { beforeEach(() => { vi.clearAllMocks() - authMockFns.mockGetSession.mockResolvedValue({ user: USER }) - mockGetUserEntityPermissions.mockResolvedValue('write') - mockPerformUpdateContent.mockResolvedValue({ success: true, file: RECORD }) + authMockFns.mockGetSession.mockResolvedValue({ user: USER, session: { id: 'session-1' } }) + mocks.admit.mockResolvedValue(undefined) + mocks.updateContent.mockResolvedValue({ file: RECORD }) }) it('authenticates before parsing an invalid request body', async () => { @@ -70,22 +71,23 @@ describe('PUT /api/workspaces/[id]/files/[fileId]/content', () => { expect(response.status).toBe(401) await expect(response.json()).resolves.toEqual({ error: 'Unauthorized' }) - expect(mockGetUserEntityPermissions).not.toHaveBeenCalled() - expect(mockPerformUpdateContent).not.toHaveBeenCalled() + expect(mocks.admit).not.toHaveBeenCalled() + expect(mocks.updateContent).not.toHaveBeenCalled() }) - it('authorizes the workspace before parsing the request body', async () => { - mockGetUserEntityPermissions.mockResolvedValue('read') + it('performs cheap file admission before buffering the request body', async () => { + mocks.admit.mockRejectedValue( + new OrchestrationError('forbidden', 'Insufficient workspace permissions') + ) const response = await PUT(createRequest('{not-json'), routeContext) expect(response.status).toBe(403) - await expect(response.json()).resolves.toEqual({ error: 'Insufficient permissions' }) - expect(mockGetUserEntityPermissions).toHaveBeenCalledWith(USER.id, 'workspace', WORKSPACE_ID) - expect(mockPerformUpdateContent).not.toHaveBeenCalled() + expect(mocks.admit).toHaveBeenCalledWith(PRINCIPAL, FILE_ID) + expect(mocks.updateContent).not.toHaveBeenCalled() }) - it('rejects malformed base64 after authorization', async () => { + it('rejects malformed base64 after admission', async () => { const response = await PUT( createRequest({ content: 'not-base64!', encoding: 'base64' }), routeContext @@ -93,7 +95,7 @@ describe('PUT /api/workspaces/[id]/files/[fileId]/content', () => { expect(response.status).toBe(400) await expect(response.json()).resolves.toMatchObject({ error: 'Validation error' }) - expect(mockPerformUpdateContent).not.toHaveBeenCalled() + expect(mocks.updateContent).not.toHaveBeenCalled() }) it('accepts empty base64 as a zero-byte replacement', async () => { @@ -101,14 +103,14 @@ describe('PUT /api/workspaces/[id]/files/[fileId]/content', () => { const response = await PUT(request, routeContext) expect(response.status).toBe(200) - expect(mockPerformUpdateContent).toHaveBeenCalledWith({ - workspaceId: WORKSPACE_ID, - fileId: FILE_ID, - userId: USER.id, - content: '', - encoding: 'base64', - actorName: USER.name, - actorEmail: USER.email, + expect(mocks.updateContent).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + fileId: FILE_ID, + assertedWorkspaceId: WORKSPACE_ID, + content: '', + encoding: 'base64', + }, request, }) }) @@ -120,16 +122,14 @@ describe('PUT /api/workspaces/[id]/files/[fileId]/content', () => { ) expect(response.status).toBe(200) - expect(mockPerformUpdateContent).toHaveBeenCalled() + expect(mocks.updateContent).toHaveBeenCalled() }) - it('rejects a JSON body above the inline-content cap', async () => { + it('rejects a JSON body above the inline-content cap after admission', async () => { const response = await PUT(createRequest({ content: '' }, 70 * 1024 * 1024 + 1), routeContext) expect(response.status).toBe(413) - await expect(response.json()).resolves.toEqual({ - error: `Request body exceeds the maximum allowed size of ${70 * 1024 * 1024} bytes`, - }) - expect(mockPerformUpdateContent).not.toHaveBeenCalled() + expect(mocks.admit).toHaveBeenCalled() + expect(mocks.updateContent).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts index a7d4934c783..9b3aa671ef8 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts @@ -1,79 +1,37 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' +import { updateWorkspaceFileContentContract } from '@/lib/api/contracts/workspace-files' import { - updateWorkspaceFileContentContract, - workspaceFileParamsSchema, -} from '@/lib/api/contracts/workspace-files' -import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { - messageForOrchestrationError, - statusForOrchestrationError, -} from '@/lib/core/orchestration/types' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - MAX_WORKSPACE_FILE_INLINE_BODY_BYTES, - performUpdateWorkspaceFileContent, -} from '@/lib/workspace-files/orchestration' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' + defineInternalJsonRoute, + internalFileErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { updateWorkspaceFileContent } from '@/lib/workspace-files/application/update-workspace-file-content' +import { MAX_WORKSPACE_FILE_INLINE_BODY_BYTES } from '@/lib/workspace-files/orchestration' export const dynamic = 'force-dynamic' -const logger = createLogger('WorkspaceFileContentAPI') - -/** - * PUT /api/workspaces/[id]/files/[fileId]/content - * Update a workspace file's text content (requires write permission) - */ -export const PUT = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const paramsResult = workspaceFileParamsSchema.safeParse(await context.params) - if (!paramsResult.success) { - return NextResponse.json( - { error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') }, - { status: 400 } - ) - } - const { id: workspaceId, fileId } = paramsResult.data - - const userPermission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (userPermission !== 'admin' && userPermission !== 'write') { - logger.warn(`User ${session.user.id} lacks write permission for workspace ${workspaceId}`) - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - const parsed = await parseRequest(updateWorkspaceFileContentContract, request, context, { - maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES, - }) - if (!parsed.success) return parsed.response - const { content, encoding } = parsed.data.body - - const result = await performUpdateWorkspaceFileContent({ - workspaceId, - fileId, - userId: session.user.id, - content, - encoding: encoding === 'base64' ? 'base64' : 'utf-8', - actorName: session.user.name, - actorEmail: session.user.email, - request, - }) - - if (!result.success || !result.file) { - return NextResponse.json( - { - success: false, - error: messageForOrchestrationError(result, 'Failed to update file content'), - }, - { status: statusForOrchestrationError(result.errorCode) } - ) +/** PUT /api/workspaces/[id]/files/[fileId]/content — Replace a file's bytes. */ +export const PUT = defineInternalJsonRoute({ + contract: updateWorkspaceFileContentContract, + auth: internalSessionAuth, + operation: fileOperations.updateContent, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal content-update behavior', + }), + errorPolicy: internalFileErrorPolicy, + parseOptions: { maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES }, + beforeParse: async ({ principal, params }) => { + if (typeof params.fileId === 'string') { + await updateWorkspaceFileContent.admit(principal, params.fileId) } - - return NextResponse.json({ success: true, file: result.file }) - } -) + }, + mapInput: ({ params, body }) => ({ + fileId: params.fileId, + assertedWorkspaceId: params.id, + content: body.content, + encoding: body.encoding === 'base64' ? ('base64' as const) : ('utf-8' as const), + }), + useCase: updateWorkspaceFileContent, + present: ({ file }) => ({ success: true, file: { ...file, folderId: file.folderId ?? null } }), +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/csv-preview/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/csv-preview/route.ts index 0fd9553a4c4..4d65dae99a4 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/csv-preview/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/csv-preview/route.ts @@ -1,56 +1,35 @@ import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' import { getWorkspaceCsvPreviewContract } from '@/lib/api/contracts/workspace-file-table' -import { parseRequest } from '@/lib/api/server' -import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getCsvPreviewSlice } from '@/lib/file-parsers/csv-preview-slice' -import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import { + defineInternalJsonRoute, + internalPlainFileErrorPolicy, + internalRateLimits, + internalSessionOrServiceAuth, +} from '@/lib/api/server/routes' +import { csvPreviewWorkspaceFile } from '@/lib/workspace-files/application/csv-preview-workspace-file' const logger = createLogger('WorkspaceCsvPreviewAPI') export const runtime = 'nodejs' export const dynamic = 'force-dynamic' -export const GET = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => { - const authResult = await checkSessionOrInternalAuth(request, { requireWorkflowId: false }) - if (!authResult.success || !authResult.userId) { - return NextResponse.json({ error: 'Authentication required' }, { status: 401 }) - } - const userId = authResult.userId - - const parsed = await parseRequest(getWorkspaceCsvPreviewContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId, fileId } = parsed.data.params - const { key } = parsed.data.query - - const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - if (!permission) { - return NextResponse.json({ error: 'Access denied' }, { status: 403 }) - } - - // Resolve the file record (active, in this workspace) and read from its authoritative key — - // never the client-supplied one. This rejects archived/deleted files and keys with no live - // row, matching the access guarantees of /api/files/serve. - const record = await getWorkspaceFile(workspaceId, fileId) - if (!record || record.key !== key) { - return NextResponse.json({ error: 'File not found' }, { status: 404 }) - } - - const slice = await getCsvPreviewSlice({ - key: record.key, - context: 'workspace', - signal: request.signal, - }) - +export const GET = defineInternalJsonRoute({ + contract: getWorkspaceCsvPreviewContract, + auth: internalSessionOrServiceAuth, + operation: csvPreviewWorkspaceFile.operation, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal CSV preview behavior' }), + errorPolicy: internalPlainFileErrorPolicy, + mapInput: ({ params, query }) => ({ + fileId: params.fileId, + assertedWorkspaceId: params.id, + key: query.key, + }), + useCase: csvPreviewWorkspaceFile, + present: (result) => { logger.info('CSV preview served', { - workspaceId, - rows: slice.rows.length, - truncated: slice.truncated, + rows: result.rows.length, + truncated: result.truncated, }) - - return NextResponse.json({ success: true, ...slice }) - } -) + return result + }, +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/dimensions/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/dimensions/route.test.ts index b6413ea6818..52db9ebbf0f 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/dimensions/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/dimensions/route.test.ts @@ -1,93 +1,96 @@ /** * @vitest-environment node */ -import { authMockFns, permissionsMock, permissionsMockFns } from '@sim/testing' +import { authMockFns } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockUpdateWorkspaceFileDimensions } = vi.hoisted(() => ({ - mockUpdateWorkspaceFileDimensions: vi.fn(), -})) +const mocks = vi.hoisted(() => ({ updateDimensions: vi.fn() })) -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ - updateWorkspaceFileDimensions: mockUpdateWorkspaceFileDimensions, +vi.mock('@/lib/workspace-files/application/update-workspace-file-dimensions', () => ({ + updateWorkspaceFileDimensionsOperation: { + operation: { id: 'files.update_metadata', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.updateDimensions, + }, })) -vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) - -const WS = '7727ef3f-8cf6-4686-b063-2bb006a10785' -const FILE = 'wf_abc123' -const KEY = 'workspace/7727ef3f/screenshot.png' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { PATCH } from '@/app/api/workspaces/[id]/files/[fileId]/dimensions/route' -const routeContext = { params: Promise.resolve({ id: WS, fileId: FILE }) } +const WORKSPACE_ID = '7727ef3f-8cf6-4686-b063-2bb006a10785' +const FILE_ID = 'wf_abc123' +const KEY = 'workspace/7727ef3f/screenshot.png' +const USER = { id: 'user-1' } +const PRINCIPAL = { kind: 'session' as const, userId: USER.id, sessionId: 'session-1' } +const context = { params: Promise.resolve({ id: WORKSPACE_ID, fileId: FILE_ID }) } -function buildRequest(body: unknown): NextRequest { - return new NextRequest(`http://localhost/api/workspaces/${WS}/files/${FILE}/dimensions`, { - method: 'PATCH', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify(body), - }) +function request(body: unknown): NextRequest { + return new NextRequest( + `http://localhost/api/workspaces/${WORKSPACE_ID}/files/${FILE_ID}/dimensions`, + { + method: 'PATCH', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify(body), + } + ) } describe('PATCH /api/workspaces/[id]/files/[fileId]/dimensions', () => { beforeEach(() => { vi.clearAllMocks() - authMockFns.mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write') - mockUpdateWorkspaceFileDimensions.mockResolvedValue(true) + authMockFns.mockGetSession.mockResolvedValue({ user: USER, session: { id: 'session-1' } }) + mocks.updateDimensions.mockResolvedValue({ success: true }) }) - it('stores dimensions for a writer, keyed to the content version', async () => { - const res = await PATCH(buildRequest({ key: KEY, width: 1600, height: 900 }), routeContext) - expect(res.status).toBe(200) - expect(await res.json()).toEqual({ success: true }) - expect(mockUpdateWorkspaceFileDimensions).toHaveBeenCalledWith(WS, FILE, { - key: KEY, - width: 1600, - height: 900, + it('updates dimensions through the shared operation', async () => { + const req = request({ key: KEY, width: 1600, height: 900 }) + const response = await PATCH(req, context) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ success: true }) + expect(mocks.updateDimensions).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { + fileId: FILE_ID, + assertedWorkspaceId: WORKSPACE_ID, + key: KEY, + width: 1600, + height: 900, + }, + request: req, }) }) - it('allows an admin', async () => { - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('admin') - const res = await PATCH(buildRequest({ key: KEY, width: 10, height: 20 }), routeContext) - expect(res.status).toBe(200) - expect(mockUpdateWorkspaceFileDimensions).toHaveBeenCalledOnce() - }) + it('preserves the stale content-version result', async () => { + mocks.updateDimensions.mockResolvedValue({ success: false }) + const response = await PATCH(request({ key: KEY, width: 10, height: 20 }), context) - it('reports success:false when the content-version guard rejects the write (key changed)', async () => { - mockUpdateWorkspaceFileDimensions.mockResolvedValue(false) - const res = await PATCH(buildRequest({ key: KEY, width: 10, height: 20 }), routeContext) - expect(res.status).toBe(200) - expect(await res.json()).toEqual({ success: false }) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ success: false }) }) - it('rejects an unauthenticated caller before touching the DB', async () => { + it('authenticates before parsing or dispatching', async () => { authMockFns.mockGetSession.mockResolvedValue(null) - const res = await PATCH(buildRequest({ key: KEY, width: 10, height: 10 }), routeContext) - expect(res.status).toBe(401) - expect(mockUpdateWorkspaceFileDimensions).not.toHaveBeenCalled() + const response = await PATCH(request({ key: KEY, width: 10, height: 10 }), context) + + expect(response.status).toBe(401) + expect(mocks.updateDimensions).not.toHaveBeenCalled() }) - it('rejects a read-only member (backfill requires write)', async () => { - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('read') - const res = await PATCH(buildRequest({ key: KEY, width: 10, height: 10 }), routeContext) - expect(res.status).toBe(403) - expect(mockUpdateWorkspaceFileDimensions).not.toHaveBeenCalled() + it('rejects invalid dimensions after authentication', async () => { + const response = await PATCH(request({ key: KEY, width: 0, height: 10 }), context) + + expect(response.status).toBe(400) + expect(mocks.updateDimensions).not.toHaveBeenCalled() }) - it('rejects a missing key or non-positive / non-integer dimensions', async () => { - for (const body of [ - { width: 10, height: 10 }, // missing key - { key: KEY, width: 0, height: 10 }, - { key: KEY, width: 10, height: -5 }, - { key: KEY, width: 10.5, height: 10 }, - { key: KEY, width: 10 }, - ]) { - const res = await PATCH(buildRequest(body), routeContext) - expect(res.status).toBe(400) - } - expect(mockUpdateWorkspaceFileDimensions).not.toHaveBeenCalled() + it('renders typed authorization errors', async () => { + mocks.updateDimensions.mockRejectedValue( + new OrchestrationError('forbidden', 'Insufficient workspace permissions') + ) + const response = await PATCH(request({ key: KEY, width: 10, height: 10 }), context) + + expect(response.status).toBe(403) + expect(mocks.updateDimensions).toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/dimensions/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/dimensions/route.ts index 0d91d38563e..1da6fe7b5f4 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/dimensions/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/dimensions/route.ts @@ -1,57 +1,26 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' import { updateWorkspaceFileDimensionsContract } from '@/lib/api/contracts/workspace-files' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { updateWorkspaceFileDimensions } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import { + defineInternalJsonRoute, + internalFileErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { updateWorkspaceFileDimensionsOperation } from '@/lib/workspace-files/application/update-workspace-file-dimensions' -const logger = createLogger('WorkspaceFileDimensionsAPI') - -/** - * PATCH /api/workspaces/[id]/files/[fileId]/dimensions - * - * Store an image file's intrinsic pixel dimensions — a pure rendering hint the editor uses to reserve - * layout space before the image loads. Requires write permission. The write commits whenever the row - * still holds the measured storage key, overwriting any stale value so a wrong size self-corrects; the - * client reports only on a real mismatch, so this is not storm-y despite not being a backfill-once no-op. - */ -export const PATCH = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(updateWorkspaceFileDimensionsContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId, fileId } = parsed.data.params - const { key, width, height } = parsed.data.body - - const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (permission !== 'admin' && permission !== 'write') { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - try { - // `written` is false when the content-version guard rejected the write (the row's storage key no - // longer matches the key the client measured — the content was replaced since). That is not an - // error; the client's next measurement, once its file list has the new key, persists correctly. - const written = await updateWorkspaceFileDimensions(workspaceId, fileId, { - key, - width, - height, - }) - return NextResponse.json({ success: written }) - } catch (error) { - logger.error('Failed to store workspace file dimensions', { - workspaceId, - fileId, - error: getErrorMessage(error), - }) - return NextResponse.json({ error: 'Failed to update dimensions' }, { status: 500 }) - } - } -) +export const PATCH = defineInternalJsonRoute({ + contract: updateWorkspaceFileDimensionsContract, + auth: internalSessionAuth, + operation: fileOperations.updateMetadata, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal dimensions behavior' }), + errorPolicy: internalFileErrorPolicy, + mapInput: ({ params, body }) => ({ + fileId: params.fileId, + assertedWorkspaceId: params.id, + key: body.key, + width: body.width, + height: body.height, + }), + useCase: updateWorkspaceFileDimensionsOperation, + present: ({ success }) => ({ success }), +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/download/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/download/route.ts index 77c8900a718..b016ab64218 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/download/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/download/route.ts @@ -1,96 +1,60 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' -import { workspaceFileParamsSchema } from '@/lib/api/contracts/workspace-files' -import { getValidationErrorMessage } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { NextResponse } from 'next/server' +import { downloadWorkspaceFileUrlContract } from '@/lib/api/contracts/workspace-files' +import { + defineInternalJsonRoute, + internalFileErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { getBaseUrl } from '@/lib/core/utils/urls' import { captureServerEvent } from '@/lib/posthog/server' -import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace' -import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' +import { downloadWorkspaceFile } from '@/lib/workspace-files/application/download-workspace-file' export const dynamic = 'force-dynamic' const logger = createLogger('WorkspaceFileDownloadAPI') -/** - * POST /api/workspaces/[id]/files/[fileId]/download - * Return authenticated file serve URL (requires read permission) - * Uses /api/files/serve endpoint which enforces authentication and context - */ -export const POST = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string; fileId: string }> }) => { - const requestId = generateRequestId() - const paramsResult = workspaceFileParamsSchema.safeParse(await params) - if (!paramsResult.success) { - return NextResponse.json( - { error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') }, - { status: 400 } - ) +const downloadErrorPolicy = { + render(error: unknown) { + const typed = internalFileErrorPolicy.render(error) + if (typed) return typed + logger.error('Failed to generate workspace file download URL', { error }) + return NextResponse.json( + { success: false, error: 'Failed to generate download URL' }, + { status: 500 } + ) + }, +} + +/** POST /api/workspaces/[id]/files/[fileId]/download — Create an authenticated serve URL. */ +export const POST = defineInternalJsonRoute({ + contract: downloadWorkspaceFileUrlContract, + auth: internalSessionAuth, + operation: downloadWorkspaceFile.operation, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal download behavior' }), + errorPolicy: downloadErrorPolicy, + mapInput: ({ params }) => ({ + fileId: params.fileId, + assertedWorkspaceId: params.id, + }), + useCase: downloadWorkspaceFile, + onSuccess: ({ principal, result }) => { + captureServerEvent( + principal.userId, + 'file_downloaded', + { workspace_id: result.file.workspaceId, is_bulk: false, file_count: 1 }, + { groups: { workspace: result.file.workspaceId } } + ) + }, + present: ({ file }) => { + const baseUrl = getBaseUrl() + return { + success: true as const, + downloadUrl: `${baseUrl}/api/files/serve/${encodeURIComponent(file.key)}?context=workspace`, + viewerUrl: `${baseUrl}/workspace/${file.workspaceId}/files/${file.id}`, + fileName: file.name, + expiresIn: null, } - const { id: workspaceId, fileId } = paramsResult.data - - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const userPermission = await verifyWorkspaceMembership(session.user.id, workspaceId) - if (!userPermission) { - logger.warn( - `[${requestId}] User ${session.user.id} lacks permission for workspace ${workspaceId}` - ) - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - const fileRecord = await getWorkspaceFile(workspaceId, fileId) - if (!fileRecord) { - return NextResponse.json({ error: 'File not found' }, { status: 404 }) - } - - const { getBaseUrl } = await import('@/lib/core/utils/urls') - const serveUrl = `${getBaseUrl()}/api/files/serve/${encodeURIComponent(fileRecord.key)}?context=workspace` - const viewerUrl = `${getBaseUrl()}/workspace/${workspaceId}/files/${fileId}` - - logger.info(`[${requestId}] Generated download URL for workspace file: ${fileRecord.name}`) - - recordAudit({ - workspaceId, - actorId: session.user.id, - action: AuditAction.FILE_DOWNLOADED, - resourceType: AuditResourceType.FILE, - resourceId: fileId, - resourceName: fileRecord.name, - description: `Downloaded file "${fileRecord.name}"`, - metadata: { fileId, fileName: fileRecord.name, bytes: fileRecord.size }, - request, - }) - captureServerEvent( - session.user.id, - 'file_downloaded', - { workspace_id: workspaceId, is_bulk: false, file_count: 1 }, - { groups: { workspace: workspaceId } } - ) - - return NextResponse.json({ - success: true, - downloadUrl: serveUrl, - viewerUrl: viewerUrl, - fileName: fileRecord.name, - expiresIn: null, - }) - } catch (error) { - logger.error(`[${requestId}] Error generating download URL:`, error) - return NextResponse.json( - { - success: false, - error: getErrorMessage(error, 'Failed to generate download URL'), - }, - { status: 500 } - ) - } - } -) + }, +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/restore/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/restore/route.ts index 0d41810c77e..9ae22043f22 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/restore/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/restore/route.ts @@ -1,64 +1,20 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' -import { workspaceFileParamsSchema } from '@/lib/api/contracts/workspace-files' -import { getValidationErrorMessage } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { performRestoreWorkspaceFile } from '@/lib/workspace-files/orchestration' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import { restoreWorkspaceFileContract } from '@/lib/api/contracts/workspace-files' +import { + defineInternalJsonRoute, + internalFileErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { restoreWorkspaceFileOperation } from '@/lib/workspace-files/application/restore-workspace-file' -const logger = createLogger('RestoreWorkspaceFileAPI') - -export const POST = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string; fileId: string }> }) => { - const requestId = generateRequestId() - const paramsResult = workspaceFileParamsSchema.safeParse(await params) - if (!paramsResult.success) { - return NextResponse.json( - { error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') }, - { status: 400 } - ) - } - const { id: workspaceId, fileId } = paramsResult.data - - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const userPermission = await getUserEntityPermissions( - session.user.id, - 'workspace', - workspaceId - ) - if (userPermission !== 'admin' && userPermission !== 'write') { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - const result = await performRestoreWorkspaceFile({ - workspaceId, - fileId, - userId: session.user.id, - }) - if (!result.success) { - return NextResponse.json( - { error: result.error }, - { status: result.errorCode === 'conflict' ? 409 : 500 } - ) - } - - logger.info(`[${requestId}] Restored workspace file ${fileId}`) - - return NextResponse.json({ success: true }) - } catch (error) { - logger.error(`[${requestId}] Error restoring workspace file ${fileId}`, error) - return NextResponse.json( - { error: getErrorMessage(error, 'Internal server error') }, - { status: 500 } - ) - } - } -) +export const POST = defineInternalJsonRoute({ + contract: restoreWorkspaceFileContract, + auth: internalSessionAuth, + operation: fileOperations.restore, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal restore behavior' }), + errorPolicy: internalFileErrorPolicy, + mapInput: ({ params }) => ({ fileId: params.fileId, assertedWorkspaceId: params.id }), + useCase: restoreWorkspaceFileOperation, + present: ({ restored }) => ({ success: restored }), +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts index 8039b5ced00..08eed10e6fa 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts @@ -1,30 +1,20 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' import { + deleteWorkspaceFileContract, renameWorkspaceFileContract, - workspaceFileParamsSchema, } from '@/lib/api/contracts/workspace-files' -import { getValidationErrorMessage } from '@/lib/api/server' import { defineInternalJsonRoute, internalFileErrorPolicy, internalRateLimits, internalSessionAuth, } from '@/lib/api/server/routes' -import { getSession } from '@/lib/auth' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' +import { deleteWorkspaceFileOperation } from '@/lib/workspace-files/application/delete-workspace-file' import { fileOperations } from '@/lib/workspace-files/application/operations' import { renameWorkspaceFile } from '@/lib/workspace-files/application/rename-workspace-file' -import { performDeleteWorkspaceFileItems } from '@/lib/workspace-files/orchestration' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' export const dynamic = 'force-dynamic' -const logger = createLogger('WorkspaceFileAPI') - /** * PATCH /api/workspaces/[id]/files/[fileId] * Rename a workspace file (requires write permission) @@ -56,76 +46,21 @@ export const PATCH = defineInternalJsonRoute({ * DELETE /api/workspaces/[id]/files/[fileId] * Archive a workspace file (requires write permission) */ -export const DELETE = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string; fileId: string }> }) => { - const requestId = generateRequestId() - const paramsResult = workspaceFileParamsSchema.safeParse(await params) - if (!paramsResult.success) { - return NextResponse.json( - { error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') }, - { status: 400 } - ) - } - const { id: workspaceId, fileId } = paramsResult.data - - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Check workspace permissions (requires write) - const userPermission = await getUserEntityPermissions( - session.user.id, - 'workspace', - workspaceId - ) - if (userPermission !== 'admin' && userPermission !== 'write') { - logger.warn( - `[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}` - ) - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - const result = await performDeleteWorkspaceFileItems({ - workspaceId, - userId: session.user.id, - fileIds: [fileId], - }) - if (!result.success) { - return NextResponse.json( - { success: false, error: result.error }, - { - status: - result.errorCode === 'validation' - ? 400 - : result.errorCode === 'not_found' - ? 404 - : 500, - } - ) - } - - logger.info(`[${requestId}] Archived workspace file: ${fileId}`) - - captureServerEvent( - session.user.id, - 'file_deleted', - { workspace_id: workspaceId }, - { groups: { workspace: workspaceId } } - ) - return NextResponse.json({ - success: true, - }) - } catch (error) { - logger.error(`[${requestId}] Error deleting workspace file:`, error) - return NextResponse.json( - { - success: false, - error: getErrorMessage(error, 'Failed to delete file'), - }, - { status: 500 } - ) - } - } -) +export const DELETE = defineInternalJsonRoute({ + contract: deleteWorkspaceFileContract, + auth: internalSessionAuth, + operation: fileOperations.delete, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal delete behavior' }), + errorPolicy: internalFileErrorPolicy, + mapInput: ({ params }) => ({ fileId: params.fileId, assertedWorkspaceId: params.id }), + useCase: deleteWorkspaceFileOperation, + onSuccess: ({ principal, result }) => { + captureServerEvent( + principal.userId, + 'file_deleted', + { workspace_id: result.workspaceId }, + { groups: { workspace: result.workspaceId } } + ) + }, + present: ({ deleted }) => ({ success: deleted }), +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.test.ts index 9865aee2651..2fe55b10776 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.test.ts @@ -1,171 +1,163 @@ /** * @vitest-environment node */ -import { auditMock, authMockFns, permissionsMock, permissionsMockFns } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockGetWorkspaceFile, mockGetShareForResource, mockUpsertFileShare, mockValidateSharing } = - vi.hoisted(() => ({ - mockGetWorkspaceFile: vi.fn(), - mockGetShareForResource: vi.fn(), - mockUpsertFileShare: vi.fn(), - mockValidateSharing: vi.fn(), - })) - -vi.mock('@/lib/uploads/contexts/workspace', () => ({ - getWorkspaceFile: mockGetWorkspaceFile, +const mocks = vi.hoisted(() => ({ + getSession: vi.fn(), + getShare: vi.fn(), + updateShare: vi.fn(), })) -vi.mock('@/lib/public-shares/share-manager', () => { - class ShareValidationError extends Error { - constructor(message: string) { - super(message) - this.name = 'ShareValidationError' - } - } - return { - getShareForResource: mockGetShareForResource, - upsertFileShare: mockUpsertFileShare, - ShareValidationError, - } -}) - -vi.mock('@/ee/access-control/utils/permission-check', () => { - class PublicFileSharingNotAllowedError extends Error { - constructor() { - super('Public file sharing is not allowed based on your permission group settings') - this.name = 'PublicFileSharingNotAllowedError' - } - } - return { validatePublicFileSharing: mockValidateSharing, PublicFileSharingNotAllowedError } -}) - -vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock) -vi.mock('@sim/audit', () => auditMock) - -const WS = '7727ef3f-8cf6-4686-b063-2bb006a10785' -const FILE_ID = 'wf_abc' +vi.mock('@/lib/auth', () => ({ getSession: mocks.getSession })) + +vi.mock('@/lib/workspace-files/application/share-workspace-file', () => ({ + getWorkspaceFileShare: { + operation: { id: 'files.share.read', minimumRole: 'read', workspaceApiKey: 'allow' }, + execute: mocks.getShare, + }, + updateWorkspaceFileShare: { + operation: { id: 'files.share.update', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.updateShare, + }, +})) -import { ShareValidationError } from '@/lib/public-shares/share-manager' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { GET, PUT } from '@/app/api/workspaces/[id]/files/[fileId]/share/route' -const params = (id = WS, fileId = FILE_ID) => ({ params: Promise.resolve({ id, fileId }) }) - -const putRequest = (body: unknown) => - new NextRequest(`http://localhost/api/workspaces/${WS}/files/${FILE_ID}/share`, { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) - -const getRequest = () => - new NextRequest(`http://localhost/api/workspaces/${WS}/files/${FILE_ID}/share`) - +const WORKSPACE_ID = 'workspace-1' +const FILE_ID = 'wf_1' +const PRINCIPAL = { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' } const SHARE = { - id: 'sh_1', + id: 'shr_1', token: 'tok_1', url: 'https://sim.ai/f/tok_1', isActive: true, resourceType: 'file' as const, resourceId: FILE_ID, + authType: 'public' as const, + hasPassword: false, + allowedEmails: [] as string[], +} +const context = { + params: Promise.resolve({ id: WORKSPACE_ID, fileId: FILE_ID }), +} + +function getRequest() { + return new NextRequest( + `http://localhost:3000/api/workspaces/${WORKSPACE_ID}/files/${FILE_ID}/share` + ) +} + +function putRequest(body: unknown) { + return new NextRequest( + `http://localhost:3000/api/workspaces/${WORKSPACE_ID}/files/${FILE_ID}/share`, + { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + } + ) } -describe('share route', () => { +describe('/api/workspaces/[id]/files/[fileId]/share', () => { beforeEach(() => { vi.clearAllMocks() - authMockFns.mockGetSession.mockResolvedValue({ - user: { id: 'user-1', name: 'User One', email: 'u@example.com' }, - }) - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValue('write') - mockGetWorkspaceFile.mockResolvedValue({ id: FILE_ID, name: 'report.pdf' }) - mockGetShareForResource.mockResolvedValue(SHARE) - mockUpsertFileShare.mockResolvedValue(SHARE) - mockValidateSharing.mockResolvedValue(undefined) // policy allows by default + mocks.getSession.mockResolvedValue({ user: { id: 'user-1' }, session: { id: 'session-1' } }) + mocks.getShare.mockResolvedValue({ share: SHARE }) + mocks.updateShare.mockResolvedValue({ share: SHARE }) }) - describe('GET', () => { - it('returns 401 when unauthenticated', async () => { - authMockFns.mockGetSession.mockResolvedValueOnce(null) - const res = await GET(getRequest(), params()) - expect(res.status).toBe(401) - }) + it('authenticates before parsing or executing', async () => { + mocks.getSession.mockResolvedValueOnce(null) - it('returns 403 when the caller has no workspace access', async () => { - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValueOnce(null) - const res = await GET(getRequest(), params()) - expect(res.status).toBe(403) - }) + const response = await GET(getRequest(), context) - it('returns the share for a member', async () => { - const res = await GET(getRequest(), params()) - expect(res.status).toBe(200) - expect(await res.json()).toEqual({ share: SHARE }) - }) + expect(response.status).toBe(401) + expect(mocks.getShare).not.toHaveBeenCalled() }) - describe('PUT', () => { - it('returns 403 for a read-only member', async () => { - permissionsMockFns.mockGetUserEntityPermissions.mockResolvedValueOnce('read') - const res = await PUT(putRequest({ isActive: true }), params()) - expect(res.status).toBe(403) - expect(mockUpsertFileShare).not.toHaveBeenCalled() - }) + it('returns the share through the internal envelope', async () => { + const response = await GET(getRequest(), context) - it('maps a ShareValidationError to 400, not 500', async () => { - mockUpsertFileShare.mockRejectedValueOnce( - new ShareValidationError('Password is required for password-protected shares') - ) - const res = await PUT(putRequest({ isActive: true, authType: 'password' }), params()) - expect(res.status).toBe(400) - expect((await res.json()).error).toBe('Password is required for password-protected shares') + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ share: SHARE }) + expect(mocks.getShare).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { fileId: FILE_ID, assertedWorkspaceId: WORKSPACE_ID }, + request: expect.anything(), }) + }) - it('returns 404 when the file is not in the workspace', async () => { - mockGetWorkspaceFile.mockResolvedValueOnce(null) - const res = await PUT(putRequest({ isActive: true }), params()) - expect(res.status).toBe(404) - expect(mockUpsertFileShare).not.toHaveBeenCalled() - }) + it('renders authorization failures as 403', async () => { + mocks.getShare.mockRejectedValueOnce(new OrchestrationError('forbidden', 'Access denied')) + + const response = await GET(getRequest(), context) + + expect(response.status).toBe(403) + expect(await response.json()).toEqual({ success: false, error: 'Access denied' }) + }) + + it('renders resource absence as 404', async () => { + mocks.getShare.mockRejectedValueOnce(new OrchestrationError('not_found', 'File not found')) + + const response = await GET(getRequest(), context) + + expect(response.status).toBe(404) + }) + + it('rejects malformed update input before the use case', async () => { + const response = await PUT(putRequest({}), context) + + expect(response.status).toBe(400) + expect(mocks.updateShare).not.toHaveBeenCalled() + }) - it('enables the share for a writer', async () => { - const res = await PUT(putRequest({ isActive: true }), params()) - expect(res.status).toBe(200) - expect(mockUpsertFileShare).toHaveBeenCalledWith({ - workspaceId: WS, + it('passes the session principal and asserted workspace to the shared update use case', async () => { + const response = await PUT(putRequest({ isActive: true, authType: 'password' }), context) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ share: SHARE }) + expect(mocks.updateShare).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { fileId: FILE_ID, - userId: 'user-1', + assertedWorkspaceId: WORKSPACE_ID, isActive: true, - }) - expect(await res.json()).toEqual({ share: SHARE }) + authType: 'password', + password: undefined, + allowedEmails: undefined, + token: undefined, + }, + request: expect.anything(), }) + }) - it('returns 403 when org access-control disables public sharing (enable)', async () => { - const { PublicFileSharingNotAllowedError } = await import( - '@/ee/access-control/utils/permission-check' - ) - mockValidateSharing.mockRejectedValueOnce(new PublicFileSharingNotAllowedError()) - const res = await PUT(putRequest({ isActive: true }), params()) - expect(res.status).toBe(403) - expect(mockUpsertFileShare).not.toHaveBeenCalled() - }) + it('renders typed update failures without exposing a 500', async () => { + mocks.updateShare.mockRejectedValueOnce( + new OrchestrationError('validation', 'Password is required') + ) - it('allows disabling a share even when policy disallows enabling', async () => { - mockValidateSharing.mockRejectedValue(new Error('should not be called for disable')) - const res = await PUT(putRequest({ isActive: false }), params()) - expect(res.status).toBe(200) - expect(mockValidateSharing).not.toHaveBeenCalled() - expect(mockUpsertFileShare).toHaveBeenCalledWith({ - workspaceId: WS, - fileId: FILE_ID, - userId: 'user-1', - isActive: false, - }) - }) + const response = await PUT(putRequest({ isActive: true }), context) - it('rejects a missing isActive body', async () => { - const res = await PUT(putRequest({}), params()) - expect(res.status).toBe(400) - }) + expect(response.status).toBe(400) + expect(await response.json()).toEqual({ success: false, error: 'Password is required' }) + }) + + it('preserves the internal caller-supplied token field for compatibility', async () => { + await PUT( + putRequest({ + isActive: true, + token: 'client-reserved-token', + }), + context + ) + + expect(mocks.updateShare).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ token: 'client-reserved-token' }), + }) + ) }) }) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.ts index f5810627a1b..3e078ba42c7 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.ts @@ -1,106 +1,46 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' import { getFileShareContract, upsertFileShareContract } from '@/lib/api/contracts/public-shares' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' import { - messageForOrchestrationError, - statusForOrchestrationError, -} from '@/lib/core/orchestration/types' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' + defineInternalJsonRoute, + internalFileErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { fileOperations } from '@/lib/workspace-files/application/operations' import { - performGetWorkspaceFileShare, - performUpsertWorkspaceFileShare, -} from '@/lib/workspace-files/orchestration' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' + getWorkspaceFileShare, + updateWorkspaceFileShare, +} from '@/lib/workspace-files/application/share-workspace-file' export const dynamic = 'force-dynamic' -const logger = createLogger('WorkspaceFileShareAPI') - -/** - * GET /api/workspaces/[id]/files/[fileId]/share - * Fetch the public share state for a file (requires workspace membership). - */ -export const GET = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => { - const requestId = generateRequestId() - - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(getFileShareContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId, fileId } = parsed.data.params - - const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (permission === null) { - logger.warn(`[${requestId}] User ${session.user.id} lacks access to workspace ${workspaceId}`) - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - const result = await performGetWorkspaceFileShare({ workspaceId, fileId }) - if (!result.success) { - return NextResponse.json( - { error: messageForOrchestrationError(result, 'Failed to fetch share') }, - { status: statusForOrchestrationError(result.errorCode) } - ) - } - - return NextResponse.json({ share: result.share ?? null }) - } -) - -/** - * PUT /api/workspaces/[id]/files/[fileId]/share - * Enable or disable the public share for a file (requires write permission). - */ -export const PUT = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => { - const requestId = generateRequestId() - - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(upsertFileShareContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId, fileId } = parsed.data.params - const { isActive, authType, password, allowedEmails, token } = parsed.data.body - - const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (permission !== 'admin' && permission !== 'write') { - logger.warn( - `[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}` - ) - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - const result = await performUpsertWorkspaceFileShare({ - workspaceId, - fileId, - userId: session.user.id, - isActive, - authType, - password, - allowedEmails, - token, - actorName: session.user.name, - actorEmail: session.user.email, - request, - }) - - if (!result.success || !result.share) { - return NextResponse.json( - { error: messageForOrchestrationError(result, 'Failed to update share') }, - { status: statusForOrchestrationError(result.errorCode) } - ) - } - - return NextResponse.json({ share: result.share }) - } -) +export const GET = defineInternalJsonRoute({ + contract: getFileShareContract, + auth: internalSessionAuth, + operation: fileOperations.readShare, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal share-read behavior' }), + errorPolicy: internalFileErrorPolicy, + mapInput: ({ params }) => ({ fileId: params.fileId, assertedWorkspaceId: params.id }), + useCase: getWorkspaceFileShare, + present: ({ share }) => ({ share }), +}) + +export const PUT = defineInternalJsonRoute({ + contract: upsertFileShareContract, + auth: internalSessionAuth, + operation: fileOperations.updateShare, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal share-update behavior', + }), + errorPolicy: internalFileErrorPolicy, + mapInput: ({ params, body }) => ({ + fileId: params.fileId, + assertedWorkspaceId: params.id, + isActive: body.isActive, + authType: body.authType, + password: body.password, + allowedEmails: body.allowedEmails, + token: body.token, + }), + useCase: updateWorkspaceFileShare, + present: ({ share }) => ({ share }), +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/style/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/style/route.ts index cc68e4dc348..4616bce027c 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/style/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/style/route.ts @@ -1,90 +1,37 @@ -import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' +import { NextResponse } from 'next/server' import { workspaceFileStyleContract } from '@/lib/api/contracts/workspace-files' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { extractDocumentStyle } from '@/lib/copilot/vfs/document-style' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { fetchWorkspaceFileBuffer, getWorkspaceFile } from '@/lib/uploads/contexts/workspace' -import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' +import { + defineInternalJsonRoute, + internalPlainFileErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + StyleExtractionUnsupportedError, + styleWorkspaceFile, +} from '@/lib/workspace-files/application/style-workspace-file' export const dynamic = 'force-dynamic' export const runtime = 'nodejs' -const logger = createLogger('WorkspaceFileStyleAPI') - -/** - * GET /api/workspaces/[id]/files/[fileId]/style - * Extract a compact JSON style summary from an uploaded .docx, .pptx, or .pdf file. - * OOXML files return theme colors, font pair, and named styles. - * PDF files return page dimensions and embedded font names. - */ -const MAX_STYLE_FILE_BYTES = 100 * 1024 * 1024 // 100 MB - -export const GET = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(workspaceFileStyleContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId, fileId } = parsed.data.params - - const membership = await verifyWorkspaceMembership(session.user.id, workspaceId) - if (!membership) { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - const fileRecord = await getWorkspaceFile(workspaceId, fileId) - if (!fileRecord) { - return NextResponse.json({ error: 'File not found' }, { status: 404 }) - } - - const rawExt = fileRecord.name.split('.').pop()?.toLowerCase() - if (rawExt !== 'docx' && rawExt !== 'pptx' && rawExt !== 'pdf') { - return NextResponse.json( - { error: 'Style extraction supports .docx, .pptx, and .pdf files' }, - { status: 422 } - ) +const styleErrorPolicy = { + ...internalPlainFileErrorPolicy, + render(error: unknown) { + if (error instanceof StyleExtractionUnsupportedError) { + return NextResponse.json({ error: error.message }, { status: 422 }) } - const ext: 'docx' | 'pptx' | 'pdf' = rawExt - - if (fileRecord.size > MAX_STYLE_FILE_BYTES) { - return NextResponse.json( - { error: 'File is too large for style extraction (limit: 100 MB)' }, - { status: 422 } - ) - } - - let buffer: Buffer - try { - buffer = await fetchWorkspaceFileBuffer(fileRecord) - } catch (err) { - logger.error('Failed to download file for style extraction', { - fileId, - error: toError(err).message, - }) - return NextResponse.json({ error: 'Failed to read file' }, { status: 500 }) - } - - const summary = await extractDocumentStyle(buffer, ext) - if (!summary) { - return NextResponse.json( - { - error: - 'Could not extract style — file may be encrypted, corrupt, image-only, or contain no parseable style information', - }, - { status: 422 } - ) - } - - logger.info('Extracted style summary via API', { fileId, format: ext }) - - return NextResponse.json(summary, { - headers: { 'Cache-Control': 'private, max-age=300' }, - }) - } -) + return internalPlainFileErrorPolicy.render(error) + }, +} + +export const GET = defineInternalJsonRoute({ + contract: workspaceFileStyleContract, + auth: internalSessionAuth, + operation: styleWorkspaceFile.operation, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal style behavior' }), + errorPolicy: styleErrorPolicy, + mapInput: ({ params }) => ({ fileId: params.fileId, assertedWorkspaceId: params.id }), + useCase: styleWorkspaceFile, + present: (result) => workspaceFileStyleContract.response.schema.parse(result), + responseHeaders: () => ({ 'Cache-Control': 'private, max-age=300' }), +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/bulk-archive/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/bulk-archive/route.test.ts new file mode 100644 index 00000000000..7c973596afc --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/bulk-archive/route.test.ts @@ -0,0 +1,79 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getSession: vi.fn(), + execute: vi.fn(), + captureServerEvent: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ getSession: mocks.getSession })) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.captureServerEvent })) +vi.mock('@/lib/workspace-files/application/archive-workspace-file-items', () => ({ + archiveWorkspaceFileItemsOperation: { + operation: { id: 'files.delete', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.execute, + }, +})) + +import { POST } from '@/app/api/workspaces/[id]/files/bulk-archive/route' + +const WORKSPACE_ID = 'workspace-1' +const context = { params: Promise.resolve({ id: WORKSPACE_ID }) } + +function request(body: unknown) { + return new NextRequest( + `http://localhost:3000/api/workspaces/${WORKSPACE_ID}/files/bulk-archive`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + } + ) +} + +describe('/api/workspaces/[id]/files/bulk-archive', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getSession.mockResolvedValue({ user: { id: 'user-1' }, session: { id: 'session-1' } }) + mocks.execute.mockResolvedValue({ deletedItems: { files: 2, folders: 1 } }) + }) + + it('archives selected files and folders through the shared operation', async () => { + const response = await POST( + request({ fileIds: ['wf_1', 'wf_2'], folderIds: ['folder-1'] }), + context + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + deletedItems: { files: 2, folders: 1 }, + }) + expect(mocks.execute).toHaveBeenCalledWith({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + workspaceId: WORKSPACE_ID, + fileIds: ['wf_1', 'wf_2'], + folderIds: ['folder-1'], + }, + request: expect.anything(), + }) + expect(mocks.captureServerEvent).toHaveBeenCalledWith( + 'user-1', + 'file_bulk_deleted', + { workspace_id: WORKSPACE_ID, file_count: 2, folder_count: 1 }, + { groups: { workspace: WORKSPACE_ID } } + ) + }) + + it('rejects an empty selection before the use case', async () => { + const response = await POST(request({}), context) + + expect(response.status).toBe(400) + expect(mocks.execute).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/bulk-archive/route.ts b/apps/sim/app/api/workspaces/[id]/files/bulk-archive/route.ts index 1f47f650bd6..a1caa9d8ec5 100644 --- a/apps/sim/app/api/workspaces/[id]/files/bulk-archive/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/bulk-archive/route.ts @@ -1,70 +1,39 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' import { bulkArchiveWorkspaceFileItemsContract } from '@/lib/api/contracts/workspace-file-folders' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + defineInternalJsonRoute, + internalFileErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' import { captureServerEvent } from '@/lib/posthog/server' -import { performDeleteWorkspaceFileItems } from '@/lib/workspace-files/orchestration' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import { archiveWorkspaceFileItemsOperation } from '@/lib/workspace-files/application/archive-workspace-file-items' +import { fileOperations } from '@/lib/workspace-files/application/operations' -const logger = createLogger('WorkspaceFileBulkArchiveAPI') - -export const POST = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(bulkArchiveWorkspaceFileItemsContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId } = parsed.data.params - const { fileIds, folderIds } = parsed.data.body - - const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (permission !== 'admin' && permission !== 'write') { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - try { - const result = await performDeleteWorkspaceFileItems({ - workspaceId, - userId: session.user.id, - fileIds, - folderIds, - }) - if (!result.success) { - return NextResponse.json( - { success: false, error: result.error }, - { - status: - result.errorCode === 'validation' - ? 400 - : result.errorCode === 'not_found' - ? 404 - : 500, - } - ) - } - if (!result.deletedItems) { - return NextResponse.json( - { success: false, error: 'Failed to delete workspace file items' }, - { status: 500 } - ) - } - - captureServerEvent( - session.user.id, - 'file_bulk_deleted', - { workspace_id: workspaceId, file_count: fileIds.length, folder_count: folderIds.length }, - { groups: { workspace: workspaceId } } - ) - - return NextResponse.json({ success: true, deletedItems: result.deletedItems }) - } catch (error) { - logger.error('Failed to bulk archive workspace file items:', error) - return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }) - } - } -) +export const POST = defineInternalJsonRoute({ + contract: bulkArchiveWorkspaceFileItemsContract, + auth: internalSessionAuth, + operation: fileOperations.delete, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal bulk archive behavior', + }), + errorPolicy: internalFileErrorPolicy, + mapInput: ({ params, body }) => ({ + workspaceId: params.id, + fileIds: body.fileIds, + folderIds: body.folderIds, + }), + useCase: archiveWorkspaceFileItemsOperation, + onSuccess: ({ principal, input }) => { + captureServerEvent( + principal.userId, + 'file_bulk_deleted', + { + workspace_id: input.workspaceId, + file_count: input.fileIds?.length ?? 0, + folder_count: input.folderIds?.length ?? 0, + }, + { groups: { workspace: input.workspaceId } } + ) + }, + present: ({ deletedItems }) => ({ success: true, deletedItems }), +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/download/route.integration.test.ts b/apps/sim/app/api/workspaces/[id]/files/download/route.integration.test.ts index d7a871fbcbf..fb2c3a57171 100644 --- a/apps/sim/app/api/workspaces/[id]/files/download/route.integration.test.ts +++ b/apps/sim/app/api/workspaces/[id]/files/download/route.integration.test.ts @@ -20,13 +20,13 @@ const run = promisify(execFile) const { mockGetSession, - mockVerifyWorkspaceMembership, + mockAuthorizeWorkspaceFileOperation, mockListWorkspaceFiles, mockListFolders, mockDownloadFileStream, } = vi.hoisted(() => ({ mockGetSession: vi.fn(), - mockVerifyWorkspaceMembership: vi.fn(), + mockAuthorizeWorkspaceFileOperation: vi.fn(), mockListWorkspaceFiles: vi.fn(), mockListFolders: vi.fn(), mockDownloadFileStream: vi.fn(), @@ -36,8 +36,8 @@ vi.mock('@/lib/auth', () => ({ auth: { api: { getSession: vi.fn() } }, getSession: mockGetSession, })) -vi.mock('@/app/api/workflows/utils', () => ({ - verifyWorkspaceMembership: mockVerifyWorkspaceMembership, +vi.mock('@/lib/workspace-files/application/workspace-operation-context', () => ({ + authorizeWorkspaceFileOperation: mockAuthorizeWorkspaceFileOperation, })) vi.mock('@/lib/uploads/contexts/workspace', () => ({ listWorkspaceFiles: mockListWorkspaceFiles, @@ -88,8 +88,10 @@ afterAll(async () => { describe('workspace files download — real archive', () => { beforeEach(() => { vi.clearAllMocks() - mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) - mockVerifyWorkspaceMembership.mockResolvedValue({ role: 'member' }) + mockGetSession.mockResolvedValue({ user: { id: 'user-1' }, session: { id: 'session-1' } }) + mockAuthorizeWorkspaceFileOperation.mockResolvedValue({ + context: { workspaceId: WORKSPACE_ID }, + }) mockListFolders.mockResolvedValue([{ id: 'folder-1', name: 'Reports', parentId: null }]) // A real fs stream, not a single pre-made buffer. mockDownloadFileStream.mockImplementation(async () => createReadStream(bigPath)) diff --git a/apps/sim/app/api/workspaces/[id]/files/download/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/download/route.test.ts index 41dc0680b7b..538c3aa196e 100644 --- a/apps/sim/app/api/workspaces/[id]/files/download/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/files/download/route.test.ts @@ -1,80 +1,41 @@ /** * @vitest-environment node */ -import { Readable } from 'stream' +import { Readable } from 'node:stream' import { createMockRequest } from '@sim/testing' import JSZip from 'jszip' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockGetSession, - mockVerifyWorkspaceMembership, - mockListWorkspaceFiles, - mockListWorkspaceFileFolders, - mockFetchServableWorkspaceFileBuffer, - mockDownloadFileStream, -} = vi.hoisted(() => ({ - mockGetSession: vi.fn(), - mockVerifyWorkspaceMembership: vi.fn(), - mockListWorkspaceFiles: vi.fn(), - mockListWorkspaceFileFolders: vi.fn(), - mockFetchServableWorkspaceFileBuffer: vi.fn(), - mockDownloadFileStream: vi.fn(), -})) +const { mockGetSession, mockDownloadItems, mockDownloadFileStream, mockCaptureServerEvent } = + vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockDownloadItems: vi.fn(), + mockDownloadFileStream: vi.fn(), + mockCaptureServerEvent: vi.fn(), + })) vi.mock('@/lib/auth', () => ({ auth: { api: { getSession: vi.fn() } }, getSession: mockGetSession, })) -vi.mock('@/app/api/workflows/utils', () => ({ - verifyWorkspaceMembership: mockVerifyWorkspaceMembership, -})) - -vi.mock('@/lib/uploads/contexts/workspace', () => ({ - listWorkspaceFiles: mockListWorkspaceFiles, - listWorkspaceFileFolders: mockListWorkspaceFileFolders, - buildWorkspaceFileFolderPathMap: (folders: Array<{ id: string; name: string }>) => - new Map(folders.map((folder) => [folder.id, folder.name])), - fetchServableWorkspaceFileBuffer: mockFetchServableWorkspaceFileBuffer, +vi.mock('@/lib/workspace-files/application/download-workspace-file-items', () => ({ + downloadWorkspaceFileItems: { + operation: { id: 'files.download', minimumRole: 'read', workspaceApiKey: 'allow' }, + execute: mockDownloadItems, + }, })) vi.mock('@/lib/uploads/core/storage-service', () => ({ downloadFileStream: mockDownloadFileStream, })) -vi.mock('@sim/audit', () => ({ - recordAudit: vi.fn(), - AuditAction: { FILE_DOWNLOADED: 'file.downloaded' }, - AuditResourceType: { FILE: 'file' }, -})) - -vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: vi.fn() })) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mockCaptureServerEvent })) -import { DocCompileUserError } from '@/lib/copilot/tools/server/files/doc-compile' -import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { GET } from '@/app/api/workspaces/[id]/files/download/route' const WORKSPACE_ID = 'ws-1' const context = { params: Promise.resolve({ id: WORKSPACE_ID }) } -const MB = 1024 * 1024 - -function workspaceFile(id: string, name: string, folderId: string | null = 'folder-1') { - return { - id, - name, - key: `workspace/${WORKSPACE_ID}/${id}`, - path: `/serve/${id}`, - size: 100, - type: 'application/octet-stream', - folderId, - } -} - -/** A file whose stored bytes are a generator source, so it must be resolved. */ -function generatedDocument(id: string, name: string, folderId: string | null = 'folder-1') { - return { ...workspaceFile(id, name, folderId), type: 'text/x-docxjs' } -} function requestFor(query: string) { return createMockRequest( @@ -85,265 +46,85 @@ function requestFor(query: string) { ) } +function result(files: Array<{ id: string; name: string; folderId: string | null }>) { + return { + filesToZip: files.map((file) => ({ + ...file, + key: `workspace/${WORKSPACE_ID}/${file.id}`, + size: 5, + storageContext: 'workspace', + })), + folderPaths: new Map([['folder-1', 'Reports']]), + renderedDocuments: new Map(), + declaredBytes: files.length * 5, + } +} + async function zipFrom(response: Response) { return JSZip.loadAsync(Buffer.from(await response.arrayBuffer())) } -describe('workspace files download route', () => { +describe('GET /api/workspaces/[id]/files/download', () => { beforeEach(() => { vi.clearAllMocks() - mockGetSession.mockResolvedValue({ user: { id: 'user-1' } }) - mockVerifyWorkspaceMembership.mockResolvedValue({ role: 'member' }) - mockListWorkspaceFileFolders.mockResolvedValue([ - { id: 'folder-1', name: 'Reports', parentId: null }, - ]) + mockGetSession.mockResolvedValue({ user: { id: 'user-1' }, session: { id: 'session-1' } }) + mockDownloadItems.mockResolvedValue(result([{ id: 'f1', name: 'clip.mp4', folderId: null }])) mockDownloadFileStream.mockImplementation(async () => Readable.from([Buffer.from('plain')])) }) - it('zips the rendered bytes for a generated doc, not its stored source', async () => { - mockListWorkspaceFiles.mockResolvedValue([generatedDocument('f1', 'overview.docx')]) - // A real .docx is a ZIP; the stored source would be plain JS text. - const rendered = Buffer.from('PKrendered-docx') - mockFetchServableWorkspaceFileBuffer.mockResolvedValue({ - buffer: rendered, - contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - }) - + it('streams a zip assembled from the shared download use case result', async () => { const response = await GET(requestFor('fileIds=f1'), context) expect(response.status).toBe(200) - const entry = (await zipFrom(response)).file('Reports/overview.docx') - expect(entry).not.toBeNull() - expect(Buffer.from(await entry!.async('uint8array'))).toEqual(rendered) - }) - - it('streams ordinary files instead of materializing them', async () => { - mockListWorkspaceFiles.mockResolvedValue([workspaceFile('f1', 'clip.mp4')]) - - const response = await GET(requestFor('fileIds=f1'), context) - - expect(response.status).toBe(200) - // Nothing has been read yet: the entry opens its storage read only once the - // consumer pulls the archive, which is what keeps peak memory to one entry. - expect(mockDownloadFileStream).not.toHaveBeenCalled() - + expect(response.headers.get('Content-Type')).toBe('application/zip') + expect(response.headers.get('Content-Disposition')).toBe( + 'attachment; filename="workspace-files.zip"' + ) + expect(response.headers.get('Cache-Control')).toBe('no-store') const zip = await zipFrom(response) - - expect(mockDownloadFileStream).toHaveBeenCalledTimes(1) - // Never routed through the buffering document reader. - expect(mockFetchServableWorkspaceFileBuffer).not.toHaveBeenCalled() - - const entry = zip.file('Reports/clip.mp4') - expect(entry).not.toBeNull() - expect(await entry!.async('string')).toBe('plain') + expect(await zip.file('clip.mp4')?.async('string')).toBe('plain') }) - it('preserves nested folder paths across both entry kinds', async () => { - mockListWorkspaceFileFolders.mockResolvedValue([ - { id: 'folder-1', name: 'Reports', parentId: null }, - { id: 'folder-2', name: 'visuals', parentId: 'folder-1' }, - ]) - mockListWorkspaceFiles.mockResolvedValue([ - generatedDocument('f1', 'summary.docx', 'folder-1'), - workspaceFile('f2', 'hero.png', 'folder-2'), + it('preserves rendered entries and folder paths supplied by the use case', async () => { + const archiveResult = result([ + { id: 'f1', name: 'overview.docx', folderId: 'folder-1' }, + { id: 'f2', name: 'hero.png', folderId: null }, ]) - mockFetchServableWorkspaceFileBuffer.mockResolvedValue({ - buffer: Buffer.from('PKdoc'), - contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - }) + archiveResult.renderedDocuments.set('f1', Buffer.from('rendered')) + mockDownloadItems.mockResolvedValue(archiveResult) const zip = await zipFrom(await GET(requestFor('fileIds=f1&fileIds=f2'), context)) - expect(zip.file('Reports/summary.docx')).not.toBeNull() - expect(zip.file('visuals/hero.png')).not.toBeNull() - }) - - it('returns 409 naming the documents whose artifacts are still compiling', async () => { - mockListWorkspaceFiles.mockResolvedValue([ - generatedDocument('f1', 'ready.docx'), - generatedDocument('f2', 'pending.docx'), - ]) - mockFetchServableWorkspaceFileBuffer.mockImplementation(async (file: { name: string }) => { - if (file.name === 'pending.docx') - throw new DocCompileUserError('Document is still being generated') - return { buffer: Buffer.from('PKok'), contentType: 'application/octet-stream' } - }) - - const response = await GET(requestFor('fileIds=f1&fileIds=f2'), context) - - expect(response.status).toBe(409) - const body = await response.json() - expect(body.error).toContain('pending.docx') - expect(body.error).not.toContain('ready.docx') - }) - - it('rejects with 400, not 500, when a document blows its own allowance', async () => { - mockListWorkspaceFiles.mockResolvedValue([generatedDocument('f1', 'huge.docx')]) - mockFetchServableWorkspaceFileBuffer.mockRejectedValue( - new PayloadSizeLimitError({ label: 'servable file download', maxBytes: 1 }) - ) - - const response = await GET(requestFor('fileIds=f1'), context) - - expect(response.status).toBe(400) - const body = await response.json() - expect(body.error).toContain('huge.docx') - expect(body.error).not.toContain('Selected files total') + expect(await zip.file('Reports/overview.docx')?.async('string')).toBe('rendered') + expect(await zip.file('hero.png')?.async('string')).toBe('plain') }) - it('counts streamed files against the same budget as rendered documents', async () => { - // 200 MB of ordinary files leaves 50 MB of the 250 MB budget for documents. - mockListWorkspaceFiles.mockResolvedValue([ - { ...workspaceFile('f1', 'clip.mp4'), size: 200 * MB }, - generatedDocument('f2', 'report.docx'), - ]) - mockFetchServableWorkspaceFileBuffer.mockResolvedValue({ - buffer: Buffer.from('PKdoc'), - contentType: 'application/octet-stream', - }) - - await zipFrom(await GET(requestFor('fileIds=f1&fileIds=f2'), context)) - - // Without reserving the streamed bytes the document would get the full 50 MB - // ceiling, letting the archive ship 250 MB of documents on top of 200 MB of video. - expect(mockFetchServableWorkspaceFileBuffer.mock.calls[0][1].maxBytes).toBe(50 * MB) - }) + it('preserves the internal product analytics event after authorization and selection', async () => { + await GET(requestFor('fileIds=f1'), context) - it('rejects when streamed files leave no budget for a document', async () => { - mockListWorkspaceFiles.mockResolvedValue([ - { ...workspaceFile('f1', 'clip.mp4'), size: 249 * MB }, - generatedDocument('f2', 'report.docx'), - ]) - mockFetchServableWorkspaceFileBuffer.mockRejectedValue( - new PayloadSizeLimitError({ label: 'servable file download', maxBytes: 1 }) + expect(mockCaptureServerEvent).toHaveBeenCalledWith( + 'user-1', + 'file_downloaded', + { workspace_id: WORKSPACE_ID, is_bulk: true, file_count: 1 }, + { groups: { workspace: WORKSPACE_ID } } ) - - const response = await GET(requestFor('fileIds=f1&fileIds=f2'), context) - - expect(response.status).toBe(400) - const body = await response.json() - expect(body.error).toContain('once documents are rendered') - // No byte count: the rendered total is not knowable, so quoting one would mislead. - expect(body.error).not.toContain('Selected files total') }) - it('blames the shared budget once earlier documents have consumed it', async () => { - mockListWorkspaceFiles.mockResolvedValue([ - generatedDocument('f1', 'first.docx'), - generatedDocument('f2', 'second.docx'), - ]) - mockFetchServableWorkspaceFileBuffer.mockImplementation(async (file: { name: string }) => { - // The first document eats the whole budget, so the second's cap is the remainder. - if (file.name === 'first.docx') { - return { buffer: Buffer.alloc(240 * MB), contentType: 'application/octet-stream' } - } - throw new PayloadSizeLimitError({ label: 'servable file download', maxBytes: 1 }) - }) - - const response = await GET(requestFor('fileIds=f1&fileIds=f2'), context) - - expect(response.status).toBe(400) - const body = await response.json() - expect(body.error).toContain('once documents are rendered') - expect(body.error).not.toContain('second.docx') - }) - - it.each([ - ['text/x-docxjs', 'report.docx'], - ['text/x-pptxgenjs', 'deck.pptx'], - ['text/x-pdflibjs', 'isolated.pdf'], - ['text/x-python-pdf', 'sandboxed.pdf'], - ['text/x-python-xlsx', 'sheet.xlsx'], - ])('resolves %s rather than streaming its source', async (type, name) => { - // Both PDF generators must be covered: the isolated-vm path stores pdf-lib JS and - // the E2B path stores Python, and either one streamed raw is the corruption bug. - mockListWorkspaceFiles.mockResolvedValue([{ ...workspaceFile('f1', name), type }]) - mockFetchServableWorkspaceFileBuffer.mockResolvedValue({ - buffer: Buffer.from('PKrendered'), - contentType: 'application/octet-stream', - }) - - await zipFrom(await GET(requestFor('fileIds=f1'), context)) - - expect(mockFetchServableWorkspaceFileBuffer).toHaveBeenCalledTimes(1) - expect(mockDownloadFileStream).not.toHaveBeenCalled() - }) - - it('streams an uploaded office file rather than resolving it', async () => { - // A real upload serves exactly its stored bytes, so it must not take the buffered - // path — otherwise a selection of large decks is held in memory for nothing. - const upload = { - ...workspaceFile('f1', 'deck.pptx'), - size: 80 * MB, - type: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', - } - mockListWorkspaceFiles.mockResolvedValue([upload]) + it('authenticates before parsing and dispatching the use case', async () => { + mockGetSession.mockResolvedValue(null) const response = await GET(requestFor('fileIds=f1'), context) - await zipFrom(response) - expect(response.status).toBe(200) - expect(mockFetchServableWorkspaceFileBuffer).not.toHaveBeenCalled() - expect(mockDownloadFileStream).toHaveBeenCalledTimes(1) + expect(response.status).toBe(401) + expect(mockDownloadItems).not.toHaveBeenCalled() }) - it('caps a generated document at the render headroom', async () => { - mockListWorkspaceFiles.mockResolvedValue([generatedDocument('f1', 'report.docx')]) - mockFetchServableWorkspaceFileBuffer.mockResolvedValue({ - buffer: Buffer.from('PKdoc'), - contentType: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', - }) + it('keeps application validation errors in the legacy error envelope', async () => { + mockDownloadItems.mockRejectedValue(new Error('should not be raw')) - await zipFrom(await GET(requestFor('fileIds=f1'), context)) - - expect(mockFetchServableWorkspaceFileBuffer.mock.calls[0][1].maxBytes).toBe(50 * MB) - }) - - it('surfaces a storage failure as a 500 even when another document is pending', async () => { - mockListWorkspaceFiles.mockResolvedValue([ - generatedDocument('f1', 'pending.docx'), - generatedDocument('f2', 'broken.docx'), - ]) - mockFetchServableWorkspaceFileBuffer.mockImplementation(async (file: { name: string }) => { - if (file.name === 'pending.docx') - throw new DocCompileUserError('Document is still being generated') - throw new Error('storage down') - }) - - const response = await GET(requestFor('fileIds=f1&fileIds=f2'), context) - - // A 409 would tell the client to retry something that can never succeed. - expect(response.status).toBe(500) - }) - - it('stops resolving documents once one hard-fails', async () => { - const files = Array.from({ length: 20 }, (_, index) => - generatedDocument(`f${index}`, `doc${index}.docx`) - ) - mockListWorkspaceFiles.mockResolvedValue(files) - mockFetchServableWorkspaceFileBuffer.mockImplementation(async (file: { name: string }) => { - if (file.name === 'doc0.docx') throw new Error('storage down') - return { buffer: Buffer.from('PKok'), contentType: 'application/octet-stream' } - }) - - const response = await GET( - requestFor(files.map((file) => `fileIds=${file.id}`).join('&')), - context - ) + const response = await GET(requestFor('fileIds=f1'), context) expect(response.status).toBe(500) - expect(mockFetchServableWorkspaceFileBuffer.mock.calls.length).toBeLessThan(files.length) - }) - - it('rejects a selection whose declared sizes already exceed the limit', async () => { - mockListWorkspaceFiles.mockResolvedValue([ - { ...workspaceFile('f1', 'a.mp4'), size: 200 * MB }, - { ...workspaceFile('f2', 'b.mp4'), size: 200 * MB }, - ]) - - const response = await GET(requestFor('fileIds=f1&fileIds=f2'), context) - - expect(response.status).toBe(400) - expect(mockDownloadFileStream).not.toHaveBeenCalled() + expect(await response.json()).toEqual({ error: 'Internal server error' }) }) }) diff --git a/apps/sim/app/api/workspaces/[id]/files/download/route.ts b/apps/sim/app/api/workspaces/[id]/files/download/route.ts index 67b070f303f..20853cdd77e 100644 --- a/apps/sim/app/api/workspaces/[id]/files/download/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/download/route.ts @@ -1,57 +1,37 @@ import { Readable } from 'node:stream' -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { ZipArchive } from 'archiver' -import { type NextRequest, NextResponse } from 'next/server' +import { NextResponse } from 'next/server' import { downloadWorkspaceFileItemsContract } from '@/lib/api/contracts/workspace-file-folders' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' +import { + defineInternalBinaryRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' import { nodeReadableToWebStream } from '@/lib/core/utils/node-stream' -import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { captureServerEvent } from '@/lib/posthog/server' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' -import { - buildWorkspaceFileFolderPathMap, - fetchServableWorkspaceFileBuffer, - listWorkspaceFileFolders, - listWorkspaceFiles, -} from '@/lib/uploads/contexts/workspace' import { downloadFileStream } from '@/lib/uploads/core/storage-service' -import { - formatFileSize, - isGeneratedDocumentSourceType, - isRenderableDocumentName, - MAX_RENDERED_DOCUMENT_BYTES, -} from '@/lib/uploads/utils/file-utils' -import { docNotReadyMessage, isDocNotReadyError } from '@/lib/uploads/utils/servable-file-response' import { buildZipEntryPaths } from '@/lib/uploads/zip-entry-path' -import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' +import { downloadWorkspaceFileItems } from '@/lib/workspace-files/application/download-workspace-file-items' const logger = createLogger('WorkspaceFilesDownloadAPI') -const MAX_ZIP_DOWNLOAD_FILES = 100 -const MAX_ZIP_DOWNLOAD_BYTES = 250 * 1024 * 1024 -/** - * Whether this entry's stored bytes are a generation source that has to be resolved - * before it can go in the archive. An ordinary uploaded `.docx` serves exactly what is - * stored, so it streams like anything else — routing every office extension through the - * buffered path would hold a whole selection of real documents in memory for a check - * the resolver settles on the first few magic bytes. Metadata without a type falls back - * to the extension: better to resolve and pass through than to stream source text under - * a document name. - */ -function needsRendering(file: WorkspaceFileRecord): boolean { - return file.type ? isGeneratedDocumentSourceType(file.type) : isRenderableDocumentName(file.name) +const downloadErrorPolicy = { + render(error: unknown): NextResponse | null { + const classified = asOrchestrationError(error) + if (classified) { + return NextResponse.json( + { error: classified.message }, + { status: statusForOrchestrationError(classified.code) } + ) + } + logger.error('Failed to download workspace file selection', { error }) + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + }, } -/** - * A `Readable` that opens its storage read on first pull rather than up front. The - * archiver works through entries sequentially, so handing it an open stream per entry - * would hold a connection per selected file — more than the storage client pools — with - * most sitting idle until their turn. The generator body does not run until the first - * read, and a failure to open surfaces as the stream's `error` event. - */ function lazyWorkspaceFileStream(file: WorkspaceFileRecord): Readable { return Readable.from( (async function* () { @@ -60,204 +40,55 @@ function lazyWorkspaceFileStream(file: WorkspaceFileRecord): Readable { context: file.storageContext ?? 'workspace', }) })(), - // `Readable.from` defaults to object mode; these are bytes headed for an archive. { objectMode: false } ) } -function selectionTooLargeResponse(bytes: number): NextResponse { - return NextResponse.json( - { - error: `Selected files total ${formatFileSize(bytes)}, which exceeds the ${formatFileSize(MAX_ZIP_DOWNLOAD_BYTES)} download limit.`, - }, - { status: 400 } - ) -} - -/** - * The rendered archive would exceed the limit even though the declared sizes did not — - * generated documents render to more than the source they declared, so no accurate byte - * count exists to quote here. - */ -function archiveTooLargeResponse(): NextResponse { - return NextResponse.json( - { - error: `The selected files exceed the ${formatFileSize(MAX_ZIP_DOWNLOAD_BYTES)} download limit once documents are rendered. Select fewer files.`, - }, - { status: 400 } - ) -} - -function collectDescendantFolderIds( - selectedFolderIds: string[], - folders: Array<{ id: string; parentId: string | null }> -): Set { - const folderIds = new Set(selectedFolderIds) - let changed = true - while (changed) { - changed = false - for (const folder of folders) { - if (folder.parentId && folderIds.has(folder.parentId) && !folderIds.has(folder.id)) { - folderIds.add(folder.id) - changed = true - } - } - } - return folderIds -} - -export const GET = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(downloadWorkspaceFileItemsContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId } = parsed.data.params - const { fileIds, folderIds } = parsed.data.query - - const permission = await verifyWorkspaceMembership(session.user.id, workspaceId) - if (!permission) { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - try { - const [files, folders] = await Promise.all([ - listWorkspaceFiles(workspaceId, { hydrateFolderPaths: false }), - listWorkspaceFileFolders(workspaceId), - ]) - const folderPaths = buildWorkspaceFileFolderPathMap(folders) - const selectedFolderIds = collectDescendantFolderIds(folderIds, folders) - const requestedFileIds = new Set(fileIds) - const filesToZip = files.filter( - (file) => - requestedFileIds.has(file.id) || (file.folderId && selectedFolderIds.has(file.folderId)) - ) - - if (filesToZip.length === 0) { - return NextResponse.json({ error: 'No files selected for download' }, { status: 400 }) - } - - if (filesToZip.length > MAX_ZIP_DOWNLOAD_FILES) { - return NextResponse.json( - { - error: `Too many files selected for download. Select ${MAX_ZIP_DOWNLOAD_FILES} or fewer files.`, - }, - { status: 400 } - ) - } - - const declaredBytes = filesToZip.reduce((sum, file) => sum + file.size, 0) - if (declaredBytes > MAX_ZIP_DOWNLOAD_BYTES) { - return selectionTooLargeResponse(declaredBytes) - } - - // Streamed entries ship exactly what they declared, so their share of the budget is - // known up front and is reserved here. Documents then draw from what is left — - // one budget across both kinds, or the archive could ship two full limits' worth. - const reservedForStreamed = filesToZip - .filter((file) => !needsRendering(file)) - .reduce((sum, file) => sum + file.size, 0) - - // Generated documents are resolved before the archive starts: once the first byte - // is written the status code is committed, so anything that can still fail the - // request has to fail here. Their buffers are held until the archive is assembled, - // bounded by what is left of the request's byte budget. - const renderedDocuments = new Map() - const pendingNames: string[] = [] - let renderedBytes = 0 - - for (const file of filesToZip) { - if (!needsRendering(file)) continue - - const remaining = Math.max(0, MAX_ZIP_DOWNLOAD_BYTES - reservedForStreamed - renderedBytes) - // A source's declared size says nothing about what it renders to, so the cap is - // the per-document ceiling, bounded by what is left of the budget. - const allowance = Math.min(remaining, MAX_RENDERED_DOCUMENT_BYTES) - - try { - const { buffer } = await fetchServableWorkspaceFileBuffer(file, { maxBytes: allowance }) - renderedBytes += buffer.length - renderedDocuments.set(file.id, buffer) - } catch (error) { - if (error instanceof PayloadSizeLimitError) { - // Blamed on the entry when its own ceiling was the binding cap; otherwise the - // documents ahead of it have consumed the budget. - return allowance === MAX_RENDERED_DOCUMENT_BYTES - ? NextResponse.json( - { - error: `"${file.name}" renders to more than ${formatFileSize(MAX_RENDERED_DOCUMENT_BYTES)} and is too large to include in a zip; download it on its own instead.`, - }, - { status: 400 } - ) - : archiveTooLargeResponse() - } - // Pending artifacts are collected so the 409 can name all of them; anything - // else dooms the request and waiting cannot fix it. - if (!isDocNotReadyError(error)) throw error - pendingNames.push(file.name) - } - } - - if (pendingNames.length > 0) { - return NextResponse.json({ error: docNotReadyMessage(pendingNames) }, { status: 409 }) - } - - // Entry paths stay workspace-root-relative so a mixed selection of folders and - // loose files keeps the layout the user sees in the files list. - const entryPaths = buildZipEntryPaths( - filesToZip.map((file) => ({ - name: file.name, - folderPath: file.folderId ? folderPaths.get(file.folderId) : null, - })) - ) - - // Ordinary files are never materialized: each entry opens its storage read only - // when the archiver reaches it, so one entry is resident rather than the archive. - const archive = new ZipArchive({ store: true }) - archive.on('warning', (error: Error) => { - logger.warn('Archive warning while streaming workspace files', { error }) - }) - - filesToZip.forEach((file, index) => { - const rendered = renderedDocuments.get(file.id) - archive.append(rendered ?? lazyWorkspaceFileStream(file), { name: entryPaths[index] }) - }) - archive.finalize().catch((error) => { - // The archive's `error` event already fails the response stream; this keeps the - // same failure from also surfacing as an unhandled rejection. - logger.error('Failed to finalize workspace file archive', { error }) - }) - - recordAudit({ - workspaceId, - actorId: session.user.id, - action: AuditAction.FILE_DOWNLOADED, - resourceType: AuditResourceType.FILE, - description: `Downloaded ${filesToZip.length} file${filesToZip.length === 1 ? '' : 's'} as zip`, - metadata: { fileCount: filesToZip.length, totalBytes: declaredBytes }, - request, - }) - captureServerEvent( - session.user.id, - 'file_downloaded', - { workspace_id: workspaceId, is_bulk: true, file_count: filesToZip.length }, - { groups: { workspace: workspaceId } } - ) - - // No Content-Length: the archive size is not known until it has been produced. - return new NextResponse(nodeReadableToWebStream(archive), { - headers: { - 'Content-Type': 'application/zip', - 'Content-Disposition': 'attachment; filename="workspace-files.zip"', - 'Cache-Control': 'no-store', - }, +export const GET = defineInternalBinaryRoute({ + contract: downloadWorkspaceFileItemsContract, + auth: internalSessionAuth, + operation: downloadWorkspaceFileItems.operation, + rateLimit: internalRateLimits.none({ reason: 'Internal workspace zip download' }), + errorPolicy: downloadErrorPolicy, + mapInput: ({ params, query }) => ({ + workspaceId: params.id, + fileIds: query.fileIds, + folderIds: query.folderIds, + }), + useCase: downloadWorkspaceFileItems, + onSuccess: ({ principal, input, result }) => { + captureServerEvent( + principal.userId, + 'file_downloaded', + { workspace_id: input.workspaceId, is_bulk: true, file_count: result.filesToZip.length }, + { groups: { workspace: input.workspaceId } } + ) + }, + present: ({ filesToZip, folderPaths, renderedDocuments }) => { + const entryPaths = buildZipEntryPaths( + filesToZip.map((file) => ({ + name: file.name, + folderPath: file.folderId ? folderPaths.get(file.folderId) : null, + })) + ) + const archive = new ZipArchive({ store: true }) + archive.on('warning', (error: Error) => { + logger.warn('Archive warning while streaming workspace files', { error }) + }) + filesToZip.forEach((file, index) => { + archive.append(renderedDocuments.get(file.id) ?? lazyWorkspaceFileStream(file), { + name: entryPaths[index], }) - } catch (error) { - logger.error('Failed to download workspace file selection:', error) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + }) + archive.finalize().catch((error) => { + logger.error('Failed to finalize workspace file archive', { error }) + }) + + return { + body: nodeReadableToWebStream(archive), + contentType: 'application/zip', + contentDisposition: 'attachment; filename="workspace-files.zip"', + headers: { 'Cache-Control': 'no-store' }, } - } -) + }, +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/restore/route.ts b/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/restore/route.ts index ecf7b17b281..631702a3c5b 100644 --- a/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/restore/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/restore/route.ts @@ -1,64 +1,31 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' import { restoreWorkspaceFileFolderContract } from '@/lib/api/contracts/workspace-file-folders' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { statusForOrchestrationError } from '@/lib/core/orchestration/types' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + defineInternalJsonRoute, + internalFileErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' import { captureServerEvent } from '@/lib/posthog/server' -import { performRestoreWorkspaceFileFolder } from '@/lib/workspace-files/orchestration' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { restoreWorkspaceFileFolderOperation } from '@/lib/workspace-files/application/workspace-file-folders' -const logger = createLogger('WorkspaceFileFolderRestoreAPI') - -export const POST = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string; folderId: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(restoreWorkspaceFileFolderContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId, folderId } = parsed.data.params - - const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (permission !== 'admin' && permission !== 'write') { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - try { - const result = await performRestoreWorkspaceFileFolder({ - workspaceId, - folderId, - userId: session.user.id, - }) - if (!result.success) { - return NextResponse.json( - { success: false, error: result.error }, - { status: statusForOrchestrationError(result.errorCode) } - ) - } - const { folder, restoredItems } = result - if (!folder || !restoredItems) { - return NextResponse.json( - { success: false, error: 'Failed to restore workspace file folder' }, - { status: 500 } - ) - } - - logger.info(`Restored workspace file folder: ${folderId}`) - - captureServerEvent( - session.user.id, - 'folder_restored', - { folder_id: folderId, workspace_id: workspaceId }, - { groups: { workspace: workspaceId } } - ) - return NextResponse.json({ success: true, folder, restoredItems }) - } catch (error) { - logger.error('Failed to restore workspace file folder:', error) - return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }) - } - } -) +export const POST = defineInternalJsonRoute({ + contract: restoreWorkspaceFileFolderContract, + auth: internalSessionAuth, + operation: fileOperations.restoreFolder, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal folder restore behavior', + }), + errorPolicy: internalFileErrorPolicy, + mapInput: ({ params }) => ({ workspaceId: params.id, folderId: params.folderId }), + useCase: restoreWorkspaceFileFolderOperation, + onSuccess: ({ principal, input }) => { + captureServerEvent( + principal.userId, + 'folder_restored', + { folder_id: input.folderId, workspace_id: input.workspaceId }, + { groups: { workspace: input.workspaceId } } + ) + }, + present: ({ folder, restoredItems }) => ({ success: true, folder, restoredItems }), +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.test.ts new file mode 100644 index 00000000000..8a9873a8eb0 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.test.ts @@ -0,0 +1,143 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getSession: vi.fn(), + updateFolder: vi.fn(), + deleteFolder: vi.fn(), + restoreFolder: vi.fn(), + captureServerEvent: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ getSession: mocks.getSession })) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.captureServerEvent })) +vi.mock('@/lib/workspace-files/application/workspace-file-folders', () => ({ + updateWorkspaceFileFolderOperation: { + operation: { id: 'files.folders.update', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.updateFolder, + }, + deleteWorkspaceFileFolderOperation: { + operation: { id: 'files.folders.delete', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.deleteFolder, + }, + restoreWorkspaceFileFolderOperation: { + operation: { id: 'files.folders.restore', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.restoreFolder, + }, +})) + +import { POST as RESTORE } from '@/app/api/workspaces/[id]/files/folders/[folderId]/restore/route' +import { DELETE, PATCH } from '@/app/api/workspaces/[id]/files/folders/[folderId]/route' + +const WORKSPACE_ID = 'workspace-1' +const FOLDER_ID = 'folder-1' +const PRINCIPAL = { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' } +const context = { + params: Promise.resolve({ id: WORKSPACE_ID, folderId: FOLDER_ID }), +} +const folder = { + id: FOLDER_ID, + workspaceId: WORKSPACE_ID, + userId: 'user-1', + name: 'Reports', + parentId: null, + path: '/Reports', + sortOrder: 0, + deletedAt: null, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), +} +const serializedFolder = { + ...folder, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', +} + +function request(method: 'PATCH' | 'DELETE' | 'POST', body?: unknown) { + return new NextRequest( + `http://localhost:3000/api/workspaces/${WORKSPACE_ID}/files/folders/${FOLDER_ID}${method === 'POST' ? '/restore' : ''}`, + { + method, + ...(body === undefined + ? {} + : { + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + } + ) +} + +describe('/api/workspaces/[id]/files/folders/[folderId]', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getSession.mockResolvedValue({ user: { id: 'user-1' }, session: { id: 'session-1' } }) + mocks.updateFolder.mockResolvedValue({ folder }) + mocks.deleteFolder.mockResolvedValue({ deletedItems: { folders: 1, files: 2 } }) + mocks.restoreFolder.mockResolvedValue({ + folder, + restoredItems: { folders: 1, files: 2 }, + }) + }) + + it('updates a folder through the shared use case', async () => { + const response = await PATCH(request('PATCH', { name: 'Reports' }), context) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ success: true, folder: serializedFolder }) + expect(mocks.updateFolder).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { workspaceId: WORKSPACE_ID, folderId: FOLDER_ID, name: 'Reports' }, + request: expect.anything(), + }) + expect(mocks.captureServerEvent).toHaveBeenCalledWith( + 'user-1', + 'folder_renamed', + { workspace_id: WORKSPACE_ID }, + { groups: { workspace: WORKSPACE_ID } } + ) + }) + + it('deletes a folder through the shared use case', async () => { + const response = await DELETE(request('DELETE'), context) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + deletedItems: { folders: 1, files: 2 }, + }) + expect(mocks.deleteFolder).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { workspaceId: WORKSPACE_ID, folderId: FOLDER_ID }, + request: expect.anything(), + }) + }) + + it('restores a folder through the shared use case', async () => { + const response = await RESTORE(request('POST'), context) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + folder: serializedFolder, + restoredItems: { folders: 1, files: 2 }, + }) + expect(mocks.restoreFolder).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { workspaceId: WORKSPACE_ID, folderId: FOLDER_ID }, + request: expect.anything(), + }) + }) + + it('authenticates before parsing a folder mutation', async () => { + mocks.getSession.mockResolvedValueOnce(null) + + const response = await PATCH(request('PATCH', { name: 'Reports' }), context) + + expect(response.status).toBe(401) + expect(mocks.updateFolder).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.ts b/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.ts index 079f25e8459..42cdbb596aa 100644 --- a/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.ts @@ -1,121 +1,58 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' import { deleteWorkspaceFileFolderContract, updateWorkspaceFileFolderContract, } from '@/lib/api/contracts/workspace-file-folders' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { statusForOrchestrationError } from '@/lib/core/orchestration/types' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + defineInternalJsonRoute, + internalFileErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' import { captureServerEvent } from '@/lib/posthog/server' +import { fileOperations } from '@/lib/workspace-files/application/operations' import { - performDeleteWorkspaceFileItems, - performUpdateWorkspaceFileFolder, -} from '@/lib/workspace-files/orchestration' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' - -const logger = createLogger('WorkspaceFileFolderAPI') - -async function assertWritePermission(userId: string, workspaceId: string) { - const permission = await getUserEntityPermissions(userId, 'workspace', workspaceId) - return permission === 'admin' || permission === 'write' -} - -export const PATCH = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string; folderId: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(updateWorkspaceFileFolderContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId, folderId } = parsed.data.params - - if (!(await assertWritePermission(session.user.id, workspaceId))) { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - try { - const result = await performUpdateWorkspaceFileFolder({ - workspaceId, - folderId, - userId: session.user.id, - ...parsed.data.body, - }) - if (!result.success || !result.folder) { - return NextResponse.json( - { success: false, error: result.error }, - { status: statusForOrchestrationError(result.errorCode) } - ) - } - captureServerEvent( - session.user.id, - 'folder_renamed', - { workspace_id: workspaceId }, - { groups: { workspace: workspaceId } } - ) - return NextResponse.json({ success: true, folder: result.folder }) - } catch (error) { - logger.error('Failed to update workspace file folder:', error) - return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }) - } - } -) - -export const DELETE = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string; folderId: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(deleteWorkspaceFileFolderContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId, folderId } = parsed.data.params - - if (!(await assertWritePermission(session.user.id, workspaceId))) { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - try { - const result = await performDeleteWorkspaceFileItems({ - workspaceId, - userId: session.user.id, - folderIds: [folderId], - }) - if (!result.success) { - return NextResponse.json( - { success: false, error: result.error }, - { - status: - result.errorCode === 'validation' - ? 400 - : result.errorCode === 'not_found' - ? 404 - : 500, - } - ) - } - if (!result.deletedItems) { - return NextResponse.json( - { success: false, error: 'Failed to delete workspace file folder' }, - { status: 500 } - ) - } - - captureServerEvent( - session.user.id, - 'folder_deleted', - { workspace_id: workspaceId }, - { groups: { workspace: workspaceId } } - ) - - return NextResponse.json({ success: true, deletedItems: result.deletedItems }) - } catch (error) { - logger.error('Failed to delete workspace file folder:', error) - return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }) - } - } -) + deleteWorkspaceFileFolderOperation, + updateWorkspaceFileFolderOperation, +} from '@/lib/workspace-files/application/workspace-file-folders' + +export const PATCH = defineInternalJsonRoute({ + contract: updateWorkspaceFileFolderContract, + auth: internalSessionAuth, + operation: fileOperations.updateFolder, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal folder update behavior', + }), + errorPolicy: internalFileErrorPolicy, + mapInput: ({ params, body }) => ({ workspaceId: params.id, folderId: params.folderId, ...body }), + useCase: updateWorkspaceFileFolderOperation, + onSuccess: ({ principal, input }) => { + captureServerEvent( + principal.userId, + 'folder_renamed', + { workspace_id: input.workspaceId }, + { groups: { workspace: input.workspaceId } } + ) + }, + present: ({ folder }) => ({ success: true, folder }), +}) + +export const DELETE = defineInternalJsonRoute({ + contract: deleteWorkspaceFileFolderContract, + auth: internalSessionAuth, + operation: fileOperations.deleteFolder, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal folder deletion behavior', + }), + errorPolicy: internalFileErrorPolicy, + mapInput: ({ params }) => ({ workspaceId: params.id, folderId: params.folderId }), + useCase: deleteWorkspaceFileFolderOperation, + onSuccess: ({ principal, input }) => { + captureServerEvent( + principal.userId, + 'folder_deleted', + { workspace_id: input.workspaceId }, + { groups: { workspace: input.workspaceId } } + ) + }, + present: ({ deletedItems }) => ({ success: true, deletedItems }), +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/folders/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/folders/route.test.ts new file mode 100644 index 00000000000..e9645fe19ba --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/folders/route.test.ts @@ -0,0 +1,114 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getSession: vi.fn(), + listFolders: vi.fn(), + createFolder: vi.fn(), + captureServerEvent: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ getSession: mocks.getSession })) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.captureServerEvent })) +vi.mock('@/lib/workspace-files/application/workspace-file-folders', () => ({ + listWorkspaceFileFoldersOperation: { + operation: { id: 'files.folders.list', minimumRole: 'read', workspaceApiKey: 'allow' }, + execute: mocks.listFolders, + }, + createWorkspaceFileFolderOperation: { + operation: { id: 'files.folders.create', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.createFolder, + }, +})) + +import { GET, POST } from '@/app/api/workspaces/[id]/files/folders/route' + +const WORKSPACE_ID = 'workspace-1' +const context = { params: Promise.resolve({ id: WORKSPACE_ID }) } +const folder = { + id: 'folder-1', + workspaceId: WORKSPACE_ID, + userId: 'user-1', + name: 'Reports', + parentId: null, + path: '/Reports', + sortOrder: 0, + deletedAt: null, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), +} +const serializedFolder = { + ...folder, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', +} + +function request(method: 'GET' | 'POST', body?: unknown) { + return new NextRequest(`http://localhost:3000/api/workspaces/${WORKSPACE_ID}/files/folders`, { + method, + ...(body === undefined + ? {} + : { + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + }) +} + +describe('/api/workspaces/[id]/files/folders', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getSession.mockResolvedValue({ user: { id: 'user-1' }, session: { id: 'session-1' } }) + mocks.listFolders.mockResolvedValue({ folders: [folder] }) + mocks.createFolder.mockResolvedValue({ folder }) + }) + + it('authenticates before listing folders', async () => { + mocks.getSession.mockResolvedValueOnce(null) + + const response = await GET(request('GET'), context) + + expect(response.status).toBe(401) + expect(mocks.listFolders).not.toHaveBeenCalled() + }) + + it('lists folders through the shared use case', async () => { + const response = await GET(request('GET'), context) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ success: true, folders: [serializedFolder] }) + expect(mocks.listFolders).toHaveBeenCalledWith({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: WORKSPACE_ID, scope: 'active' }, + request: expect.anything(), + }) + }) + + it('creates a folder and preserves the internal success event', async () => { + const response = await POST(request('POST', { name: 'Reports' }), context) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ success: true, folder: serializedFolder }) + expect(mocks.createFolder).toHaveBeenCalledWith({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: WORKSPACE_ID, name: 'Reports', parentId: undefined }, + request: expect.anything(), + }) + expect(mocks.captureServerEvent).toHaveBeenCalledWith( + 'user-1', + 'folder_created', + { workspace_id: WORKSPACE_ID }, + { groups: { workspace: WORKSPACE_ID } } + ) + }) + + it('rejects an invalid folder name before the use case', async () => { + const response = await POST(request('POST', { name: 'nested/name' }), context) + + expect(response.status).toBe(400) + expect(mocks.createFolder).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/folders/route.ts b/apps/sim/app/api/workspaces/[id]/files/folders/route.ts index ba3180cb609..21e54a67c53 100644 --- a/apps/sim/app/api/workspaces/[id]/files/folders/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/folders/route.ts @@ -1,86 +1,54 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' import { createWorkspaceFileFolderContract, listWorkspaceFileFoldersContract, } from '@/lib/api/contracts/workspace-file-folders' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { statusForOrchestrationError } from '@/lib/core/orchestration/types' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + defineInternalJsonRoute, + internalFileErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' import { captureServerEvent } from '@/lib/posthog/server' -import { listWorkspaceFileFolders } from '@/lib/uploads/contexts/workspace' -import { performCreateWorkspaceFileFolder } from '@/lib/workspace-files/orchestration' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' - -const logger = createLogger('WorkspaceFileFoldersAPI') - -async function getWorkspacePermission(userId: string, workspaceId: string) { - return getUserEntityPermissions(userId, 'workspace', workspaceId) -} - -export const GET = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(listWorkspaceFileFoldersContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId } = parsed.data.params - const { scope } = parsed.data.query - - const permission = await getWorkspacePermission(session.user.id, workspaceId) - if (!permission) { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - const folders = await listWorkspaceFileFolders(workspaceId, { scope }) - return NextResponse.json({ success: true, folders }) - } -) - -export const POST = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(createWorkspaceFileFolderContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId } = parsed.data.params - const { name, parentId } = parsed.data.body - - const permission = await getWorkspacePermission(session.user.id, workspaceId) - if (permission !== 'admin' && permission !== 'write') { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - try { - const result = await performCreateWorkspaceFileFolder({ - workspaceId, - userId: session.user.id, - name, - parentId, - }) - if (!result.success || !result.folder) { - return NextResponse.json( - { success: false, error: result.error }, - { status: statusForOrchestrationError(result.errorCode) } - ) - } - captureServerEvent( - session.user.id, - 'folder_created', - { workspace_id: workspaceId }, - { groups: { workspace: workspaceId } } - ) - return NextResponse.json({ success: true, folder: result.folder }) - } catch (error) { - logger.error('Failed to create workspace file folder:', error) - return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }) - } - } -) +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { + createWorkspaceFileFolderOperation, + listWorkspaceFileFoldersOperation, +} from '@/lib/workspace-files/application/workspace-file-folders' + +export const GET = defineInternalJsonRoute({ + contract: listWorkspaceFileFoldersContract, + auth: internalSessionAuth, + operation: fileOperations.listFolders, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal folder listing behavior', + }), + errorPolicy: internalFileErrorPolicy, + mapInput: ({ params, query }) => ({ workspaceId: params.id, scope: query.scope }), + useCase: listWorkspaceFileFoldersOperation, + present: ({ folders }) => ({ success: true, folders }), +}) + +export const POST = defineInternalJsonRoute({ + contract: createWorkspaceFileFolderContract, + auth: internalSessionAuth, + operation: fileOperations.createFolder, + rateLimit: internalRateLimits.none({ + reason: 'Preserve existing internal folder creation behavior', + }), + errorPolicy: internalFileErrorPolicy, + mapInput: ({ params, body }) => ({ + workspaceId: params.id, + name: body.name, + parentId: body.parentId, + }), + useCase: createWorkspaceFileFolderOperation, + onSuccess: ({ principal, input }) => { + captureServerEvent( + principal.userId, + 'folder_created', + { workspace_id: input.workspaceId }, + { groups: { workspace: input.workspaceId } } + ) + }, + present: ({ folder }) => ({ success: true, folder }), +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/inline/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/inline/route.test.ts index 11726424281..495044427bb 100644 --- a/apps/sim/app/api/workspaces/[id]/files/inline/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/files/inline/route.test.ts @@ -4,77 +4,83 @@ import { authMockFns } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { OrchestrationError } from '@/lib/core/orchestration/types' -const { mockGetPerms, mockResolveImage, mockDownloadFile } = vi.hoisted(() => ({ - mockGetPerms: vi.fn(), - mockResolveImage: vi.fn(), - mockDownloadFile: vi.fn(), -})) +const { mockReadInline } = vi.hoisted(() => ({ mockReadInline: vi.fn() })) -vi.mock('@/lib/workspaces/permissions/utils', () => ({ getUserEntityPermissions: mockGetPerms })) -vi.mock('@/lib/uploads/server/inline-image', () => ({ - resolveWorkspaceInlineImage: mockResolveImage, +vi.mock('@/lib/workspace-files/application/read-workspace-inline-file', () => ({ + readWorkspaceInlineFile: { + operation: { id: 'files.read_content', minimumRole: 'read', workspaceApiKey: 'allow' }, + execute: mockReadInline, + }, })) -vi.mock('@/lib/uploads/core/storage-service', () => ({ downloadFile: mockDownloadFile })) import { GET } from '@/app/api/workspaces/[id]/files/inline/route' const mockGetSession = authMockFns.mockGetSession - const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00]) const params = { params: Promise.resolve({ id: 'ws-1' }) } -const req = (q: string) => new NextRequest(`http://localhost/api/workspaces/ws-1/files/inline?${q}`) +const req = (q: string) => + new NextRequest(`http://localhost/api/workspaces/ws-1/files/inline${q ? `?${q}` : ''}`) describe('GET /api/workspaces/[id]/files/inline', () => { beforeEach(() => { vi.clearAllMocks() - mockGetSession.mockResolvedValue({ user: { id: 'u1' } }) - mockGetPerms.mockResolvedValue('read') - mockResolveImage.mockResolvedValue({ - key: 'workspace/ws-1/x-photo.png', - contentType: 'image/png', - filename: 'photo.png', + mockGetSession.mockResolvedValue({ user: { id: 'u1' }, session: { id: 's1' } }) + mockReadInline.mockResolvedValue({ + file: { name: 'photo.png', type: 'image/png', size: PNG.length }, + stream: new Blob([new Uint8Array(PNG)]).stream(), }) - mockDownloadFile.mockResolvedValue(PNG) }) - it('serves a workspace-scoped image by fileId, always revalidating', async () => { + it('serves authenticated workspace-scoped content by file id', async () => { const res = await GET(req('fileId=wf_abc'), params) + expect(res.status).toBe(200) - expect(mockResolveImage).toHaveBeenCalledWith('ws-1', { fileId: 'wf_abc' }) - // Authenticated content: always revalidate so a deletion/revocation is enforced on the next request. + expect(mockReadInline).toHaveBeenCalledWith( + expect.objectContaining({ + principal: { kind: 'session', userId: 'u1', sessionId: 's1' }, + input: { workspaceId: 'ws-1', fileId: 'wf_abc' }, + }) + ) expect(res.headers.get('Cache-Control')).toBe('private, no-cache, must-revalidate') + expect(res.headers.get('Content-Disposition')).toBe('inline; filename="photo.png"') }) - it('serves a workspace-scoped image by key, always revalidating', async () => { - const res = await GET(req(`key=${encodeURIComponent('workspace/ws-1/x-photo.png')}`), params) + it('passes key references to the shared read use case', async () => { + const res = await GET(req('key=workspace%2Fws-1%2Fphoto.png'), params) + expect(res.status).toBe(200) - // Same policy as fileId: authenticated content never cached past a revalidation, so a deleted or - // access-revoked image drops out immediately rather than lingering in a private browser cache. - expect(res.headers.get('Cache-Control')).toBe('private, no-cache, must-revalidate') + expect(mockReadInline.mock.calls[0][0].input).toEqual({ + workspaceId: 'ws-1', + key: 'workspace/ws-1/photo.png', + fileId: undefined, + }) }) - it('404s when the reference does not resolve in the workspace (cross-workspace)', async () => { - mockResolveImage.mockResolvedValue(null) + it('returns the concealed 404 response for an unauthorized or missing file', async () => { + mockReadInline.mockRejectedValue( + new OrchestrationError('forbidden', 'Insufficient permissions') + ) + const res = await GET(req('fileId=wf_other'), params) - expect(res.status).toBe(404) - }) - it('404s without workspace membership, before resolving the file', async () => { - mockGetPerms.mockResolvedValue(null) - const res = await GET(req('fileId=wf_abc'), params) expect(res.status).toBe(404) - expect(mockResolveImage).not.toHaveBeenCalled() + expect(await res.json()).toEqual({ error: 'FileNotFoundError', message: 'Not found' }) }) - it('401s without a session', async () => { + it('authenticates before parsing invalid input', async () => { mockGetSession.mockResolvedValue(null) - const res = await GET(req('fileId=wf_abc'), params) + + const res = await GET(req(''), params) + expect(res.status).toBe(401) + expect(mockReadInline).not.toHaveBeenCalled() }) - it('400s when neither key nor fileId is provided', async () => { - const res = await GET(req(''), params) + it('returns a validation response when both references are supplied', async () => { + const res = await GET(req('key=k&fileId=f'), params) expect(res.status).toBe(400) + expect(mockReadInline).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/app/api/workspaces/[id]/files/inline/route.ts b/apps/sim/app/api/workspaces/[id]/files/inline/route.ts index 245fb5731d8..a68fa188b33 100644 --- a/apps/sim/app/api/workspaces/[id]/files/inline/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/inline/route.ts @@ -1,59 +1,84 @@ import { createLogger } from '@sim/logger' -import type { NextRequest } from 'next/server' -import { NextResponse } from 'next/server' +import type { NextResponse } from 'next/server' import { getInlineWorkspaceFileContract } from '@/lib/api/contracts/workspace-files' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { resolveWorkspaceInlineImage } from '@/lib/uploads/server/inline-image' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' -import { serveInlineImage } from '@/app/api/files/serve-inline-image' -import { createErrorResponse, FileNotFoundError } from '@/app/api/files/utils' +import { + defineInternalBinaryRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' +import { readWorkspaceInlineFile } from '@/lib/workspace-files/application/read-workspace-inline-file' +import { + createErrorResponse, + encodeFilenameForHeader, + FileNotFoundError, + getSecureFileHeaders, +} from '@/app/api/files/utils' export const dynamic = 'force-dynamic' const logger = createLogger('WorkspaceInlineFileAPI') +const inlineFileErrorPolicy: { render(error: unknown): NextResponse | null } = { + render(error): NextResponse | null { + const classified = asOrchestrationError(error) + if (classified) { + if (classified.code === 'not_found' || classified.code === 'forbidden') { + return createErrorResponse(new FileNotFoundError('Not found')) + } + return createErrorResponse( + new Error(classified.message), + statusForOrchestrationError(classified.code) + ) + } + if (error instanceof Error) { + logger.error('Error serving workspace inline image', { error }) + return createErrorResponse(error) + } + const fallback = new Error('Failed to serve file') + logger.error('Error serving workspace inline image', { error }) + return createErrorResponse(fallback) + }, +} + /** * GET /api/workspaces/[id]/files/inline?key=|fileId= * - * Serves an image embedded in a workspace markdown document, **scoped to the workspace in the path**. - * The markdown editor rewrites its embedded `/api/files/serve/` and `/api/files/view/` srcs to - * this route so a referenced file resolves only within the document's workspace — a cross-workspace - * reference returns 404 and does not render, even for a viewer who belongs to the other workspace. Read - * access to the workspace is required; disposition/content-type handling mirrors the serve route. + * Serves an authenticated workspace-scoped image. Authentication and the + * `files.read_content` authorization check happen before resolving or reading + * the referenced object, preserving cross-workspace concealment. */ -export const GET = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - try { - const parsed = await parseRequest(getInlineWorkspaceFileContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId } = parsed.data.params - const ref = parsed.data.query - - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Authorize before disclosing anything; deny with 404 so a non-member can't probe existence. - const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (!permission) { - throw new FileNotFoundError('Not found') - } - - const image = await resolveWorkspaceInlineImage(workspaceId, ref) - if (!image) { - throw new FileNotFoundError('Not found') - } - - return await serveInlineImage(image, { sniff: false }) - } catch (error) { - if (error instanceof FileNotFoundError) { - return createErrorResponse(error) - } - logger.error('Error serving workspace inline image:', error) - return createErrorResponse(error instanceof Error ? error : new Error('Failed to serve file')) +export const GET = defineInternalBinaryRoute({ + contract: getInlineWorkspaceFileContract, + auth: internalSessionAuth, + operation: readWorkspaceInlineFile.operation, + rateLimit: internalRateLimits.none({ reason: 'Internal workspace inline image delivery' }), + errorPolicy: inlineFileErrorPolicy, + mapInput: ({ params, query }) => ({ + workspaceId: params.id, + key: query.key, + fileId: query.fileId, + }), + useCase: readWorkspaceInlineFile, + present: ({ file, stream }) => { + const secure = getSecureFileHeaders(file.name, file.type) + const headers = new Headers({ + 'Content-Type': secure.contentType, + 'Content-Disposition': `${secure.disposition}; ${encodeFilenameForHeader(file.name)}`, + 'Cache-Control': 'private, no-cache, must-revalidate', + 'X-Content-Type-Options': 'nosniff', + }) + if (secure.contentType === 'image/svg+xml') { + headers.set( + 'Content-Security-Policy', + "default-src 'none'; style-src 'unsafe-inline'; sandbox;" + ) + } + return { + body: stream, + contentType: secure.contentType, + contentLength: file.size, + headers, } - } -) + }, +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/move/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/move/route.test.ts new file mode 100644 index 00000000000..46c81932a2c --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/move/route.test.ts @@ -0,0 +1,86 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getSession: vi.fn(), + execute: vi.fn(), + captureServerEvent: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ getSession: mocks.getSession })) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.captureServerEvent })) +vi.mock('@/lib/workspace-files/application/move-workspace-file-items', () => ({ + moveWorkspaceFileItemsOperation: { + operation: { id: 'files.move', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.execute, + }, +})) + +import { POST } from '@/app/api/workspaces/[id]/files/move/route' + +const WORKSPACE_ID = 'workspace-1' +const context = { params: Promise.resolve({ id: WORKSPACE_ID }) } + +function request(body: unknown) { + return new NextRequest(`http://localhost:3000/api/workspaces/${WORKSPACE_ID}/files/move`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) +} + +describe('/api/workspaces/[id]/files/move', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getSession.mockResolvedValue({ user: { id: 'user-1' }, session: { id: 'session-1' } }) + mocks.execute.mockResolvedValue({ movedItems: { files: 2, folders: 1 } }) + }) + + it('moves selected files and folders through the shared operation', async () => { + const response = await POST( + request({ fileIds: ['wf_1', 'wf_2'], folderIds: ['folder-1'], targetFolderId: null }), + context + ) + + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ + success: true, + movedItems: { files: 2, folders: 1 }, + }) + expect(mocks.execute).toHaveBeenCalledWith({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + workspaceId: WORKSPACE_ID, + fileIds: ['wf_1', 'wf_2'], + folderIds: ['folder-1'], + targetFolderId: null, + }, + request: expect.anything(), + }) + expect(mocks.captureServerEvent).toHaveBeenCalledWith( + 'user-1', + 'file_moved', + { workspace_id: WORKSPACE_ID, file_count: 2, folder_count: 1 }, + { groups: { workspace: WORKSPACE_ID } } + ) + }) + + it('rejects an empty selection before the use case', async () => { + const response = await POST(request({}), context) + + expect(response.status).toBe(400) + expect(mocks.execute).not.toHaveBeenCalled() + }) + + it('authenticates before parsing the selection', async () => { + mocks.getSession.mockResolvedValueOnce(null) + + const response = await POST(request({ fileIds: ['wf_1'] }), context) + + expect(response.status).toBe(401) + expect(mocks.execute).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/move/route.ts b/apps/sim/app/api/workspaces/[id]/files/move/route.ts index 81861789eee..da5dcee4646 100644 --- a/apps/sim/app/api/workspaces/[id]/files/move/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/move/route.ts @@ -1,78 +1,52 @@ -import { createLogger } from '@sim/logger' -import { type NextRequest, NextResponse } from 'next/server' import { moveWorkspaceFileItemsContract } from '@/lib/api/contracts/workspace-file-folders' -import { parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + defineInternalJsonRoute, + internalFileErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' import { captureServerEvent } from '@/lib/posthog/server' -import { performMoveWorkspaceFileItems } from '@/lib/workspace-files/orchestration' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import { moveWorkspaceFileItemsOperation } from '@/lib/workspace-files/application/move-workspace-file-items' +import { fileOperations } from '@/lib/workspace-files/application/operations' -const logger = createLogger('WorkspaceFileMoveAPI') - -export const POST = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) +export const POST = defineInternalJsonRoute({ + contract: moveWorkspaceFileItemsContract, + auth: internalSessionAuth, + operation: fileOperations.move, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal file move behavior' }), + errorPolicy: internalFileErrorPolicy, + mapInput: ({ params, body }) => ({ + workspaceId: params.id, + fileIds: body.fileIds, + folderIds: body.folderIds, + targetFolderId: body.targetFolderId, + }), + useCase: moveWorkspaceFileItemsOperation, + onSuccess: ({ principal, input }) => { + if (input.fileIds && input.fileIds.length > 0) { + captureServerEvent( + principal.userId, + 'file_moved', + { + workspace_id: input.workspaceId, + file_count: input.fileIds.length, + folder_count: input.folderIds?.length ?? 0, + }, + { groups: { workspace: input.workspaceId } } + ) } - - const parsed = await parseRequest(moveWorkspaceFileItemsContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId } = parsed.data.params - const { fileIds, folderIds, targetFolderId } = parsed.data.body - - const permission = await getUserEntityPermissions(session.user.id, 'workspace', workspaceId) - if (permission !== 'admin' && permission !== 'write') { - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - try { - const result = await performMoveWorkspaceFileItems({ - workspaceId, - userId: session.user.id, - fileIds, - folderIds, - targetFolderId, - }) - if (!result.success || !result.movedItems) { - return NextResponse.json( - { success: false, error: result.error }, - { - status: - result.errorCode === 'conflict' - ? 409 - : result.errorCode === 'not_found' - ? 404 - : result.errorCode === 'validation' - ? 400 - : 500, - } - ) - } - if (fileIds.length > 0) { - captureServerEvent( - session.user.id, - 'file_moved', - { workspace_id: workspaceId, file_count: fileIds.length, folder_count: folderIds.length }, - { groups: { workspace: workspaceId } } - ) - } - if (folderIds.length > 0) { - captureServerEvent( - session.user.id, - 'folder_moved', - { workspace_id: workspaceId, file_count: fileIds.length, folder_count: folderIds.length }, - { groups: { workspace: workspaceId } } - ) - } - return NextResponse.json({ - success: true, - movedItems: result.movedItems, - }) - } catch (error) { - logger.error('Failed to move workspace file items:', error) - return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }) + if (input.folderIds && input.folderIds.length > 0) { + captureServerEvent( + principal.userId, + 'folder_moved', + { + workspace_id: input.workspaceId, + file_count: input.fileIds?.length ?? 0, + folder_count: input.folderIds.length, + }, + { groups: { workspace: input.workspaceId } } + ) } - } -) + }, + present: ({ movedItems }) => ({ success: true, movedItems }), +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/route.test.ts index 7ce4f9f1a4c..076b4ceed9b 100644 --- a/apps/sim/app/api/workspaces/[id]/files/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/files/route.test.ts @@ -5,59 +5,50 @@ import { authMockFns } from '@sim/testing' import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { - mockGetUserEntityPermissions, - mockGetWorkspaceShares, - mockListWorkspaceFiles, - mockPerformCreateWorkspaceFile, -} = vi.hoisted(() => ({ - mockGetUserEntityPermissions: vi.fn(), - mockGetWorkspaceShares: vi.fn(), - mockListWorkspaceFiles: vi.fn(), - mockPerformCreateWorkspaceFile: vi.fn(), +const mocks = vi.hoisted(() => ({ + admitCreate: vi.fn(), + createFile: vi.fn(), + listFiles: vi.fn(), + captureServerEvent: vi.fn(), })) -vi.mock('@/lib/public-shares/share-manager', () => ({ - getWorkspaceShares: mockGetWorkspaceShares, +vi.mock('@/lib/workspace-files/application/create-workspace-file', () => ({ + createWorkspaceFile: { + operation: { id: 'files.create', minimumRole: 'write', workspaceApiKey: 'allow' }, + admit: mocks.admitCreate, + execute: mocks.createFile, + }, })) -vi.mock('@/lib/uploads/contexts/workspace', () => ({ - listWorkspaceFiles: mockListWorkspaceFiles, +vi.mock('@/lib/workspace-files/application/list-workspace-files', () => ({ + listAllWorkspaceFiles: { + operation: { id: 'files.list', minimumRole: 'read', workspaceApiKey: 'allow' }, + execute: mocks.listFiles, + }, })) -vi.mock('@/lib/workspace-files/orchestration', () => ({ - MAX_WORKSPACE_FILE_INLINE_BODY_BYTES: 70 * 1024 * 1024, - performCreateWorkspaceFile: mockPerformCreateWorkspaceFile, -})) - -vi.mock('@/lib/workspaces/permissions/utils', () => ({ - getUserEntityPermissions: mockGetUserEntityPermissions, -})) -vi.mock('@/app/api/workflows/utils', () => ({ - verifyWorkspaceMembership: vi.fn().mockResolvedValue('write'), -})) +vi.mock('@/lib/posthog/server', () => ({ captureServerEvent: mocks.captureServerEvent })) -import { POST } from '@/app/api/workspaces/[id]/files/route' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { GET, POST } from '@/app/api/workspaces/[id]/files/route' -const WORKSPACE_ID = '7727ef3f-8cf6-4686-b063-2bb006a10785' +const WORKSPACE_ID = 'workspace-1' const USER = { id: 'user-1', name: 'Test User', email: 'test@sim.ai' } -const CREATED_FILE = { - id: 'wf_created', +const PRINCIPAL = { kind: 'session' as const, userId: USER.id, sessionId: 'session-1' } +const FILE = { + id: 'wf_1', workspaceId: WORKSPACE_ID, - name: 'untitled.md', - key: `workspace/${WORKSPACE_ID}/untitled.md`, - path: '/api/files/serve/untitled.md?context=workspace', + name: 'notes.md', + key: `workspace/${WORKSPACE_ID}/notes.md`, + path: '/api/files/serve/notes.md?context=workspace', size: 0, type: 'text/markdown', uploadedBy: USER.id, folderId: null, - folderPath: null, - deletedAt: null, uploadedAt: new Date('2026-08-04T00:00:00.000Z'), updatedAt: new Date('2026-08-04T00:00:00.000Z'), } - -const routeContext = { params: Promise.resolve({ id: WORKSPACE_ID }) } +const context = { params: Promise.resolve({ id: WORKSPACE_ID }) } function createRequest(body: unknown): NextRequest { return new NextRequest(`http://localhost:3000/api/workspaces/${WORKSPACE_ID}/files`, { @@ -67,187 +58,102 @@ function createRequest(body: unknown): NextRequest { }) } -describe('POST /api/workspaces/[id]/files', () => { +describe('/api/workspaces/[id]/files', () => { beforeEach(() => { vi.clearAllMocks() - authMockFns.mockGetSession.mockResolvedValue({ user: USER }) - mockGetUserEntityPermissions.mockResolvedValue('write') - mockGetWorkspaceShares.mockResolvedValue(new Map()) - mockListWorkspaceFiles.mockResolvedValue([]) - mockPerformCreateWorkspaceFile.mockResolvedValue({ success: true, file: CREATED_FILE }) + authMockFns.mockGetSession.mockResolvedValue({ user: USER, session: { id: 'session-1' } }) + mocks.admitCreate.mockResolvedValue(undefined) + mocks.createFile.mockResolvedValue({ file: FILE }) + mocks.listFiles.mockResolvedValue({ files: [FILE] }) + }) + + it('lists files through the shared read operation', async () => { + const request = new NextRequest( + `http://localhost:3000/api/workspaces/${WORKSPACE_ID}/files?scope=archived` + ) + const response = await GET(request, context) + + expect(response.status).toBe(200) + expect((await response.json()).files).toHaveLength(1) + expect(mocks.listFiles).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { workspaceId: WORKSPACE_ID, scope: 'archived' }, + request, + }) }) - it('authenticates before parsing an invalid request body', async () => { + it('authenticates before create admission or body parsing', async () => { authMockFns.mockGetSession.mockResolvedValue(null) - const response = await POST(createRequest('{not-json'), routeContext) + const response = await POST(createRequest('{not-json'), context) expect(response.status).toBe(401) - await expect(response.json()).resolves.toEqual({ error: 'Unauthorized' }) - expect(mockGetUserEntityPermissions).not.toHaveBeenCalled() - expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled() + expect(mocks.admitCreate).not.toHaveBeenCalled() + expect(mocks.createFile).not.toHaveBeenCalled() }) - it('authorizes the workspace before parsing the request body', async () => { - mockGetUserEntityPermissions.mockResolvedValue('read') + it('authorizes the asserted workspace before buffering the create body', async () => { + mocks.admitCreate.mockRejectedValue( + new OrchestrationError('forbidden', 'Insufficient workspace permissions') + ) - const response = await POST(createRequest({ content: 'missing a name' }), routeContext) + const response = await POST(createRequest('{not-json'), context) expect(response.status).toBe(403) - await expect(response.json()).resolves.toEqual({ error: 'Insufficient permissions' }) - expect(mockGetUserEntityPermissions).toHaveBeenCalledWith(USER.id, 'workspace', WORKSPACE_ID) - expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled() + expect(mocks.admitCreate).toHaveBeenCalledWith(PRINCIPAL, WORKSPACE_ID) + expect(mocks.createFile).not.toHaveBeenCalled() }) - it('rejects an invalid body after workspace authorization', async () => { - const response = await POST(createRequest({ content: 'missing a name' }), routeContext) - const body = await response.json() + it('rejects malformed base64 after admission', async () => { + const response = await POST( + createRequest({ name: 'notes.md', content: 'not-base64!', encoding: 'base64' }), + context + ) expect(response.status).toBe(400) - expect(body.error).toBe('Validation error') - expect(mockGetUserEntityPermissions).toHaveBeenCalledWith(USER.id, 'workspace', WORKSPACE_ID) - expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled() + expect(mocks.admitCreate).toHaveBeenCalled() + expect(mocks.createFile).not.toHaveBeenCalled() }) - it.each(['read', null])( - 'requires write or admin permission (%s is rejected)', - async (permission) => { - mockGetUserEntityPermissions.mockResolvedValue(permission) - - const response = await POST(createRequest({ name: 'untitled.md' }), routeContext) - - expect(response.status).toBe(403) - await expect(response.json()).resolves.toEqual({ error: 'Insufficient permissions' }) - expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled() - } - ) - - it.each(['write', 'admin'])( - 'creates an empty file with defaults for %s users', - async (permission) => { - mockGetUserEntityPermissions.mockResolvedValue(permission) - const request = createRequest({ name: 'untitled.md' }) - - const response = await POST(request, routeContext) - const body = await response.json() - - expect(response.status).toBe(201) - expect(body).toMatchObject({ success: true, file: { id: CREATED_FILE.id } }) - expect(mockPerformCreateWorkspaceFile).toHaveBeenCalledTimes(1) - const params = mockPerformCreateWorkspaceFile.mock.calls[0][0] - expect(params).toMatchObject({ + it('creates through the shared use case and preserves internal analytics', async () => { + const request = createRequest({ name: 'notes.md', content: 'TQ==', encoding: 'base64' }) + const response = await POST(request, context) + + expect(response.status).toBe(201) + expect((await response.json()).file.id).toBe(FILE.id) + expect(mocks.createFile).toHaveBeenCalledWith({ + principal: PRINCIPAL, + input: { workspaceId: WORKSPACE_ID, - userId: USER.id, - actorName: USER.name, - actorEmail: USER.email, - name: 'untitled.md', + name: 'notes.md', contentType: 'text/markdown', + content: 'TQ==', + encoding: 'base64', + folderId: undefined, exactName: false, - }) - expect(params.folderId).toBeUndefined() - expect(params.content).toEqual(Buffer.alloc(0)) - expect(params.request).toBe(request) - } - ) - - it('decodes initialized base64 content and preserves folder and content type', async () => { - const content = Buffer.from([0, 1, 2, 255]) - const request = createRequest({ - name: 'data.bin', - contentType: 'application/octet-stream', - folderId: 'folder-1', - content: content.toString('base64'), - encoding: 'base64', - }) - mockPerformCreateWorkspaceFile.mockResolvedValue({ - success: true, - file: { - ...CREATED_FILE, - name: 'data.bin', - type: 'application/octet-stream', - size: content.length, - folderId: 'folder-1', }, + request, }) - - const response = await POST(request, routeContext) - - expect(response.status).toBe(201) - expect(mockPerformCreateWorkspaceFile).toHaveBeenCalledWith( - expect.objectContaining({ - workspaceId: WORKSPACE_ID, - name: 'data.bin', - contentType: 'application/octet-stream', - folderId: 'folder-1', - content, - exactName: false, - }) - ) - }) - - it('rejects malformed base64 after authorization and before orchestration', async () => { - const response = await POST( - createRequest({ name: 'data.bin', content: 'not-base64!', encoding: 'base64' }), - routeContext - ) - - expect(response.status).toBe(400) - await expect(response.json()).resolves.toMatchObject({ error: 'Validation error' }) - expect(mockGetUserEntityPermissions).toHaveBeenCalledWith(USER.id, 'workspace', WORKSPACE_ID) - expect(mockPerformCreateWorkspaceFile).not.toHaveBeenCalled() - }) - - it('accepts empty base64 as a zero-byte file', async () => { - const response = await POST( - createRequest({ name: 'empty.bin', content: '', encoding: 'base64' }), - routeContext - ) - - expect(response.status).toBe(201) - expect(mockPerformCreateWorkspaceFile).toHaveBeenCalledWith( - expect.objectContaining({ content: Buffer.alloc(0) }) + expect(mocks.captureServerEvent).toHaveBeenCalledWith( + USER.id, + 'file_uploaded', + { workspace_id: WORKSPACE_ID, file_type: 'text/markdown' }, + { groups: { workspace: WORKSPACE_ID } } ) }) - it.each([ - ['validation', 400, 'Invalid file name'], - ['not_found', 404, 'Target folder not found'], - ['conflict', 409, 'A file with this name already exists'], - ['payload_too_large', 413, 'File size exceeds 50MB limit'], - ] as const)('maps a %s orchestration failure to %i', async (errorCode, expectedStatus, error) => { - mockPerformCreateWorkspaceFile.mockResolvedValue({ success: false, error, errorCode }) - - const response = await POST(createRequest({ name: 'untitled.md' }), routeContext) - - expect(response.status).toBe(expectedStatus) - await expect(response.json()).resolves.toEqual({ success: false, error }) - }) - - it('does not expose an internal orchestration error', async () => { - mockPerformCreateWorkspaceFile.mockResolvedValue({ - success: false, - error: 'update workspace_files set ... failed', - errorCode: 'internal', - }) - - const response = await POST(createRequest({ name: 'untitled.md' }), routeContext) - - expect(response.status).toBe(500) - await expect(response.json()).resolves.toEqual({ - success: false, - error: 'Failed to create file', - }) - }) - - it('maps an unexpected throw to a 500 response', async () => { - mockPerformCreateWorkspaceFile.mockRejectedValue(new Error('storage unavailable')) - - const response = await POST(createRequest({ name: 'untitled.md' }), routeContext) + it('renders typed create conflicts without exposing unknown errors', async () => { + mocks.createFile.mockRejectedValueOnce(new OrchestrationError('conflict', 'Name exists')) + const conflict = await POST(createRequest({ name: 'notes.md' }), context) + expect(conflict.status).toBe(409) + expect(await conflict.json()).toEqual({ success: false, error: 'Name exists' }) - expect(response.status).toBe(500) - await expect(response.json()).resolves.toEqual({ + mocks.createFile.mockRejectedValueOnce(new Error('database details')) + const unexpected = await POST(createRequest({ name: 'notes.md' }), context) + expect(unexpected.status).toBe(500) + expect(await unexpected.json()).toEqual({ success: false, - error: 'Failed to create file', + error: 'Internal server error', }) }) }) diff --git a/apps/sim/app/api/workspaces/[id]/files/route.ts b/apps/sim/app/api/workspaces/[id]/files/route.ts index 9a370fb4f94..76c5387fc35 100644 --- a/apps/sim/app/api/workspaces/[id]/files/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/route.ts @@ -1,177 +1,65 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import { type NextRequest, NextResponse } from 'next/server' import { createWorkspaceFileContract, - listWorkspaceFilesQuerySchema, - workspaceFilesParamsSchema, + listWorkspaceFilesContract, } from '@/lib/api/contracts/workspace-files' -import { getValidationErrorMessage, parseRequest } from '@/lib/api/server' -import { getSession } from '@/lib/auth' import { - messageForOrchestrationError, - statusForOrchestrationError, -} from '@/lib/core/orchestration/types' -import { generateRequestId } from '@/lib/core/utils/request' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { getWorkspaceShares } from '@/lib/public-shares/share-manager' -import { listWorkspaceFiles } from '@/lib/uploads/contexts/workspace' + defineInternalJsonRoute, + internalFileErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { captureServerEvent } from '@/lib/posthog/server' import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' -import { - MAX_WORKSPACE_FILE_INLINE_BODY_BYTES, - performCreateWorkspaceFile, -} from '@/lib/workspace-files/orchestration' -import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' -import { verifyWorkspaceMembership } from '@/app/api/workflows/utils' +import { createWorkspaceFile } from '@/lib/workspace-files/application/create-workspace-file' +import { listAllWorkspaceFiles } from '@/lib/workspace-files/application/list-workspace-files' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { MAX_WORKSPACE_FILE_INLINE_BODY_BYTES } from '@/lib/workspace-files/orchestration' export const dynamic = 'force-dynamic' -const logger = createLogger('WorkspaceFilesAPI') - -/** - * GET /api/workspaces/[id]/files - * List all files for a workspace (requires read permission) - */ -export const GET = withRouteHandler( - async (request: NextRequest, { params }: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() - const paramsResult = workspaceFilesParamsSchema.safeParse(await params) - if (!paramsResult.success) { - return NextResponse.json( - { error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') }, - { status: 400 } - ) - } - const { id: workspaceId } = paramsResult.data - - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - // Check workspace permissions (requires read) - const userPermission = await verifyWorkspaceMembership(session.user.id, workspaceId) - if (!userPermission) { - logger.warn( - `[${requestId}] User ${session.user.id} lacks permission for workspace ${workspaceId}` - ) - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - const queryResult = listWorkspaceFilesQuerySchema.safeParse( - Object.fromEntries(request.nextUrl.searchParams.entries()) - ) - if (!queryResult.success) { - return NextResponse.json( - { error: getValidationErrorMessage(queryResult.error, 'Invalid scope') }, - { status: 400 } - ) - } - const { scope } = queryResult.data - - const files = await listWorkspaceFiles(workspaceId, { scope }) - - const shares = await getWorkspaceShares('file', workspaceId) - const filesWithShares = files.map((file) => ({ - ...file, - share: shares.get(file.id) ?? null, - })) - - logger.info(`[${requestId}] Listed ${files.length} files for workspace ${workspaceId}`) - - return NextResponse.json({ - success: true, - files: filesWithShares, - }) - } catch (error) { - logger.error(`[${requestId}] Error listing workspace files:`, error) - return NextResponse.json( - { - success: false, - error: getErrorMessage(error, 'Failed to list files'), - }, - { status: 500 } - ) - } - } -) - -/** - * POST /api/workspaces/[id]/files - * Create an authored workspace file (requires write permission) - */ -export const POST = withRouteHandler( - async (request: NextRequest, context: { params: Promise<{ id: string }> }) => { - const requestId = generateRequestId() - - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const paramsResult = workspaceFilesParamsSchema.safeParse(await context.params) - if (!paramsResult.success) { - return NextResponse.json( - { error: getValidationErrorMessage(paramsResult.error, 'Invalid route parameters') }, - { status: 400 } - ) - } - const { id: workspaceId } = paramsResult.data - - const userPermission = await getUserEntityPermissions( - session.user.id, - 'workspace', - workspaceId - ) - if (userPermission !== 'admin' && userPermission !== 'write') { - logger.warn( - `[${requestId}] User ${session.user.id} lacks write permission for workspace ${workspaceId}` - ) - return NextResponse.json({ error: 'Insufficient permissions' }, { status: 403 }) - } - - const parsed = await parseRequest(createWorkspaceFileContract, request, context, { - maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES, - }) - if (!parsed.success) return parsed.response - const { name, contentType, folderId, content, encoding } = parsed.data.body - - const result = await performCreateWorkspaceFile({ - workspaceId, - userId: session.user.id, - name, - contentType: contentType ?? getMimeTypeFromExtension(getFileExtension(name)), - folderId, - content: Buffer.from(content, encoding), - exactName: false, - actorName: session.user.name, - actorEmail: session.user.email, - request, - }) - if (!result.success || !result.file) { - return NextResponse.json( - { - success: false, - error: messageForOrchestrationError(result, 'Failed to create file'), - }, - { status: statusForOrchestrationError(result.errorCode) } - ) - } - - logger.info(`[${requestId}] Created workspace file: ${result.file.name}`) - return NextResponse.json({ success: true, file: result.file }, { status: 201 }) - } catch (error) { - logger.error(`[${requestId}] Error creating workspace file:`, error) - - return NextResponse.json( - { - success: false, - error: 'Failed to create file', - }, - { status: 500 } - ) - } - } -) +/** GET /api/workspaces/[id]/files — List workspace files. */ +export const GET = defineInternalJsonRoute({ + contract: listWorkspaceFilesContract, + auth: internalSessionAuth, + operation: fileOperations.list, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal file-list behavior' }), + errorPolicy: internalFileErrorPolicy, + mapInput: ({ params, query }) => ({ workspaceId: params.id, scope: query.scope }), + useCase: listAllWorkspaceFiles, + present: ({ files }) => ({ + success: true, + files: files.map((file) => ({ ...file, folderId: file.folderId ?? null })), + }), +}) + +/** POST /api/workspaces/[id]/files — Create an authored workspace file. */ +export const POST = defineInternalJsonRoute({ + contract: createWorkspaceFileContract, + auth: internalSessionAuth, + operation: fileOperations.create, + rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal file-create behavior' }), + errorPolicy: internalFileErrorPolicy, + parseOptions: { maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES }, + beforeParse: async ({ principal, params }) => { + if (typeof params.id === 'string') await createWorkspaceFile.admit(principal, params.id) + }, + mapInput: ({ params, body }) => ({ + workspaceId: params.id, + name: body.name, + contentType: body.contentType ?? getMimeTypeFromExtension(getFileExtension(body.name)), + content: body.content, + encoding: body.encoding, + folderId: body.folderId, + exactName: false, + }), + useCase: createWorkspaceFile, + onSuccess: ({ principal, result }) => { + captureServerEvent( + principal.userId, + 'file_uploaded', + { workspace_id: result.file.workspaceId, file_type: result.file.type }, + { groups: { workspace: result.file.workspaceId } } + ) + }, + present: ({ file }) => ({ success: true, file: { ...file, folderId: file.folderId ?? null } }), +}) diff --git a/apps/sim/lib/api/contracts/v2/files.ts b/apps/sim/lib/api/contracts/v2/files.ts index c8bd351ec34..7c30dd44a57 100644 --- a/apps/sim/lib/api/contracts/v2/files.ts +++ b/apps/sim/lib/api/contracts/v2/files.ts @@ -361,6 +361,7 @@ export const v2CreateFileContract = defineRouteContract({ response: { mode: 'json', schema: v2DataResponse(v2FileSchema), + status: 201, }, }) @@ -368,7 +369,7 @@ export const v2CreateFileUploadContract = defineRouteContract({ method: 'POST', path: '/api/v2/files/uploads', body: v2CreateFileUploadBodySchema, - response: { mode: 'json', schema: v2DataResponse(v2CreateFileUploadDataSchema) }, + response: { mode: 'json', schema: v2DataResponse(v2CreateFileUploadDataSchema), status: 201 }, }) export const v2AbortFileUploadContract = defineRouteContract({ diff --git a/apps/sim/lib/api/contracts/workspace-file-folders.ts b/apps/sim/lib/api/contracts/workspace-file-folders.ts index 11fad5227ab..66620f36363 100644 --- a/apps/sim/lib/api/contracts/workspace-file-folders.ts +++ b/apps/sim/lib/api/contracts/workspace-file-folders.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { defineRouteContract } from '@/lib/api/contracts/types' +import { MAX_WORKSPACE_FILE_BULK_REQUEST_IDS } from '@/lib/workspace-files/limits' export const workspaceFileFolderScopeSchema = z.enum(['active', 'archived', 'all']) @@ -56,8 +57,14 @@ export const updateWorkspaceFileFolderBodySchema = z.object({ export const moveWorkspaceFileItemsBodySchema = z .object({ - fileIds: z.array(z.string()).default([]), - folderIds: z.array(z.string()).default([]), + fileIds: z + .array(z.string()) + .max(MAX_WORKSPACE_FILE_BULK_REQUEST_IDS, 'Too many file IDs') + .default([]), + folderIds: z + .array(z.string()) + .max(MAX_WORKSPACE_FILE_BULK_REQUEST_IDS, 'Too many folder IDs') + .default([]), targetFolderId: z.string().nullable().optional(), }) .refine((body) => body.fileIds.length > 0 || body.folderIds.length > 0, { @@ -66,8 +73,14 @@ export const moveWorkspaceFileItemsBodySchema = z export const bulkArchiveWorkspaceFileItemsBodySchema = z .object({ - fileIds: z.array(z.string()).default([]), - folderIds: z.array(z.string()).default([]), + fileIds: z + .array(z.string()) + .max(MAX_WORKSPACE_FILE_BULK_REQUEST_IDS, 'Too many file IDs') + .default([]), + folderIds: z + .array(z.string()) + .max(MAX_WORKSPACE_FILE_BULK_REQUEST_IDS, 'Too many folder IDs') + .default([]), }) .refine((body) => body.fileIds.length > 0 || body.folderIds.length > 0, { message: 'At least one file or folder must be selected', diff --git a/apps/sim/lib/api/contracts/workspace-files.ts b/apps/sim/lib/api/contracts/workspace-files.ts index 74a60303a4f..8a7f4792d35 100644 --- a/apps/sim/lib/api/contracts/workspace-files.ts +++ b/apps/sim/lib/api/contracts/workspace-files.ts @@ -175,6 +175,7 @@ export const createWorkspaceFileContract = defineRouteContract({ schema: workspaceFileSuccessSchema.extend({ file: workspaceFileRecordSchema, }), + status: 201, }, }) @@ -238,6 +239,22 @@ export const updateWorkspaceFileContentContract = defineRouteContract({ }, }) +export const downloadWorkspaceFileUrlContract = defineRouteContract({ + method: 'POST', + path: '/api/workspaces/[id]/files/[fileId]/download', + params: workspaceFileParamsSchema, + response: { + mode: 'json', + schema: z.object({ + success: z.literal(true), + downloadUrl: z.string().min(1), + viewerUrl: z.string().min(1), + fileName: z.string().min(1), + expiresIn: z.null(), + }), + }, +}) + const documentStyleSummarySchema = z .object({ format: z.enum(['docx', 'pptx', 'pdf']), diff --git a/apps/sim/lib/api/server/routes/definition.test.ts b/apps/sim/lib/api/server/routes/definition.test.ts index 559fcc0e673..20653b5d36c 100644 --- a/apps/sim/lib/api/server/routes/definition.test.ts +++ b/apps/sim/lib/api/server/routes/definition.test.ts @@ -5,7 +5,10 @@ import { describe, expect, it } from 'vitest' import { z } from 'zod' import { defineRouteContract } from '@/lib/api/contracts' -import { requireJsonRouteDefinition } from '@/lib/api/server/routes/definition' +import { + requireBinaryRouteDefinition, + requireJsonRouteDefinition, +} from '@/lib/api/server/routes/definition' const renameOperation = { id: 'files.rename', @@ -69,4 +72,41 @@ describe('declarative route definition invariants', () => { ) ).toThrow('must declare one success status') }) + + it('accepts binary contracts and rejects JSON contracts at the binary boundary', () => { + const binary = defineRouteContract({ + method: 'GET', + path: '/files/[fileId]', + response: { mode: 'binary' }, + }) + expect(requireBinaryRouteDefinition(binary, renameOperation, renameOperation)).toEqual({ + successStatus: 200, + }) + + expect(() => + requireBinaryRouteDefinition( + defineRouteContract({ + method: 'GET', + path: '/files/[fileId]', + response: { mode: 'json', schema: z.object({ ok: z.literal(true) }) }, + }), + renameOperation, + renameOperation + ) + ).toThrow('requires a binary response contract') + }) + + it('rejects operation mismatches at the binary boundary', () => { + expect(() => + requireBinaryRouteDefinition( + defineRouteContract({ + method: 'GET', + path: '/files/[fileId]', + response: { mode: 'binary' }, + }), + renameOperation, + { ...renameOperation, id: 'files.download' } + ) + ).toThrow('does not match') + }) }) diff --git a/apps/sim/lib/api/server/routes/definition.ts b/apps/sim/lib/api/server/routes/definition.ts index 261057bd8f6..04894034e8c 100644 --- a/apps/sim/lib/api/server/routes/definition.ts +++ b/apps/sim/lib/api/server/routes/definition.ts @@ -29,3 +29,27 @@ export function requireJsonRouteDefinition( } return { successStatus } } + +export function requireBinaryRouteDefinition( + contract: AnyApiRouteContract, + declaredOperation: WorkspaceOperation, + useCaseOperation: WorkspaceOperation +): JsonRouteDefinitionMetadata { + if (contract.response.mode !== 'binary') { + throw new Error(`${contract.method} ${contract.path} requires a binary response contract`) + } + if (declaredOperation.id !== useCaseOperation.id) { + throw new Error( + `Route operation ${declaredOperation.id} does not match use case ${useCaseOperation.id}` + ) + } + const configuredStatus = contract.response.status + if (configuredStatus !== undefined && typeof configuredStatus !== 'number') { + throw new Error(`${contract.method} ${contract.path} must declare one success status`) + } + const successStatus = configuredStatus ?? 200 + if (successStatus < 200 || successStatus >= 300) { + throw new Error(`${contract.method} ${contract.path} has a non-success response status`) + } + return { successStatus } +} diff --git a/apps/sim/lib/api/server/routes/index.ts b/apps/sim/lib/api/server/routes/index.ts index 74ce1101a0f..09de43083c8 100644 --- a/apps/sim/lib/api/server/routes/index.ts +++ b/apps/sim/lib/api/server/routes/index.ts @@ -1,9 +1,13 @@ +export { defineInternalBinaryRoute } from '@/lib/api/server/routes/internal-binary-route' export { defineInternalJsonRoute, internalFileErrorPolicy, + internalPlainFileErrorPolicy, internalRateLimits, internalSessionAuth, + internalSessionOrServiceAuth, } from '@/lib/api/server/routes/internal-json-route' +export { defineV2BinaryRoute } from '@/lib/api/server/routes/v2-binary-route' export { defineV2JsonRoute, v2ApiKeyAuth, diff --git a/apps/sim/lib/api/server/routes/internal-binary-route.ts b/apps/sim/lib/api/server/routes/internal-binary-route.ts new file mode 100644 index 00000000000..7c16b5879ee --- /dev/null +++ b/apps/sim/lib/api/server/routes/internal-binary-route.ts @@ -0,0 +1,123 @@ +import type { Principal, SessionPrincipal } from '@sim/auth/principal' +import type { NextRequest } from 'next/server' +import { NextResponse } from 'next/server' +import { requireBinaryRouteDefinition } from '@/lib/api/server/routes/definition' +import { + InternalUnauthenticatedError, + type internalSessionAuth, +} from '@/lib/api/server/routes/internal-json-route' +import type { + BinaryApiRouteContract, + BinaryResponseDescriptor, + JsonNextRouteHandler, + JsonRouteContext, + OperationUseCase, +} from '@/lib/api/server/routes/types' +import type { ParsedRequest } from '@/lib/api/server/validation' +import { parseRequest } from '@/lib/api/server/validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import type { WorkspaceOperation } from '@/lib/workspace-files/application/operations' + +interface InternalBinaryRateLimitPolicy { + readonly kind: 'none' + readonly reason: string + enforce(request: NextRequest, principal: Principal): Promise +} + +interface InternalBinaryErrorPolicy { + render(error: unknown): NextResponse | null +} + +interface InternalBinaryRouteDefinition< + C extends BinaryApiRouteContract, + O extends WorkspaceOperation, + I, + R, +> { + contract: C + operation: O + mapInput(input: ParsedRequest): I + useCase: OperationUseCase, I, R> + present(result: R): BinaryResponseDescriptor | Promise +} + +interface InternalBinaryRouteOptions< + C extends BinaryApiRouteContract, + O extends WorkspaceOperation, + I, + R, +> extends InternalBinaryRouteDefinition { + auth: typeof internalSessionAuth + rateLimit: InternalBinaryRateLimitPolicy + errorPolicy: InternalBinaryErrorPolicy + onSuccess?(args: { principal: SessionPrincipal; input: I; result: R }): void | Promise +} + +/** + * Defines an authenticated internal binary route, including streamed responses. + * + * The descriptor keeps storage and archive details out of the route handler while + * allowing a use-case presenter to return a Web Stream without buffering it. + */ +export function defineInternalBinaryRoute< + C extends BinaryApiRouteContract, + O extends WorkspaceOperation, + I, + R, +>(options: InternalBinaryRouteOptions): JsonNextRouteHandler { + const { successStatus } = requireBinaryRouteDefinition( + options.contract, + options.operation, + options.useCase.operation + ) + + const wrapped = withRouteHandler( + async (request, context) => { + if (request.method !== options.contract.method) { + throw new Error( + `Route received ${request.method} for ${options.contract.method} contract ${options.contract.path}` + ) + } + + let principal: SessionPrincipal + try { + principal = await options.auth.authenticate() + } catch (error) { + if (error instanceof InternalUnauthenticatedError) { + return NextResponse.json({ error: error.message }, { status: 401 }) + } + throw error + } + + await options.rateLimit.enforce(request, principal) + const parsed = await parseRequest(options.contract, request, context ?? {}) + if (!parsed.success) return parsed.response + + try { + const input = options.mapInput(parsed.data) + const result = await options.useCase.execute({ principal, input, request }) + const descriptor = await options.present(result) + await options.onSuccess?.({ principal, input, result }) + const headers = new Headers(descriptor.headers) + headers.set('Content-Type', descriptor.contentType) + if (descriptor.contentDisposition) { + headers.set('Content-Disposition', descriptor.contentDisposition) + } + if (descriptor.contentLength !== undefined) { + headers.set('Content-Length', String(descriptor.contentLength)) + } + return new NextResponse(descriptor.body, { status: successStatus, headers }) + } catch (error) { + const response = options.errorPolicy.render(error) + if (response) return response + throw error + } + }, + { + unhandledErrorResponse: () => + NextResponse.json({ error: 'Internal server error' }, { status: 500 }), + } + ) + + return async (request, context) => wrapped(request, context) +} diff --git a/apps/sim/lib/api/server/routes/internal-json-route.test.ts b/apps/sim/lib/api/server/routes/internal-json-route.test.ts new file mode 100644 index 00000000000..9dd411d60c0 --- /dev/null +++ b/apps/sim/lib/api/server/routes/internal-json-route.test.ts @@ -0,0 +1,73 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockGetSession, mockVerifyInternalToken } = vi.hoisted(() => ({ + mockGetSession: vi.fn(), + mockVerifyInternalToken: vi.fn(), +})) + +vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) +vi.mock('@/lib/auth/internal', () => ({ verifyInternalToken: mockVerifyInternalToken })) + +import { + InternalUnauthenticatedError, + internalSessionOrServiceAuth, +} from '@/lib/api/server/routes/internal-json-route' + +describe('internal file route authentication', () => { + beforeEach(() => { + vi.clearAllMocks() + mockGetSession.mockResolvedValue(null) + }) + + it('binds a verified internal user to an executor file principal', async () => { + mockVerifyInternalToken.mockResolvedValue({ valid: true, userId: 'user-1' }) + + const principal = await internalSessionOrServiceAuth.authenticate( + new NextRequest('http://localhost/api/workspaces/ws-1/files/file-1', { + headers: { authorization: 'Bearer signed-token' }, + }), + { id: 'ws-1', fileId: 'file-1' } + ) + + expect(principal).toMatchObject({ + kind: 'delegated', + serviceId: 'executor', + subjectUserId: 'user-1', + workspaceId: 'ws-1', + audience: 'sim:workspace-files', + resourceScope: { fileId: 'file-1' }, + }) + expect(mockGetSession).not.toHaveBeenCalled() + }) + + it('rejects internal tokens that do not carry a human subject', async () => { + mockVerifyInternalToken.mockResolvedValue({ valid: true }) + + await expect( + internalSessionOrServiceAuth.authenticate( + new NextRequest('http://localhost/api/workspaces/ws-1/files/file-1', { + headers: { authorization: 'Bearer signed-token' }, + }), + { id: 'ws-1', fileId: 'file-1' } + ) + ).rejects.toBeInstanceOf(InternalUnauthenticatedError) + }) + + it('preserves browser session principals when no service token is supplied', async () => { + mockGetSession.mockResolvedValue({ + user: { id: 'user-1' }, + session: { id: 'session-1' }, + }) + + await expect( + internalSessionOrServiceAuth.authenticate( + new NextRequest('http://localhost/api/workspaces/ws-1/files/file-1'), + { id: 'ws-1', fileId: 'file-1' } + ) + ).resolves.toEqual({ kind: 'session', userId: 'user-1', sessionId: 'session-1' }) + }) +}) diff --git a/apps/sim/lib/api/server/routes/internal-json-route.ts b/apps/sim/lib/api/server/routes/internal-json-route.ts index c96646f4e8e..3034c7c2146 100644 --- a/apps/sim/lib/api/server/routes/internal-json-route.ts +++ b/apps/sim/lib/api/server/routes/internal-json-route.ts @@ -1,4 +1,4 @@ -import type { Principal, SessionPrincipal } from '@sim/auth/principal' +import type { DelegatedPrincipal, Principal, SessionPrincipal } from '@sim/auth/principal' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { requireJsonRouteDefinition } from '@/lib/api/server/routes/definition' @@ -8,15 +8,17 @@ import type { JsonRouteContext, JsonRouteDefinition, } from '@/lib/api/server/routes/types' -import { parseRequest } from '@/lib/api/server/validation' +import { type ParseRequestOptions, parseRequest } from '@/lib/api/server/validation' import { getSession } from '@/lib/auth' +import { verifyInternalToken } from '@/lib/auth/internal' import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { createWorkspaceFileDelegatedPrincipal } from '@/lib/workspace-files/application/delegated-principal' import type { WorkspaceOperation } from '@/lib/workspace-files/application/operations' -class InternalUnauthenticatedError extends Error { - constructor() { - super('Unauthorized') +export class InternalUnauthenticatedError extends Error { + constructor(message = 'Unauthorized') { + super(message) this.name = 'InternalUnauthenticatedError' } } @@ -31,6 +33,36 @@ export const internalSessionAuth = { }, } as const +export const internalSessionOrServiceAuth = { + async authenticate( + request: NextRequest, + params: Record + ): Promise { + if (request.headers.has('x-api-key')) { + throw new InternalUnauthenticatedError('Authentication required') + } + + const authorization = request.headers.get('authorization') + if (!authorization?.startsWith('Bearer ')) return internalSessionAuth.authenticate() + + const verification = await verifyInternalToken(authorization.slice('Bearer '.length)) + if (!verification.valid || !verification.userId) { + throw new InternalUnauthenticatedError('Authentication required') + } + const workspaceId = params.id + if (typeof workspaceId !== 'string' || !workspaceId) { + throw new Error('Internal file delegation requires a workspace route parameter') + } + return createWorkspaceFileDelegatedPrincipal({ + serviceId: 'executor', + subjectUserId: verification.userId, + workspaceId, + delegationId: `internal-file:${verification.userId}`, + fileId: typeof params.fileId === 'string' ? params.fileId : undefined, + }) + }, +} as const + interface InternalRateLimitPolicy { readonly kind: 'none' readonly reason: string @@ -50,6 +82,7 @@ export const internalRateLimits = { export interface InternalErrorPolicy { render(error: unknown): NextResponse | null + unhandled?(): NextResponse } export const internalFileErrorPolicy: InternalErrorPolicy = { @@ -63,16 +96,45 @@ export const internalFileErrorPolicy: InternalErrorPolicy = { }, } +export const internalPlainFileErrorPolicy: InternalErrorPolicy = { + render(error) { + const classified = asOrchestrationError(error) + if (!classified) return null + return NextResponse.json( + { error: classified.message }, + { status: statusForOrchestrationError(classified.code) } + ) + }, + unhandled() { + return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + }, +} + +interface InternalAuthPolicy

{ + authenticate( + request: NextRequest, + params: Record + ): Promise

+} + interface InternalJsonRouteOptions< C extends JsonApiRouteContract, O extends WorkspaceOperation, I, R, + P extends Principal, > extends JsonRouteDefinition { - auth: typeof internalSessionAuth + auth: InternalAuthPolicy

rateLimit: InternalRateLimitPolicy errorPolicy: InternalErrorPolicy - onSuccess?(args: { principal: SessionPrincipal; input: I; result: R }): void | Promise + parseOptions?: Omit + beforeParse?(args: { + request: NextRequest + principal: P + params: Record + }): void | Promise + onSuccess?(args: { principal: P; input: I; result: R }): void | Promise + responseHeaders?(args: { principal: P; input: I; result: R }): HeadersInit } export function defineInternalJsonRoute< @@ -80,7 +142,8 @@ export function defineInternalJsonRoute< O extends WorkspaceOperation, I, R, ->(options: InternalJsonRouteOptions): JsonNextRouteHandler { + P extends Principal, +>(options: InternalJsonRouteOptions): JsonNextRouteHandler { const { successStatus } = requireJsonRouteDefinition( options.contract, options.operation, @@ -95,9 +158,10 @@ export function defineInternalJsonRoute< ) } - let principal: SessionPrincipal + const rawParams = context?.params ? await context.params : {} + let principal: P try { - principal = await options.auth.authenticate() + principal = await options.auth.authenticate(request, rawParams) } catch (error) { if (error instanceof InternalUnauthenticatedError) { return NextResponse.json({ error: error.message }, { status: 401 }) @@ -106,7 +170,21 @@ export function defineInternalJsonRoute< } await options.rateLimit.enforce(request, principal) - const parsed = await parseRequest(options.contract, request, context ?? {}) + if (options.beforeParse) { + try { + await options.beforeParse({ request, principal, params: rawParams }) + } catch (error) { + const response = options.errorPolicy.render(error) + if (response) return response + throw error + } + } + const parsed = await parseRequest( + options.contract, + request, + context ?? {}, + options.parseOptions + ) if (!parsed.success) return parsed.response try { @@ -123,7 +201,10 @@ export function defineInternalJsonRoute< throw new Error('Internal JSON route response mode changed after initialization') } const validatedBody = responseSchema.schema.parse(body) - return NextResponse.json(validatedBody, { status: successStatus }) + return NextResponse.json(validatedBody, { + status: successStatus, + headers: options.responseHeaders?.({ principal, input, result }), + }) } catch (error) { const response = options.errorPolicy.render(error) if (response) return response @@ -132,6 +213,7 @@ export function defineInternalJsonRoute< }, { unhandledErrorResponse: () => + options.errorPolicy.unhandled?.() ?? NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }), } ) diff --git a/apps/sim/lib/api/server/routes/types.ts b/apps/sim/lib/api/server/routes/types.ts index e1964766380..2e2b9f8a1ca 100644 --- a/apps/sim/lib/api/server/routes/types.ts +++ b/apps/sim/lib/api/server/routes/types.ts @@ -2,6 +2,7 @@ import type { Principal } from '@sim/auth/principal' import type { NextRequest } from 'next/server' import type { AnyApiRouteContract, + BinaryResponseMode, ContractJsonResponse, JsonResponseMode, } from '@/lib/api/contracts' @@ -28,6 +29,18 @@ export type JsonApiRouteContract = AnyApiRouteContract & { response: JsonResponseMode } +export type BinaryApiRouteContract = AnyApiRouteContract & { + response: BinaryResponseMode +} + +export interface BinaryResponseDescriptor { + body: BodyInit + contentType: string + contentDisposition?: string + contentLength?: number + headers?: HeadersInit +} + export interface JsonRouteDefinition< C extends JsonApiRouteContract, O extends WorkspaceOperation, @@ -45,3 +58,16 @@ export type JsonNextRouteHandler = ( request: NextRequest, context?: JsonRouteContext ) => Promise + +export interface BinaryRouteDefinition< + C extends BinaryApiRouteContract, + O extends WorkspaceOperation, + I, + R, +> { + contract: C + operation: O + mapInput(input: ParsedRequest): I + useCase: OperationUseCase, I, R> + present(result: R): BinaryResponseDescriptor | Promise +} diff --git a/apps/sim/lib/api/server/routes/v2-api-key-auth.test.ts b/apps/sim/lib/api/server/routes/v2-api-key-auth.test.ts index dad793c02cf..1ffe79bbf4b 100644 --- a/apps/sim/lib/api/server/routes/v2-api-key-auth.test.ts +++ b/apps/sim/lib/api/server/routes/v2-api-key-auth.test.ts @@ -91,6 +91,28 @@ describe('v2 API key authentication', () => { expect(mocks.getHighestPrioritySubscription).not.toHaveBeenCalled() }) + it('does not couple a workspace key to its creator ban state', async () => { + queueTableRows(schemaMock.apiKey, [ + { + id: 'key-1', + userId: 'creator-1', + workspaceId: 'workspace-1', + type: 'workspace', + expiresAt: null, + userBanned: true, + }, + ]) + mocks.resolveWorkspaceBillingPayer.mockResolvedValue({ + billedAccountUserId: 'billing-owner-1', + organizationId: null, + payerSubscription: null, + }) + + await expect(authenticateV2ApiKey('secret')).resolves.toMatchObject({ + principal: { kind: 'workspace_api_key', workspaceId: 'workspace-1', keyId: 'key-1' }, + }) + }) + it('treats missing, banned, and expired credentials as unauthenticated', async () => { await expect(authenticateV2ApiKey('missing')).rejects.toBeInstanceOf( V2ApiKeyUnauthenticatedError diff --git a/apps/sim/lib/api/server/routes/v2-api-key-auth.ts b/apps/sim/lib/api/server/routes/v2-api-key-auth.ts index c04b7b37946..d647ec9a778 100644 --- a/apps/sim/lib/api/server/routes/v2-api-key-auth.ts +++ b/apps/sim/lib/api/server/routes/v2-api-key-auth.ts @@ -44,13 +44,16 @@ interface ApiKeyRow { } function requireValidRow(row: ApiKeyRow | undefined): ApiKeyRow { - if (!row || row.userBanned || (row.expiresAt && row.expiresAt < new Date())) { + if (!row || (row.expiresAt && row.expiresAt < new Date())) { throw new V2ApiKeyUnauthenticatedError() } - if (row.userBanned === null) { - throw new Error(`API key ${row.id} is missing its credential owner`) + if (row.type === 'personal' && row.workspaceId === null) { + if (row.userBanned === null) { + throw new Error(`Personal API key ${row.id} is missing its credential owner`) + } + if (row.userBanned) throw new V2ApiKeyUnauthenticatedError() + return row } - if (row.type === 'personal' && row.workspaceId === null) return row if (row.type === 'workspace' && row.workspaceId) return row throw new Error(`API key ${row.id} has an invalid persisted type/workspace combination`) } diff --git a/apps/sim/lib/api/server/routes/v2-binary-route.ts b/apps/sim/lib/api/server/routes/v2-binary-route.ts new file mode 100644 index 00000000000..eb4ed586c14 --- /dev/null +++ b/apps/sim/lib/api/server/routes/v2-binary-route.ts @@ -0,0 +1,93 @@ +import type { NextRequest } from 'next/server' +import { requireBinaryRouteDefinition } from '@/lib/api/server/routes/definition' +import type { + BinaryApiRouteContract, + BinaryRouteDefinition, + JsonNextRouteHandler, + JsonRouteContext, +} from '@/lib/api/server/routes/types' +import { + admitV2Request, + type V2ErrorPolicy, + type V2RateLimitPolicy, + V2RouteInfrastructureError, + type v2ApiKeyAuth, +} from '@/lib/api/server/routes/v2-json-route' +import { parseRequest } from '@/lib/api/server/validation' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import type { WorkspaceOperation } from '@/lib/workspace-files/application/operations' +import { v2Error, v2ValidationError } from '@/app/api/v2/lib/response' + +interface V2BinaryRouteOptions + extends BinaryRouteDefinition { + auth: typeof v2ApiKeyAuth + rateLimit: V2RateLimitPolicy + errorPolicy: V2ErrorPolicy +} + +export function defineV2BinaryRoute< + C extends BinaryApiRouteContract, + O extends WorkspaceOperation, + I, + R, +>(options: V2BinaryRouteOptions): JsonNextRouteHandler { + const { successStatus } = requireBinaryRouteDefinition( + options.contract, + options.operation, + options.useCase.operation + ) + + const wrapped = withRouteHandler( + async (request: NextRequest, context) => { + if (request.method !== options.contract.method) { + throw new Error( + `Route received ${request.method} for ${options.contract.method} contract ${options.contract.path}` + ) + } + + const admission = await admitV2Request( + request, + options.operation, + options.auth, + options.rateLimit + ) + if (!admission.success) return admission.response + + const parsed = await parseRequest(options.contract, request, context ?? {}, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + try { + const result = await options.useCase.execute({ + principal: admission.auth.principal, + input: options.mapInput(parsed.data), + request, + }) + const descriptor = await options.present(result) + const headers = new Headers(descriptor.headers) + headers.set('Content-Type', descriptor.contentType) + headers.set('Cache-Control', 'private, no-store') + if (descriptor.contentDisposition) { + headers.set('Content-Disposition', descriptor.contentDisposition) + } + if (descriptor.contentLength !== undefined) { + headers.set('Content-Length', String(descriptor.contentLength)) + } + return new Response(descriptor.body, { status: successStatus, headers }) + } catch (error) { + const response = options.errorPolicy.render(error) + if (response) return response + throw error + } + }, + { + unhandledErrorResponse: ({ error }) => + error instanceof V2RouteInfrastructureError + ? v2Error('SERVICE_UNAVAILABLE', 'Service temporarily unavailable') + : v2Error('INTERNAL_ERROR', 'Internal server error'), + } + ) + + return async (request, context) => wrapped(request, context) +} diff --git a/apps/sim/lib/api/server/routes/v2-json-route.ts b/apps/sim/lib/api/server/routes/v2-json-route.ts index aa9169e20dd..9dbdeb95c71 100644 --- a/apps/sim/lib/api/server/routes/v2-json-route.ts +++ b/apps/sim/lib/api/server/routes/v2-json-route.ts @@ -13,7 +13,7 @@ import { type V2ApiKeyAuthContext, V2ApiKeyUnauthenticatedError, } from '@/lib/api/server/routes/v2-api-key-auth' -import { parseRequest } from '@/lib/api/server/validation' +import { type ParseRequestOptions, parseRequest } from '@/lib/api/server/validation' import { getRateLimit, RateLimiter, type SubscriptionPlan } from '@/lib/core/rate-limiter' import { getClientIp } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -33,7 +33,7 @@ const V2_PREAUTH_IP_LIMIT = { refillIntervalMs: 60_000, } as const -class V2RouteInfrastructureError extends Error { +export class V2RouteInfrastructureError extends Error { constructor(stage: 'authentication' | 'rollout_gate' | 'rate_limit', cause: unknown) { super(`V2 ${stage} infrastructure failed`, { cause }) this.name = 'V2RouteInfrastructureError' @@ -46,7 +46,7 @@ export const v2ApiKeyAuth = { }, } as const -interface V2RateLimitPolicy { +export interface V2RateLimitPolicy { readonly kind: 'public_api' enforce( request: NextRequest, @@ -105,6 +105,11 @@ export interface V2ErrorPolicy { } export const v2FileErrorPolicies = { + default: { + render(error) { + return v2CaughtOrchestrationError(error) + }, + } satisfies V2ErrorPolicy, concealResourceAuthorization: { render(error) { const response = v2CaughtOrchestrationError(error) @@ -115,11 +120,60 @@ export const v2FileErrorPolicies = { } satisfies V2ErrorPolicy, } as const +export async function admitV2Request( + request: NextRequest, + operation: WorkspaceOperation, + authPolicy: typeof v2ApiKeyAuth, + rateLimitPolicy: V2RateLimitPolicy +): Promise< + { success: true; auth: V2ApiKeyAuthContext } | { success: false; response: NextResponse } +> { + const ip = getClientIp(request) + const abuseLimit = await rateLimiter.checkRateLimitDirect( + `v2:preauth:ip:${ip}`, + V2_PREAUTH_IP_LIMIT, + { failClosed: true } + ) + if (!abuseLimit.allowed) { + return { + success: false, + response: v2RateLimitError({ ...abuseLimit, limit: V2_PREAUTH_IP_LIMIT.maxTokens }), + } + } + + let auth: V2ApiKeyAuthContext + try { + auth = await authPolicy.authenticate(request) + } catch (error) { + if (error instanceof V2ApiKeyUnauthenticatedError) { + return { success: false, response: v2Error('UNAUTHORIZED', error.message) } + } + throw new V2RouteInfrastructureError('authentication', error) + } + + let gate + try { + gate = await v2ApiGateError(auth.rolloutUserId) + } catch (error) { + throw new V2RouteInfrastructureError('rollout_gate', error) + } + if (gate) return { success: false, response: gate } + + const limited = await rateLimitPolicy.enforce(request, auth, operation) + return limited ? { success: false, response: limited } : { success: true, auth } +} + interface V2JsonRouteOptions extends JsonRouteDefinition { auth: typeof v2ApiKeyAuth rateLimit: V2RateLimitPolicy errorPolicy: V2ErrorPolicy + parseOptions?: Omit + beforeParse?(args: { + request: NextRequest + principal: V2ApiKeyAuthContext['principal'] + params: Record + }): void | Promise } export function defineV2JsonRoute< @@ -142,37 +196,28 @@ export function defineV2JsonRoute< ) } - const ip = getClientIp(request) - const abuseLimit = await rateLimiter.checkRateLimitDirect( - `v2:preauth:ip:${ip}`, - V2_PREAUTH_IP_LIMIT, - { failClosed: true } + const admission = await admitV2Request( + request, + options.operation, + options.auth, + options.rateLimit ) - if (!abuseLimit.allowed) { - return v2RateLimitError({ ...abuseLimit, limit: V2_PREAUTH_IP_LIMIT.maxTokens }) - } + if (!admission.success) return admission.response + const { auth } = admission - let auth: V2ApiKeyAuthContext - try { - auth = await options.auth.authenticate(request) - } catch (error) { - if (error instanceof V2ApiKeyUnauthenticatedError) { - return v2Error('UNAUTHORIZED', error.message) + if (options.beforeParse) { + const rawParams = context?.params ? await context.params : {} + try { + await options.beforeParse({ request, principal: auth.principal, params: rawParams }) + } catch (error) { + const response = options.errorPolicy.render(error) + if (response) return response + throw error } - throw new V2RouteInfrastructureError('authentication', error) - } - - let gate - try { - gate = await v2ApiGateError(auth.rolloutUserId) - } catch (error) { - throw new V2RouteInfrastructureError('rollout_gate', error) } - if (gate) return gate - const limited = await options.rateLimit.enforce(request, auth, options.operation) - if (limited) return limited const parsed = await parseRequest(options.contract, request, context ?? {}, { + ...options.parseOptions, validationErrorResponse: v2ValidationError, }) if (!parsed.success) return parsed.response diff --git a/apps/sim/lib/auth/principal.test.ts b/apps/sim/lib/auth/principal.test.ts index 0e05aaa4eba..27248919ee8 100644 --- a/apps/sim/lib/auth/principal.test.ts +++ b/apps/sim/lib/auth/principal.test.ts @@ -1,10 +1,50 @@ /** * @vitest-environment node */ -import { resolvePrincipalAttribution, toPrincipalActor } from '@sim/auth/principal' +import { + resolvePrincipalAttribution, + resolvePrincipalAuditAttribution, + toPrincipalActor, +} from '@sim/auth/principal' import { describe, expect, it } from 'vitest' describe('principal actors', () => { + it('maps every principal to an audit actor without billing-owner substitution', () => { + expect( + resolvePrincipalAuditAttribution({ + kind: 'session', + userId: 'user-1', + sessionId: 'session-1', + }) + ).toEqual({ + actor: { kind: 'session', userId: 'user-1' }, + actorId: 'user-1', + }) + expect( + resolvePrincipalAuditAttribution({ + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-2', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'sim:workspace-files', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2026-01-01T00:05:00Z'), + }) + ).toMatchObject({ actorId: 'user-2' }) + expect( + resolvePrincipalAuditAttribution({ + kind: 'workspace_api_key', + keyId: 'key-1', + workspaceId: 'workspace-1', + }) + ).toEqual({ + actor: { kind: 'workspace_api_key', keyId: 'key-1', workspaceId: 'workspace-1' }, + actorId: null, + actorName: 'Workspace API key', + }) + }) + it('projects principals into their shared actor identity', () => { expect( toPrincipalActor({ kind: 'personal_api_key', keyId: 'key-1', userId: 'user-1' }) diff --git a/apps/sim/lib/copilot/auth/file-delegation.test.ts b/apps/sim/lib/copilot/auth/file-delegation.test.ts index 739b7675ba6..6ee85b7dba1 100644 --- a/apps/sim/lib/copilot/auth/file-delegation.test.ts +++ b/apps/sim/lib/copilot/auth/file-delegation.test.ts @@ -37,6 +37,15 @@ describe('Copilot file delegation', () => { expect(principal.expiresAt.getTime()).toBeGreaterThan(principal.issuedAt.getTime()) }) + it('creates a workspace-scoped principal for file creation', () => { + const principal = createCopilotFilePrincipal(trustedContext, 'workspace-1') + + expect(principal.resourceScope).toEqual({ + chatId: 'chat-1', + executionId: 'execution-1', + }) + }) + it('rejects contexts that were not issued by the Copilot execution pipeline', () => { expect(() => createCopilotFilePrincipal( @@ -64,8 +73,11 @@ describe('Copilot file delegation', () => { expect(messageForCopilotFileError(new OrchestrationError('conflict', 'Name exists'))).toBe( 'Name exists' ) - expect(messageForCopilotFileError(new Error('update workspace_files set ...'))).toBe( - 'Failed to rename file' - ) + expect( + messageForCopilotFileError( + new Error('update workspace_files set ...'), + 'Failed to rename file' + ) + ).toBe('Failed to rename file') }) }) diff --git a/apps/sim/lib/copilot/auth/file-delegation.ts b/apps/sim/lib/copilot/auth/file-delegation.ts index 97ac8225647..ff482c2c044 100644 --- a/apps/sim/lib/copilot/auth/file-delegation.ts +++ b/apps/sim/lib/copilot/auth/file-delegation.ts @@ -1,8 +1,6 @@ import type { DelegatedPrincipal } from '@sim/auth/principal' import { asOrchestrationError } from '@/lib/core/orchestration/types' -import { WORKSPACE_FILES_DELEGATION_AUDIENCE } from '@/lib/workspace-files/application/authorization' - -const FILE_DELEGATION_TTL_MS = 5 * 60 * 1000 +import { createWorkspaceFileDelegatedPrincipal } from '@/lib/workspace-files/application/delegated-principal' export interface CopilotFileDelegationContext { userId: string @@ -16,7 +14,7 @@ export interface CopilotFileDelegationContext { export function createCopilotFilePrincipal( context: CopilotFileDelegationContext, workspaceId: string, - fileId: string + fileId?: string ): DelegatedPrincipal { if (!context.copilotToolExecution) { throw new Error('File delegation requires a trusted Copilot execution context') @@ -28,26 +26,22 @@ export function createCopilotFilePrincipal( throw new Error('File delegation workspace does not match the execution context') } - const issuedAt = new Date() - return { - kind: 'delegated', + return createWorkspaceFileDelegatedPrincipal({ serviceId: 'copilot', subjectUserId: context.userId, workspaceId, delegationId: `copilot-tool:${context.toolCallId}`, - audience: WORKSPACE_FILES_DELEGATION_AUDIENCE, - issuedAt, - expiresAt: new Date(issuedAt.getTime() + FILE_DELEGATION_TTL_MS), - resourceScope: { - fileId, - ...(context.chatId ? { chatId: context.chatId } : {}), - ...(context.executionId ? { executionId: context.executionId } : {}), - }, - } + fileId, + chatId: context.chatId, + executionId: context.executionId, + }) } -export function messageForCopilotFileError(error: unknown): string { +export function messageForCopilotFileError( + error: unknown, + fallback = 'File operation failed' +): string { const classified = asOrchestrationError(error) if (classified && classified.code !== 'internal') return classified.message - return 'Failed to rename file' + return fallback } diff --git a/apps/sim/lib/copilot/chat/process-contents.test.ts b/apps/sim/lib/copilot/chat/process-contents.test.ts index 0e9b85a26f3..0c1307fd4a5 100644 --- a/apps/sim/lib/copilot/chat/process-contents.test.ts +++ b/apps/sim/lib/copilot/chat/process-contents.test.ts @@ -17,6 +17,7 @@ const { getSkillById, getUserPermissionConfig, getWorkspaceFile, + readWorkspaceFileMetadata, getTableById, getRowsByIds, getBlockVisibilityForCopilot, @@ -28,6 +29,7 @@ const { getSkillById: vi.fn(), getUserPermissionConfig: vi.fn(), getWorkspaceFile: vi.fn(), + readWorkspaceFileMetadata: vi.fn(), getTableById: vi.fn(), getRowsByIds: vi.fn(), getBlockVisibilityForCopilot: vi.fn(async () => null), @@ -43,6 +45,9 @@ vi.mock('@/lib/integrations/availability.server', () => ({ vi.mock('@/lib/workflows/skills/operations', () => ({ getSkillById })) vi.mock('@/lib/mcp/service', () => ({ mcpService: { discoverServerTools } })) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ getWorkspaceFile })) +vi.mock('@/lib/workspace-files/application/read-workspace-file-metadata', () => ({ + readWorkspaceFileMetadata: { execute: readWorkspaceFileMetadata }, +})) vi.mock('@/lib/table/service', () => ({ getTableById })) vi.mock('@/lib/table/rows/service', () => ({ getRowsByIds })) @@ -444,6 +449,13 @@ describe('processContextsServer - logs contexts', () => { describe('processContextsServer - file_selection contexts', () => { beforeEach(() => { vi.clearAllMocks() + readWorkspaceFileMetadata.mockImplementation( + async ({ input }: { input: { fileId: string } }) => { + const file = await getWorkspaceFile('ws-1', input.fileId) + if (!file) throw new Error('File not found') + return { file } + } + ) }) it('inlines the selected passage with its line range and a path pointer', async () => { diff --git a/apps/sim/lib/copilot/chat/process-contents.ts b/apps/sim/lib/copilot/chat/process-contents.ts index 28f6543841d..a194b8e7d48 100644 --- a/apps/sim/lib/copilot/chat/process-contents.ts +++ b/apps/sim/lib/copilot/chat/process-contents.ts @@ -37,9 +37,10 @@ import { getRowsByIds } from '@/lib/table/rows/service' import { getTableById } from '@/lib/table/service' import type { ColumnDefinition } from '@/lib/table/types' import { getWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' -import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { getSkillById } from '@/lib/workflows/skills/operations' import { listFolders } from '@/lib/workflows/utils' +import { createWorkspaceFileDelegatedPrincipal } from '@/lib/workspace-files/application/delegated-principal' +import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' import { checkKnowledgeBaseAccess } from '@/app/api/knowledge/utils' import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' import { escapeRegExp } from '@/executor/constants' @@ -233,7 +234,7 @@ export async function processContextsServer( } } if (ctx.kind === 'file' && ctx.fileId && currentWorkspaceId) { - const result = await resolveFileResource(ctx.fileId, currentWorkspaceId) + const result = await resolveFileResource(ctx.fileId, currentWorkspaceId, userId, chatId) if (!result) return null return { type: 'file', @@ -249,7 +250,9 @@ export async function processContextsServer( ctx.text ?? '', ctx.label, ctx.startLine, - ctx.endLine + ctx.endLine, + userId, + chatId ) } if ( @@ -847,7 +850,7 @@ export async function resolveActiveResourceContext( return await resolveTableResource(resourceId, workspaceId) } case 'file': { - return await resolveFileResource(resourceId, workspaceId) + return await resolveFileResource(resourceId, workspaceId, userId, chatId) } case 'folder': { return await resolveFolderResource(resourceId, workspaceId) @@ -880,10 +883,21 @@ async function resolveTableResource( async function resolveFileResource( fileId: string, - workspaceId: string + workspaceId: string, + userId: string, + chatId?: string ): Promise { - const record = await getWorkspaceFile(workspaceId, fileId) - if (!record) return null + const principal = createWorkspaceFileDelegatedPrincipal({ + serviceId: 'copilot', + subjectUserId: userId, + workspaceId, + delegationId: `copilot-chat:${chatId ?? workspaceId}`, + chatId, + }) + const { file: record } = await readWorkspaceFileMetadata.execute({ + principal, + input: { fileId, assertedWorkspaceId: workspaceId }, + }) return { type: 'active_resource', tag: '@active_resource', @@ -919,10 +933,22 @@ async function resolveFileSelectionResource( text: string, label: string, startLine?: number, - endLine?: number + endLine?: number, + userId?: string, + chatId?: string ): Promise { - const record = await getWorkspaceFile(workspaceId, fileId) - if (!record) return null + if (!userId) throw new Error('File selection context requires a user ID') + const principal = createWorkspaceFileDelegatedPrincipal({ + serviceId: 'copilot', + subjectUserId: userId, + workspaceId, + delegationId: `copilot-chat:${chatId ?? workspaceId}`, + chatId, + }) + const { file: record } = await readWorkspaceFileMetadata.execute({ + principal, + input: { fileId, assertedWorkspaceId: workspaceId }, + }) const path = canonicalWorkspaceFilePath({ folderPath: record.folderPath, name: record.name }) const snippet = truncateSelectionText(text) const lineRange = diff --git a/apps/sim/lib/copilot/chat/workspace-context.ts b/apps/sim/lib/copilot/chat/workspace-context.ts index d2144ab844a..b0e4f649c48 100644 --- a/apps/sim/lib/copilot/chat/workspace-context.ts +++ b/apps/sim/lib/copilot/chat/workspace-context.ts @@ -23,10 +23,11 @@ import { getAccessibleOAuthCredentials, } from '@/lib/credentials/environment' import { listWorkspaceSandboxes } from '@/lib/execution/remote-sandbox/workspace-sandboxes' -import { listWorkspaceFiles } from '@/lib/uploads/contexts/workspace' import { listCustomBlockSummariesForWorkspace } from '@/lib/workflows/custom-blocks/operations' import { listCustomTools } from '@/lib/workflows/custom-tools/operations' import { listSkillsForUser } from '@/lib/workflows/skills/operations' +import { createWorkspaceFileDelegatedPrincipal } from '@/lib/workspace-files/application/delegated-principal' +import { listAllWorkspaceFiles } from '@/lib/workspace-files/application/list-workspace-files' import { assertActiveWorkspaceAccess, getUsersWithPermissions, @@ -329,7 +330,7 @@ export function buildWorkspaceContextMd(data: WorkspaceMdData): string { async function buildWorkspaceMdData( workspaceId: string, userId: string, - options?: { workspaceAccess?: WorkspaceAccess } + options?: { workspaceAccess?: WorkspaceAccess; chatId?: string; executionId?: string } ): Promise { try { // Reuse the caller's already-asserted access when provided (hot chat path); @@ -409,7 +410,19 @@ async function buildWorkspaceMdData( ) ), - listWorkspaceFiles(workspaceId), + listAllWorkspaceFiles + .execute({ + principal: createWorkspaceFileDelegatedPrincipal({ + serviceId: 'copilot', + subjectUserId: userId, + workspaceId, + delegationId: `copilot-workspace-context:${options?.chatId ?? options?.executionId ?? workspaceId}`, + chatId: options?.chatId, + executionId: options?.executionId, + }), + input: { workspaceId, scope: 'active' }, + }) + .then(({ files }) => files), getAccessibleOAuthCredentials(workspaceId, userId), @@ -549,7 +562,12 @@ const WORKSPACE_CONTEXT_UNAVAILABLE_MD = export async function generateWorkspaceContext( workspaceId: string, userId: string, - options?: { workspaceAccess?: WorkspaceAccess; secretMountPolicy?: SecretMountPolicy } + options?: { + workspaceAccess?: WorkspaceAccess + secretMountPolicy?: SecretMountPolicy + chatId?: string + executionId?: string + } ): Promise { const data = await buildWorkspaceMdData(workspaceId, userId, options) if (!data) return WORKSPACE_CONTEXT_UNAVAILABLE_MD diff --git a/apps/sim/lib/copilot/request/go/file-preview-adapter.ts b/apps/sim/lib/copilot/request/go/file-preview-adapter.ts index 3d16cff4bb1..8a9615ffe52 100644 --- a/apps/sim/lib/copilot/request/go/file-preview-adapter.ts +++ b/apps/sim/lib/copilot/request/go/file-preview-adapter.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' +import { createCopilotFilePrincipal } from '@/lib/copilot/auth/file-delegation' import { MothershipStreamV1EventType } from '@/lib/copilot/generated/mothership-stream-v1' import { createFilePreviewSession, @@ -25,7 +26,8 @@ import { loadWorkspaceFileTextForPreview, type WorkspaceFilePreviewBase, } from '@/lib/copilot/tools/server/files/file-preview' -import { resolveWorkspaceFileReference } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { findWorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { listAllWorkspaceFiles } from '@/lib/workspace-files/application/list-workspace-files' const logger = createLogger('CopilotFilePreviewAdapter') @@ -65,14 +67,22 @@ function toPreviewTargetKind(kind: string | undefined): FilePreviewTargetKind | } async function resolvePreviewTarget(args: { + context: ExecutionContext workspaceId?: string target: FileIntent['target'] }): Promise { if (args.target.kind !== 'path' || !args.workspaceId || !args.target.path) { return args.target } + if (!args.context.copilotToolExecution || !args.context.toolCallId) { + throw new Error('Workspace file preview requires a trusted Copilot execution context') + } - const file = await resolveWorkspaceFileReference(args.workspaceId, args.target.path) + const { files } = await listAllWorkspaceFiles.execute({ + principal: createCopilotFilePrincipal(args.context, args.workspaceId), + input: { workspaceId: args.workspaceId, scope: 'active' }, + }) + const file = findWorkspaceFileRecord(files, args.target.path) if (!file) { return args.target } @@ -366,6 +376,7 @@ export async function processFilePreviewStreamEvent(input: { if (toolCallId && parsedArgs) { const { operation, title, contentType, edit } = parsedArgs const target = await resolvePreviewTarget({ + context: execContext, workspaceId: execContext.workspaceId, target: parsedArgs.target, }) @@ -393,7 +404,11 @@ export async function processFilePreviewStreamEvent(input: { fileId && (operation === 'append' || operation === 'patch') ) { - previewBase = await loadWorkspaceFileTextForPreview(execContext.workspaceId, fileId) + previewBase = await loadWorkspaceFileTextForPreview( + execContext, + execContext.workspaceId, + fileId + ) } let session = buildPreviewSessionFromIntent(streamId, intent) @@ -464,7 +479,11 @@ export async function processFilePreviewStreamEvent(input: { execContext.workspaceId && (intent.operation === 'append' || intent.operation === 'patch') ) { - previewBase = await loadWorkspaceFileTextForPreview(execContext.workspaceId, result.fileId) + previewBase = await loadWorkspaceFileTextForPreview( + execContext, + execContext.workspaceId, + result.fileId + ) } let session = buildPreviewSessionFromIntent(streamId, intent) diff --git a/apps/sim/lib/copilot/request/go/stream.test.ts b/apps/sim/lib/copilot/request/go/stream.test.ts index efa9d8ef7d8..32d6a664353 100644 --- a/apps/sim/lib/copilot/request/go/stream.test.ts +++ b/apps/sim/lib/copilot/request/go/stream.test.ts @@ -23,9 +23,22 @@ vi.mock('@/lib/copilot/request/session', async () => { }) const resolveWorkspaceFileReferenceMock = vi.hoisted(() => vi.fn()) +const listAllWorkspaceFilesMock = vi.hoisted(() => vi.fn()) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ resolveWorkspaceFileReference: resolveWorkspaceFileReferenceMock, + findWorkspaceFileRecord: ( + files: Array<{ name: string; folderPath?: string | null }>, + path: string + ) => + files.find((file) => { + const normalized = path.replace(/^files\//, '').replaceAll('%20', ' ') + const filePath = file.folderPath ? `${file.folderPath}/${file.name}` : file.name + return filePath === normalized + }) ?? null, +})) +vi.mock('@/lib/workspace-files/application/list-workspace-files', () => ({ + listAllWorkspaceFiles: { execute: listAllWorkspaceFilesMock }, })) vi.mock('@/lib/copilot/tools/server/files/file-preview', async () => { @@ -121,6 +134,8 @@ describe('copilot go stream helpers', () => { vi.stubGlobal('fetch', vi.fn()) resolveWorkspaceFileReferenceMock.mockReset() resolveWorkspaceFileReferenceMock.mockResolvedValue(null) + listAllWorkspaceFilesMock.mockReset() + listAllWorkspaceFilesMock.mockResolvedValue({ files: [] }) }) afterEach(() => { @@ -169,9 +184,8 @@ describe('copilot go stream helpers', () => { }) it('hydrates path-based workspace_file edits into file preview events before edit_content streams', async () => { - resolveWorkspaceFileReferenceMock.mockResolvedValue({ - id: 'file-1', - name: 'notes.md', + listAllWorkspaceFilesMock.mockResolvedValue({ + files: [{ id: 'file-1', name: 'notes.md', folderPath: null }], }) const workspaceFileCall = createEvent({ @@ -274,6 +288,8 @@ describe('copilot go stream helpers', () => { workflowId: 'workflow-1', workspaceId: 'workspace-1', messageId: 'msg-1', + copilotToolExecution: true, + toolCallId: 'stream-tool-1', } await runStreamLoop('https://example.com/mothership/stream', {}, context, execContext, { @@ -308,13 +324,24 @@ describe('copilot go stream helpers', () => { previewPhase: 'file_preview_complete', fileId: 'file-1', }) - expect(resolveWorkspaceFileReferenceMock).toHaveBeenCalledWith('workspace-1', 'files/notes.md') + expect(listAllWorkspaceFilesMock).toHaveBeenCalledWith({ + principal: expect.objectContaining({ + kind: 'delegated', + workspaceId: 'workspace-1', + }), + input: { workspaceId: 'workspace-1', scope: 'active' }, + }) }) it('resolves workflow alias paths to the backing file before streaming previews', async () => { - resolveWorkspaceFileReferenceMock.mockResolvedValue({ - id: 'changelog-file-1', - name: 'workflow-1.md', + listAllWorkspaceFilesMock.mockResolvedValue({ + files: [ + { + id: 'changelog-file-1', + name: 'changelog.md', + folderPath: 'workflows/My Workflow', + }, + ], }) const workspaceFileCall = createEvent({ @@ -392,6 +419,8 @@ describe('copilot go stream helpers', () => { workflowId: 'workflow-1', workspaceId: 'workspace-1', messageId: 'msg-1', + copilotToolExecution: true, + toolCallId: 'stream-tool-2', } await runStreamLoop('https://example.com/mothership/stream', {}, context, execContext, { @@ -414,7 +443,7 @@ describe('copilot go stream helpers', () => { ]) expect(previewEvents[1].payload).toMatchObject({ previewPhase: 'file_preview_target', - target: { kind: 'file_id', fileId: 'changelog-file-1', fileName: 'workflow-1.md' }, + target: { kind: 'file_id', fileId: 'changelog-file-1', fileName: 'changelog.md' }, }) expect(previewEvents[2].payload).toMatchObject({ previewPhase: 'file_preview_content', @@ -426,10 +455,13 @@ describe('copilot go stream helpers', () => { previewPhase: 'file_preview_complete', fileId: 'changelog-file-1', }) - expect(resolveWorkspaceFileReferenceMock).toHaveBeenCalledWith( - 'workspace-1', - 'workflows/My%20Workflow/changelog.md' - ) + expect(listAllWorkspaceFilesMock).toHaveBeenCalledWith({ + principal: expect.objectContaining({ + kind: 'delegated', + workspaceId: 'workspace-1', + }), + input: { workspaceId: 'workspace-1', scope: 'active' }, + }) }) it('drops duplicate tool_result events before forwarding them', async () => { diff --git a/apps/sim/lib/copilot/request/tools/files.test.ts b/apps/sim/lib/copilot/request/tools/files.test.ts index f09a7c396d6..16fbe8c6702 100644 --- a/apps/sim/lib/copilot/request/tools/files.test.ts +++ b/apps/sim/lib/copilot/request/tools/files.test.ts @@ -113,6 +113,8 @@ describe('maybeWriteOutputToFile', () => { userId: 'user-1', workflowId: 'wf-1', workspaceId: 'workspace-1', + toolCallId: 'tool-1', + copilotToolExecution: true, userPermission: 'write', resolvedSecretTraceRegistry: new ResolvedSecretTraceRegistry(), ...overrides, diff --git a/apps/sim/lib/copilot/request/tools/files.ts b/apps/sim/lib/copilot/request/tools/files.ts index 43ffdcc9c27..bda8e4672d7 100644 --- a/apps/sim/lib/copilot/request/tools/files.ts +++ b/apps/sim/lib/copilot/request/tools/files.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' +import { createCopilotFilePrincipal } from '@/lib/copilot/auth/file-delegation' import { FunctionExecute, UserTable } from '@/lib/copilot/generated/tool-catalog-v1' import { CopilotOutputFileOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' @@ -271,6 +272,7 @@ export async function maybeWriteOutputToFile( }, async (span) => { try { + const principal = createCopilotFilePrincipal(context, context.workspaceId!) const writtenFiles = [] for (const outputFile of outputFiles) { const fileName = normalizeOutputWorkspaceFileName( @@ -287,7 +289,7 @@ export async function maybeWriteOutputToFile( const written = await writeWorkspaceFileByPath({ workspaceId: context.workspaceId!, - userId: context.userId!, + principal, target: { path: outputFile.path, mode: outputFile.mode ?? 'create', diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts index 54817fffbc3..a367c9f3f29 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts @@ -15,8 +15,8 @@ const { updateCustomBlockMock, deleteCustomBlockMock, getCustomBlockWithInputsByWorkflowIdMock, - listWorkspaceFilesMock, - fetchWorkspaceFileBufferMock, + resolveWorkspaceFileReferenceMock, + readWorkspaceFileContentMock, uploadFileMock, } = vi.hoisted(() => ({ ensureWorkflowAccessMock: vi.fn(), @@ -27,8 +27,8 @@ const { updateCustomBlockMock: vi.fn(), deleteCustomBlockMock: vi.fn(), getCustomBlockWithInputsByWorkflowIdMock: vi.fn(), - listWorkspaceFilesMock: vi.fn(), - fetchWorkspaceFileBufferMock: vi.fn(), + resolveWorkspaceFileReferenceMock: vi.fn(), + readWorkspaceFileContentMock: vi.fn(), uploadFileMock: vi.fn(), })) @@ -51,9 +51,11 @@ vi.mock('@/lib/billing', () => ({ isOrganizationOnEnterprisePlan: isOrganizationOnEnterprisePlanMock, })) -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ - listWorkspaceFiles: listWorkspaceFilesMock, - fetchWorkspaceFileBuffer: fetchWorkspaceFileBufferMock, +vi.mock('@/lib/workspace-files/application/resolve-workspace-file-reference', () => ({ + resolveWorkspaceFileReference: resolveWorkspaceFileReferenceMock, +})) +vi.mock('@/lib/workspace-files/application/read-workspace-file-content', () => ({ + readWorkspaceFileContent: { execute: readWorkspaceFileContentMock }, })) vi.mock('@/lib/uploads/core/storage-service', () => ({ @@ -77,7 +79,13 @@ vi.mock('@/lib/workflows/custom-blocks/operations', () => { import { executeDeployCustomBlock } from './custom-block' -const context = { userId: 'user-1', workflowId: 'wf-1' } as ExecutionContext +const context = { + userId: 'user-1', + workflowId: 'wf-1', + workspaceId: 'ws-1', + toolCallId: 'tool-1', + copilotToolExecution: true, +} as ExecutionContext const publishedBlock = { id: 'cb-1', @@ -327,16 +335,18 @@ describe('executeDeployCustomBlock', () => { }) it('ingests a workspace-file icon into public icon storage', async () => { - listWorkspaceFilesMock.mockResolvedValue([ - { - name: 'icon.png', - folderPath: null, - type: 'image/png', - size: 1024, - key: 'workspace/ws-1/123-abc-icon.png', - }, - ]) - fetchWorkspaceFileBufferMock.mockResolvedValue(Buffer.from('png-bytes')) + resolveWorkspaceFileReferenceMock.mockResolvedValue({ + id: 'file-1', + name: 'icon.png', + folderPath: null, + type: 'image/png', + size: 1024, + key: 'workspace/ws-1/123-abc-icon.png', + }) + readWorkspaceFileContentMock.mockResolvedValue({ + file: { id: 'file-1', name: 'icon.png' }, + content: Buffer.from('png-bytes'), + }) uploadFileMock.mockResolvedValue({ path: '/api/files/serve/s3/workspace-logos%2Ficon.png' }) publishCustomBlockMock.mockResolvedValue(publishedBlock) @@ -384,7 +394,7 @@ describe('executeDeployCustomBlock', () => { }) it('fails when the icon workspace file does not exist', async () => { - listWorkspaceFilesMock.mockResolvedValue([]) + resolveWorkspaceFileReferenceMock.mockRejectedValue(new Error('File not found')) const result = await executeDeployCustomBlock( { @@ -436,9 +446,14 @@ describe('executeDeployCustomBlock', () => { }) it('fails when the icon workspace file is not an image', async () => { - listWorkspaceFilesMock.mockResolvedValue([ - { name: 'notes.pdf', folderPath: null, type: 'application/pdf', size: 1024, key: 'k' }, - ]) + resolveWorkspaceFileReferenceMock.mockResolvedValue({ + id: 'file-2', + name: 'notes.pdf', + folderPath: null, + type: 'application/pdf', + size: 1024, + key: 'k', + }) const result = await executeDeployCustomBlock( { diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts index 8b2182b6abe..ede032ef3d2 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts @@ -3,13 +3,10 @@ import { toError } from '@sim/utils/errors' import { generateShortId } from '@sim/utils/id' import { isAllowedCustomBlockIconUrl } from '@/lib/api/contracts/custom-blocks' import { isOrganizationOnEnterprisePlan } from '@/lib/billing' +import { createCopilotFilePrincipal } from '@/lib/copilot/auth/file-delegation' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import { canonicalizeVfsPath, canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' +import { canonicalizeVfsPath } from '@/lib/copilot/vfs/path-utils' import { isFeatureEnabled } from '@/lib/core/config/feature-flags' -import { - fetchWorkspaceFileBuffer, - listWorkspaceFiles, -} from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { uploadFile } from '@/lib/uploads/core/storage-service' import { isImageFileType } from '@/lib/uploads/utils/file-utils' import { @@ -20,6 +17,9 @@ import { publishCustomBlock, updateCustomBlock, } from '@/lib/workflows/custom-blocks/operations' +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 { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' import { ensureWorkflowAccess } from '../access' import type { DeployCustomBlockParams } from '../param-types' @@ -38,7 +38,7 @@ const MAX_OUTPUT_ENTRIES = 50 */ async function resolveIconUrl( raw: string | undefined, - userId: string, + context: ExecutionContext, workspaceId: string ): Promise { const value = raw?.trim() @@ -53,13 +53,15 @@ async function resolveIconUrl( } const canonical = canonicalizeVfsPath(value) - const files = await listWorkspaceFiles(workspaceId, { hydrateFolderPaths: true }) - const record = files.find( - (f) => canonicalWorkspaceFilePath({ folderPath: f.folderPath, name: f.name }) === canonical - ) - if (!record) { + const principal = createCopilotFilePrincipal(context, workspaceId) + const record = await resolveWorkspaceFileReference({ + principal, + operation: fileOperations.readContent, + workspaceId, + reference: canonical, + }).catch(() => { throw new CustomBlockValidationError(`Icon file not found in this workspace: ${value}`) - } + }) if (!isImageFileType(record.type)) { throw new CustomBlockValidationError( 'Icon file must be an image (PNG, JPEG, GIF, WebP, or SVG)' @@ -69,7 +71,10 @@ async function resolveIconUrl( throw new CustomBlockValidationError('Icon file must be 5MB or smaller') } - const buffer = await fetchWorkspaceFileBuffer(record) + const { content: buffer } = await readWorkspaceFileContent.execute({ + principal, + input: { fileId: record.id, assertedWorkspaceId: workspaceId, maxBytes: MAX_ICON_BYTES }, + }) const safeFileName = record.name.replace(/[^a-zA-Z0-9.-]/g, '_') const uploaded = await uploadFile({ file: buffer, @@ -78,7 +83,7 @@ async function resolveIconUrl( context: 'workspace-logos', customKey: `workspace-logos/${Date.now()}-${generateShortId()}-${safeFileName}`, preserveKey: true, - metadata: { workspaceId, userId, originalName: record.name }, + metadata: { workspaceId, userId: context.userId, originalName: record.name }, }) return uploaded.path } @@ -218,7 +223,7 @@ export async function executeDeployCustomBlock( if (params.exposedOutputs?.some((entry) => entry.name.length > 60)) { return { success: false, error: 'exposed output names must be 60 characters or fewer' } } - const iconUrl = await resolveIconUrl(params.iconUrl, context.userId, workspaceId) + const iconUrl = await resolveIconUrl(params.iconUrl, context, workspaceId) if (existing) { await updateCustomBlock(existing.id, { diff --git a/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts b/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts index 8257c9cbcd3..15f33a0ba4a 100644 --- a/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/function-execute.test.ts @@ -26,6 +26,10 @@ const { mockFetchServableWorkspaceFileBuffer, mockGetSandboxWorkspaceFilePath, mockListWorkspaceFileFolders, + mockListAllWorkspaceFiles, + mockListWorkspaceFileFoldersOperation, + mockDownloadWorkspaceFileRecord, + mockReadWorkspaceFileContent, mockMaterializeCopilotCodeSecrets, mockHasWorkspaceSandboxAccess, mockImportWorkspaceFileSecretProvenanceForRuntime, @@ -47,6 +51,10 @@ const { mockFetchServableWorkspaceFileBuffer: vi.fn(), mockGetSandboxWorkspaceFilePath: vi.fn(), mockListWorkspaceFileFolders: vi.fn(), + mockListAllWorkspaceFiles: vi.fn(), + mockListWorkspaceFileFoldersOperation: vi.fn(), + mockDownloadWorkspaceFileRecord: vi.fn(), + mockReadWorkspaceFileContent: vi.fn(), mockMaterializeCopilotCodeSecrets: vi.fn(), mockHasWorkspaceSandboxAccess: vi.fn(), mockImportWorkspaceFileSecretProvenanceForRuntime: vi.fn(), @@ -85,6 +93,18 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => ({ listWorkspaceFileFolders: mockListWorkspaceFileFolders, })) +vi.mock('@/lib/workspace-files/application/list-workspace-files', () => ({ + listAllWorkspaceFiles: { execute: mockListAllWorkspaceFiles }, +})) +vi.mock('@/lib/workspace-files/application/workspace-file-folders', () => ({ + listWorkspaceFileFoldersOperation: { execute: mockListWorkspaceFileFoldersOperation }, +})) +vi.mock('@/lib/workspace-files/application/read-workspace-file-record', () => ({ + downloadWorkspaceFileRecord: { execute: mockDownloadWorkspaceFileRecord }, +})) +vi.mock('@/lib/workspace-files/application/read-workspace-file-content', () => ({ + readWorkspaceFileContent: { execute: mockReadWorkspaceFileContent }, +})) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', () => ({ importWorkspaceFileSecretProvenanceForRuntime: mockImportWorkspaceFileSecretProvenanceForRuntime, })) @@ -115,7 +135,12 @@ const table = { schema: { columns: [{ id: 'col_name', name: 'name', type: 'string' }] }, } -const context = { workspaceId: 'ws_1', userId: 'u1' } +const context = { + workspaceId: 'ws_1', + userId: 'u1', + copilotToolExecution: true, + toolCallId: 'function-execute-test', +} function mountedFiles() { const params = mockExecuteTool.mock.calls[0][1] as { @@ -138,6 +163,37 @@ function resetExecutionMocks(): void { entries: [], }) mockIsTableSnapshotSafeForModelMount.mockResolvedValue(true) + mockListWorkspaceFiles.mockResolvedValue([]) + mockListWorkspaceFileFolders.mockResolvedValue([]) + mockListAllWorkspaceFiles.mockImplementation(async () => { + const files = await mockListWorkspaceFiles() + if (files.length > 0) return { files } + const fallback = mockFindWorkspaceFileRecord() + return { files: fallback ? [fallback] : [] } + }) + mockListWorkspaceFileFoldersOperation.mockImplementation(async () => ({ + folders: await mockListWorkspaceFileFolders(), + })) + mockDownloadWorkspaceFileRecord.mockImplementation( + async ({ input }: { input: { fileId: string } }) => { + const files = await mockListWorkspaceFiles() + const file = + files.find((candidate: { id: string }) => candidate.id === input.fileId) ?? + mockFindWorkspaceFileRecord() + if (!file) throw new Error('File not found') + return { file } + } + ) + mockReadWorkspaceFileContent.mockImplementation( + async ({ input }: { input: { fileId: string } }) => { + const files = await mockListWorkspaceFiles() + const file = + files.find((candidate: { id: string }) => candidate.id === input.fileId) ?? + mockFindWorkspaceFileRecord() + if (!file) throw new Error('File not found') + return { file, content: await mockFetchWorkspaceFileBuffer(file) } + } + ) } describe('executeFunctionExecute trace-secret provenance', () => { @@ -891,7 +947,9 @@ describe('executeFunctionExecute file mounts', () => { }) it('cloud storage: throws when a file exceeds the per-file URL mount limit', async () => { - mockFindWorkspaceFileRecord.mockReturnValue({ ...fileRecord, size: 600 * 1024 * 1024 }) + const oversized = { ...fileRecord, size: 600 * 1024 * 1024 } + mockFindWorkspaceFileRecord.mockReturnValue(oversized) + mockListWorkspaceFiles.mockResolvedValue([oversized]) await expect( executeFunctionExecute({ inputFiles: ['files/data.csv'] }, context as never) @@ -901,7 +959,15 @@ describe('executeFunctionExecute file mounts', () => { it('cloud storage: throws when mounts exceed the aggregate URL mount limit', async () => { // Each file is at the 500MB per-file cap; the 5th pushes the running total past 2GB. - mockFindWorkspaceFileRecord.mockReturnValue({ ...fileRecord, size: 500 * 1024 * 1024 }) + const oversized = { ...fileRecord, size: 500 * 1024 * 1024 } + mockFindWorkspaceFileRecord.mockReturnValue(oversized) + mockListWorkspaceFiles.mockResolvedValue( + Array.from({ length: 5 }, (_, i) => ({ + ...oversized, + id: `file_${i}`, + name: `big-${i}.csv`, + })) + ) const paths = Array.from({ length: 5 }, (_, i) => `files/big-${i}.csv`) await expect(executeFunctionExecute({ inputFiles: paths }, context as never)).rejects.toThrow( diff --git a/apps/sim/lib/copilot/tools/handlers/function-execute.ts b/apps/sim/lib/copilot/tools/handlers/function-execute.ts index 067a41f07ca..4a8c8bcd551 100644 --- a/apps/sim/lib/copilot/tools/handlers/function-execute.ts +++ b/apps/sim/lib/copilot/tools/handlers/function-execute.ts @@ -1,6 +1,8 @@ +import type { Principal } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { omit } from '@sim/utils/object' import { hasWorkspaceSandboxAccess } from '@/lib/billing/core/subscription' +import { createCopilotFilePrincipal } from '@/lib/copilot/auth/file-delegation' import { applySecretMountPolicy } from '@/lib/copilot/secret-mount-policy' import type { ToolExecutionContext, ToolExecutionResult } from '@/lib/copilot/tool-executor/types' import { @@ -27,13 +29,10 @@ import { import { queryRows } from '@/lib/table/rows/service' import { getTableById, listTables } from '@/lib/table/service' import { getOrCreateTableSnapshot, SNAPSHOT_MAX_BYTES } from '@/lib/table/snapshot-cache' -import { listWorkspaceFileFolders } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { fetchServableWorkspaceFileBuffer, - fetchWorkspaceFileBuffer, findWorkspaceFileRecord, getSandboxWorkspaceFilePath, - listWorkspaceFiles, type WorkspaceFileRecord, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { importWorkspaceFileSecretProvenanceForRuntime } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' @@ -43,6 +42,10 @@ import { hasCloudStorage, } from '@/lib/uploads/core/storage-service' import { isGeneratedDocumentSourceType } from '@/lib/uploads/utils/file-utils' +import { listAllWorkspaceFiles } from '@/lib/workspace-files/application/list-workspace-files' +import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' +import { downloadWorkspaceFileRecord } from '@/lib/workspace-files/application/read-workspace-file-record' +import { listWorkspaceFileFoldersOperation } from '@/lib/workspace-files/application/workspace-file-folders' import { extractCodeSecretNames } from '@/executor/utils/code-secret-references' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' import { executeTool as executeAppTool } from '@/tools' @@ -132,8 +135,15 @@ async function pushWorkspaceFileMount( mountPath: string, mounted: MountedBytes, workspaceId: string, + principal: Principal, registry?: ResolvedSecretTraceRegistry ): Promise { + record = ( + await downloadWorkspaceFileRecord.execute({ + principal, + input: { fileId: record.id, assertedWorkspaceId: workspaceId }, + }) + ).file await importMountedWorkspaceFileProvenance({ workspaceId, record, mountPath, registry }) // A generated document stores its generator source, so a presigned URL for @@ -191,7 +201,19 @@ async function pushWorkspaceFileMount( `Input file "${mountPath}" renders to more than the ${MAX_FILE_SIZE / 1024 / 1024}MB per-file mount limit, or than the mount budget left. Mount fewer or smaller files.` ) }) - : { buffer: await fetchWorkspaceFileBuffer(record), contentType: record.type } + : { + buffer: ( + await readWorkspaceFileContent.execute({ + principal, + input: { + fileId: record.id, + assertedWorkspaceId: workspaceId, + maxBytes: Math.min(MAX_FILE_SIZE, remainingBudget), + }, + }) + ).content, + contentType: record.type, + } // Keyed off the resolved type: a rendered document's source MIME is `text/x-…`, and // decoding the binary as UTF-8 would corrupt it just as surely as shipping the source. const isText = /^text\/|application\/json|application\/xml|application\/csv/.test( @@ -286,18 +308,25 @@ export async function resolveInputFiles( inputTables?: unknown[], inputDirectories?: unknown[], provenanceUserId?: string, - resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry + resolvedSecretTraceRegistry?: ResolvedSecretTraceRegistry, + filePrincipal?: Principal ): Promise { const sandboxFiles: SandboxFile[] = [] const mounted: MountedBytes = { buffered: 0, url: 0 } if (inputFiles?.length && workspaceId) { + if (!filePrincipal) { + throw new Error('Workspace file mounts require a trusted Copilot principal') + } if (inputFiles.length > MAX_MOUNTED_FILES) { throw new Error( `Too many input files (${inputFiles.length}). Maximum is ${MAX_MOUNTED_FILES}. Mount fewer files.` ) } - const allFiles = await listWorkspaceFiles(workspaceId) + const { files: allFiles } = await listAllWorkspaceFiles.execute({ + principal: filePrincipal, + input: { workspaceId, scope: 'active' }, + }) for (const fileRef of inputFiles) { const filePath = typeof fileRef === 'string' @@ -327,14 +356,24 @@ export async function resolveInputFiles( mountPath, mounted, workspaceId, + filePrincipal, resolvedSecretTraceRegistry ) } } if (inputDirectories?.length && workspaceId) { - const folders = await listWorkspaceFileFolders(workspaceId) - const allFiles = await listWorkspaceFiles(workspaceId, { folders }) + if (!filePrincipal) { + throw new Error('Workspace directory mounts require a trusted Copilot principal') + } + const { folders } = await listWorkspaceFileFoldersOperation.execute({ + principal: filePrincipal, + input: { workspaceId }, + }) + const { files: allFiles } = await listAllWorkspaceFiles.execute({ + principal: filePrincipal, + input: { workspaceId, scope: 'active' }, + }) for (const dirRef of inputDirectories) { const dirPath = typeof dirRef === 'string' @@ -405,6 +444,7 @@ export async function resolveInputFiles( `${mountRoot}/${relativePath}`, mounted, workspaceId, + filePrincipal, resolvedSecretTraceRegistry ) } @@ -634,7 +674,10 @@ export async function executeFunctionExecute( inputTables, inputDirectories, secretActorUserId ?? context.userId, - mountedRegistry + mountedRegistry, + inputFiles.length > 0 || inputDirectories.length > 0 + ? createCopilotFilePrincipal(context, context.workspaceId) + : undefined ) if (resolved.length > 0) { const existing = (enrichedParams._sandboxFiles as SandboxFile[]) || [] diff --git a/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts b/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts index b789a2360a5..f0cd59c3ce2 100644 --- a/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts @@ -6,6 +6,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const { mockAllocateUniqueWorkspaceFileName, + mockAdmitCreateWorkspaceFile, mockCheckStorageQuotaForBillingContext, mockDecompress, mockFetchBuffer, @@ -17,9 +18,11 @@ const { mockHeadObject, mockIncrementStorageUsageForBillingContextInTx, mockMaybeNotifyStorageLimitForBillingContext, + mockReadWorkspaceFileMetadata, mockResolveStorageBillingContext, } = vi.hoisted(() => ({ mockAllocateUniqueWorkspaceFileName: vi.fn(), + mockAdmitCreateWorkspaceFile: vi.fn(), mockCheckStorageQuotaForBillingContext: vi.fn(), mockDecompress: vi.fn(), mockFetchBuffer: vi.fn(), @@ -31,6 +34,7 @@ const { mockHeadObject: vi.fn(), mockIncrementStorageUsageForBillingContextInTx: vi.fn(), mockMaybeNotifyStorageLimitForBillingContext: vi.fn(), + mockReadWorkspaceFileMetadata: vi.fn(), mockResolveStorageBillingContext: vi.fn(), })) @@ -52,6 +56,14 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ getWorkspaceFile: mockGetWorkspaceFile, })) +vi.mock('@/lib/workspace-files/application/read-workspace-file-metadata', () => ({ + readWorkspaceFileMetadata: { execute: mockReadWorkspaceFileMetadata }, +})) + +vi.mock('@/lib/workspace-files/application/create-workspace-file', () => ({ + createWorkspaceFile: { admit: mockAdmitCreateWorkspaceFile }, +})) + vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', () => ({ getBoundWorkspaceFileSecretProvenance: mockGetBoundWorkspaceFileSecretProvenance, })) @@ -119,8 +131,22 @@ const context = { workspaceId: 'ws-1', userId: 'user-1', workflowId: 'wf-1', + copilotToolExecution: true, + toolCallId: 'materialize-file-test', } as ExecutionContext +mockReadWorkspaceFileMetadata.mockImplementation( + async ({ input }: { input: { fileId: string; assertedWorkspaceId?: string } }) => ({ + file: await mockGetWorkspaceFile( + input.assertedWorkspaceId ?? context.workspaceId, + input.fileId, + { + throwOnError: true, + } + ), + }) +) + const STORAGE_CONTEXT = { workspaceId: 'ws-1', billedAccountUserId: 'workspace-owner', @@ -156,9 +182,12 @@ describe('executeMaterializeFile - workspace write gate', () => { 'refuses %s without workspace write access and touches no upload', async (operation) => { const { ensureWorkspaceAccess } = await import('@/lib/copilot/tools/handlers/access') - vi.mocked(ensureWorkspaceAccess).mockRejectedValueOnce( - new Error('Write access required for this workspace') - ) + const denial = new Error('Write access required for this workspace') + if (operation === 'import') { + vi.mocked(ensureWorkspaceAccess).mockRejectedValueOnce(denial) + } else { + mockAdmitCreateWorkspaceFile.mockRejectedValueOnce(denial) + } const result = await executeMaterializeFile({ fileNames: ['a.json'], operation }, context) @@ -169,10 +198,12 @@ describe('executeMaterializeFile - workspace write gate', () => { ) it('requires write, not merely read, access', async () => { - const { ensureWorkspaceAccess } = await import('@/lib/copilot/tools/handlers/access') await executeMaterializeFile({ fileNames: ['a.json'], operation: 'save' }, context) - expect(ensureWorkspaceAccess).toHaveBeenCalledWith(context.workspaceId, context.userId, 'write') + expect(mockAdmitCreateWorkspaceFile).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'delegated', subjectUserId: context.userId }), + context.workspaceId + ) }) }) @@ -578,7 +609,11 @@ describe('executeMaterializeFile - extract operation', () => { expect.any(Buffer), expect.objectContaining({ workspaceId: 'ws-1', - userId: 'user-1', + principal: expect.objectContaining({ + kind: 'delegated', + subjectUserId: 'user-1', + workspaceId: 'ws-1', + }), rootFolderSegments: ['bundle'], skipNoiseEntries: true, secretProvenance: { status: 'exact', entries: [] }, diff --git a/apps/sim/lib/copilot/tools/handlers/materialize-file.ts b/apps/sim/lib/copilot/tools/handlers/materialize-file.ts index 3c6f05e17e0..c07b79e2f64 100644 --- a/apps/sim/lib/copilot/tools/handlers/materialize-file.ts +++ b/apps/sim/lib/copilot/tools/handlers/materialize-file.ts @@ -1,4 +1,5 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import type { Principal } from '@sim/auth/principal' import { db } from '@sim/db' import { folder as folderTable, workflow, workspaceFiles } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -16,6 +17,7 @@ import { maybeNotifyStorageLimitForBillingContext, resolveStorageBillingContext, } from '@/lib/billing/storage' +import { createCopilotFilePrincipal } from '@/lib/copilot/auth/file-delegation' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' import { findMothershipUploadRowByChatAndName } from '@/lib/copilot/tools/handlers/upload-file-reader' @@ -31,7 +33,6 @@ import { findWorkspaceFileFolderIdByPath } from '@/lib/uploads/contexts/workspac import { allocateUniqueWorkspaceFileName, fetchWorkspaceFileBuffer, - getWorkspaceFile, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { getBoundWorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' import { hasCloudStorage, headObject } from '@/lib/uploads/core/storage-service' @@ -39,6 +40,8 @@ import { isArchiveFileName } from '@/lib/uploads/utils/file-utils' import { parseWorkflowJson } from '@/lib/workflows/operations/import-export' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' import { deduplicateWorkflowName } from '@/lib/workflows/utils' +import { createWorkspaceFile } from '@/lib/workspace-files/application/create-workspace-file' +import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' import { extractWorkflowMetadata } from '@/app/api/v1/admin/types' const logger = createLogger('MaterializeFile') @@ -80,7 +83,8 @@ function uploadBelongsToWorkspace( async function executeSave( fileName: string, chatId: string, - workspaceId: string + workspaceId: string, + principal: Principal ): Promise { const row = await findMothershipUploadRowByChatAndName(chatId, fileName) if (!row) { @@ -183,7 +187,12 @@ async function executeSave( const replayedFile = transition ? null - : await getWorkspaceFile(workspaceId, row.id, { throwOnError: true }) + : ( + await readWorkspaceFileMetadata.execute({ + principal, + input: { fileId: row.id, assertedWorkspaceId: workspaceId }, + }) + ).file const updated = transition?.updated ?? (replayedFile ? { id: replayedFile.id, originalName: replayedFile.name } : null) @@ -371,7 +380,8 @@ async function executeExtract( fileName: string, chatId: string, workspaceId: string, - userId: string + userId: string, + principal: Principal ): Promise { const row = await findMothershipUploadRowByChatAndName(chatId, fileName) if (!row) { @@ -461,7 +471,7 @@ async function executeExtract( }) result = await decompressArchiveBufferToWorkspaceFiles(buffer, { workspaceId, - userId, + principal, rootFolderSegments: [baseName], // The agent-facing extract drops macOS/Windows filesystem cruft so the // unpacked files/ tree only contains meaningful entries. @@ -543,6 +553,8 @@ export async function executeMaterializeFile( return { success: false, error: 'No workspace context available for materialize_file' } } + const principal = createCopilotFilePrincipal(context, context.workspaceId) + const operation = (params.operation as string | undefined) || 'save' // save (promote upload → workspace file), import (JSON → workflow), and extract // (decompress a .zip upload → workspace files/) are implemented. Reject anything @@ -554,10 +566,12 @@ export async function executeMaterializeFile( } } - // Every operation writes: save/extract create files, import creates a workflow. - // The handler-map path has no central permission gate. try { - await ensureWorkspaceAccess(context.workspaceId, context.userId, 'write') + if (operation === 'import') { + await ensureWorkspaceAccess(context.workspaceId, context.userId, 'write') + } else { + await createWorkspaceFile.admit(principal, context.workspaceId) + } } catch (error) { return { success: false, error: getErrorMessage(error, 'Workspace write access required') } } @@ -572,9 +586,15 @@ export async function executeMaterializeFile( if (operation === 'import') { result = await executeImport(fileName, context.chatId, context.workspaceId, context.userId) } else if (operation === 'extract') { - result = await executeExtract(fileName, context.chatId, context.workspaceId, context.userId) + result = await executeExtract( + fileName, + context.chatId, + context.workspaceId, + context.userId, + principal + ) } else { - result = await executeSave(fileName, context.chatId, context.workspaceId) + result = await executeSave(fileName, context.chatId, context.workspaceId, principal) } if (result.success) { diff --git a/apps/sim/lib/copilot/tools/handlers/resources.ts b/apps/sim/lib/copilot/tools/handlers/resources.ts index 52ea670cf74..b87bd2621c4 100644 --- a/apps/sim/lib/copilot/tools/handlers/resources.ts +++ b/apps/sim/lib/copilot/tools/handlers/resources.ts @@ -1,3 +1,4 @@ +import { createCopilotFilePrincipal } from '@/lib/copilot/auth/file-delegation' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { type MothershipResource, MothershipResourceType } from '@/lib/copilot/resources/types' import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' @@ -5,10 +6,12 @@ import { getKnowledgeBaseById } from '@/lib/knowledge/service' import { getLogById } from '@/lib/logs/service' import { getTableById } from '@/lib/table/service' import { - getWorkspaceFile, - resolveWorkspaceFileReference, + findWorkspaceFileRecord, + type WorkspaceFileRecord, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { getWorkflowById } from '@/lib/workflows/utils' +import { listAllWorkspaceFiles } from '@/lib/workspace-files/application/list-workspace-files' +import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' import type { OpenResourceItem, OpenResourceParams, ValidOpenResourceParams } from './param-types' const VALID_OPEN_RESOURCE_TYPES = new Set(Object.values(MothershipResourceType)) @@ -25,11 +28,23 @@ async function resolveResource( if (!context.workspaceId) return { error: 'Opening a workspace file requires workspace context.' } const fileRef = item.path || item.id || '' - const record = item.path - ? await resolveWorkspaceFileReference(context.workspaceId, item.path) - : item.id - ? await getWorkspaceFile(context.workspaceId, item.id) - : null + let record: WorkspaceFileRecord | null + if (item.path) { + const { files } = await listAllWorkspaceFiles.execute({ + principal: createCopilotFilePrincipal(context, context.workspaceId), + input: { workspaceId: context.workspaceId, scope: 'active' }, + }) + record = findWorkspaceFileRecord(files, item.path) + } else if (item.id) { + record = ( + await readWorkspaceFileMetadata.execute({ + principal: createCopilotFilePrincipal(context, context.workspaceId, item.id), + input: { fileId: item.id, assertedWorkspaceId: context.workspaceId }, + }) + ).file + } else { + record = null + } if (!record) return { error: `No workspace file found for "${fileRef}".` } resourceId = record.id title = record.name diff --git a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts index a91a5e32923..114dc66f10c 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts @@ -15,8 +15,13 @@ const mocks = vi.hoisted(() => ({ ensureWorkflowAccess: vi.fn(), getDefaultWorkspaceId: vi.fn(), getWorkspaceFileByName: vi.fn(), + resolveWorkspaceFileReference: vi.fn(), findWorkspaceFileFolderIdByPath: vi.fn(), ensureWorkspaceFileFolderPath: vi.fn(), + ensureCopilotFileFolderPath: vi.fn(), + moveWorkspaceFileItems: vi.fn(), + updateWorkspaceFileFolder: vi.fn(), + deleteWorkspaceFile: vi.fn(), renameWorkspaceFile: vi.fn(), performMoveRenameWorkspaceFile: vi.fn(), performUpdateWorkspaceFileFolder: vi.fn(), @@ -45,17 +50,68 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ getWorkspaceFileByName: mocks.getWorkspaceFileByName, })) +vi.mock('@/lib/workspace-files/application/resolve-workspace-file-reference', () => ({ + resolveWorkspaceFileReference: mocks.resolveWorkspaceFileReference, +})) + vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => ({ findWorkspaceFileFolderIdByPath: mocks.findWorkspaceFileFolderIdByPath, - ensureWorkspaceFileFolderPath: mocks.ensureWorkspaceFileFolderPath, normalizeWorkspaceFileItemName: vi.fn((name: string) => name.trim()), })) -vi.mock('@/lib/workspace-files/orchestration', () => ({ - performMoveRenameWorkspaceFile: mocks.performMoveRenameWorkspaceFile, - performUpdateWorkspaceFileFolder: mocks.performUpdateWorkspaceFileFolder, +vi.mock('@/lib/copilot/tools/server/files/file-folder-application', () => ({ + copilotFilePrincipal: vi.fn((context, workspaceId, fileId) => ({ + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: context.userId, + workspaceId, + delegationId: `copilot-tool:${context.toolCallId}`, + audience: 'sim:workspace-files', + issuedAt: new Date(), + expiresAt: new Date(Date.now() + 300_000), + ...(fileId ? { resourceScope: { fileId } } : {}), + })), + ensureCopilotFileFolderPath: mocks.ensureCopilotFileFolderPath, + requireCopilotWorkspace: vi.fn((context) => context.workspaceId), +})) + +vi.mock('@/lib/workspace-files/application/move-workspace-file-items', () => ({ + moveWorkspaceFileItemsOperation: { + operation: { id: 'files.move', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.moveWorkspaceFileItems, + }, +})) + +vi.mock('@/lib/workspace-files/application/operations', () => ({ + fileOperations: { + move: { id: 'files.move', minimumRole: 'write', workspaceApiKey: 'allow' }, + delete: { id: 'files.delete', minimumRole: 'write', workspaceApiKey: 'allow' }, + }, +})) + +vi.mock('@/lib/workspace-files/application/workspace-file-folders', () => ({ + updateWorkspaceFileFolderOperation: { + operation: { id: 'files.folders.update', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.updateWorkspaceFileFolder, + }, +})) + +vi.mock('@/lib/workspace-files/application/delete-workspace-file', () => ({ + deleteWorkspaceFileOperation: { + operation: { id: 'files.delete', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.deleteWorkspaceFile, + }, +})) + +vi.mock('@/lib/workspace-files/application/archive-workspace-file-items', () => ({ + archiveWorkspaceFileItemsOperation: { + operation: { id: 'files.delete', minimumRole: 'write', workspaceApiKey: 'allow' }, + execute: mocks.deleteWorkspaceFile, + }, })) +vi.mock('@/lib/workspace-files/orchestration', () => ({})) + vi.mock('@/lib/workspace-files/application/rename-workspace-file', () => ({ renameWorkspaceFile: { operation: { id: 'files.rename', minimumRole: 'write', workspaceApiKey: 'allow' }, @@ -119,8 +175,26 @@ describe('vfs mv/cp', () => { mocks.verifyFolderWorkspace.mockResolvedValue(true) mocks.listFolders.mockResolvedValue([]) mocks.getWorkspaceFileByName.mockResolvedValue(null) + mocks.resolveWorkspaceFileReference.mockImplementation(async ({ reference }) => { + const segments = reference.split('/').slice(1) + const folderSegments = segments.slice(0, -1) + if (folderSegments.length > 0) { + const folderId = await mocks.findWorkspaceFileFolderIdByPath('ws-1', folderSegments) + if (!folderId) return null + return mocks.getWorkspaceFileByName('ws-1', segments.at(-1), { folderId }) + } + return mocks.getWorkspaceFileByName('ws-1', segments.at(-1), { folderId: null }) + }) mocks.findWorkspaceFileFolderIdByPath.mockResolvedValue(null) mocks.ensureWorkspaceFileFolderPath.mockResolvedValue('ensured-folder') + mocks.ensureCopilotFileFolderPath.mockResolvedValue('ensured-folder') + mocks.moveWorkspaceFileItems.mockResolvedValue({ movedItems: { files: 1, folders: 0 } }) + mocks.updateWorkspaceFileFolder.mockResolvedValue({ folder: { name: 'Reports 2025' } }) + mocks.deleteWorkspaceFile.mockResolvedValue({ + id: 'file-1', + workspaceId: 'ws-1', + deleted: true, + }) mocks.renameWorkspaceFile.mockResolvedValue({ file: { id: 'file-1', name: 'renamed.md' }, }) @@ -172,7 +246,7 @@ describe('vfs mv/cp', () => { ) expect(result.success).toBe(false) expect(result.error).toContain('aborted') - expect(mocks.performMoveRenameWorkspaceFile).not.toHaveBeenCalled() + expect(mocks.moveWorkspaceFileItems).not.toHaveBeenCalled() }) }) @@ -206,7 +280,7 @@ describe('vfs mv/cp', () => { name: 'final.md', }, }) - expect(mocks.performMoveRenameWorkspaceFile).not.toHaveBeenCalled() + expect(mocks.moveWorkspaceFileItems).not.toHaveBeenCalled() expect(result).toMatchObject({ success: true, output: { results: [{ to: 'files/final.md', id: 'file-1' }] }, @@ -215,8 +289,7 @@ describe('vfs mv/cp', () => { it('moves and renames a file in one call, auto-creating destination folders', async () => { mocks.getWorkspaceFileByName.mockResolvedValue({ id: 'file-1', name: 'draft.md' }) - mocks.performMoveRenameWorkspaceFile.mockResolvedValue({ - success: true, + mocks.renameWorkspaceFile.mockResolvedValue({ file: { id: 'file-1', name: 'final.md' }, }) @@ -228,18 +301,15 @@ describe('vfs mv/cp', () => { expect(mocks.getWorkspaceFileByName).toHaveBeenCalledWith('ws-1', 'draft.md', { folderId: null, }) - expect(mocks.ensureWorkspaceFileFolderPath).toHaveBeenCalledWith({ - workspaceId: 'ws-1', - userId: 'user-1', - pathSegments: ['Reports', '2026'], - }) - expect(mocks.performMoveRenameWorkspaceFile).toHaveBeenCalledWith({ - workspaceId: 'ws-1', - userId: 'user-1', - fileId: 'file-1', - targetFolderId: 'ensured-folder', - newName: 'final.md', - }) + expect(mocks.ensureCopilotFileFolderPath).toHaveBeenCalledWith(context, 'ws-1', [ + 'Reports', + '2026', + ]) + expect(mocks.moveWorkspaceFileItems).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ targetFolderId: 'ensured-folder' }), + }) + ) expect(result.success).toBe(true) expect(result.output).toMatchObject({ results: [{ from: 'files/draft.md', to: 'files/Reports/2026/final.md', kind: 'file' }], @@ -249,20 +319,18 @@ describe('vfs mv/cp', () => { it('moves into an existing folder keeping the name without creating anything', async () => { mocks.findWorkspaceFileFolderIdByPath.mockResolvedValue('folder-images') mocks.getWorkspaceFileByName.mockResolvedValue({ id: 'file-1', name: 'a.png' }) - mocks.performMoveRenameWorkspaceFile.mockResolvedValue({ - success: true, - file: { id: 'file-1', name: 'a.png' }, - }) const result = await executeVfsMv( { sources: ['files/a.png'], destination: 'files/Images' }, context ) - expect(mocks.performMoveRenameWorkspaceFile).toHaveBeenCalledWith( - expect.objectContaining({ targetFolderId: 'folder-images', newName: 'a.png' }) + expect(mocks.moveWorkspaceFileItems).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ targetFolderId: 'folder-images' }), + }) ) - expect(mocks.ensureWorkspaceFileFolderPath).not.toHaveBeenCalled() + expect(mocks.ensureCopilotFileFolderPath).not.toHaveBeenCalled() expect(result.success).toBe(true) expect(result.output).toMatchObject({ results: [{ to: 'files/Images/a.png' }] }) }) @@ -287,8 +355,8 @@ describe('vfs mv/cp', () => { expect(result.success).toBe(false) expect(result.error).toContain('Not found') - expect(mocks.performMoveRenameWorkspaceFile).not.toHaveBeenCalled() - expect(mocks.ensureWorkspaceFileFolderPath).not.toHaveBeenCalled() + expect(mocks.moveWorkspaceFileItems).not.toHaveBeenCalled() + expect(mocks.ensureCopilotFileFolderPath).not.toHaveBeenCalled() }) it('rejects copying workspace files — cp is workflows-only', async () => { @@ -301,30 +369,28 @@ describe('vfs mv/cp', () => { expect(result.success).toBe(false) expect(result.error).toContain('cp only duplicates workflows') - expect(mocks.ensureWorkspaceFileFolderPath).not.toHaveBeenCalled() + expect(mocks.ensureCopilotFileFolderPath).not.toHaveBeenCalled() }) - it('moves and renames a file folder via performUpdateWorkspaceFileFolder', async () => { + it('moves and renames a file folder via the shared folder operation', async () => { mocks.findWorkspaceFileFolderIdByPath .mockResolvedValueOnce(null) // destination is not an existing folder .mockResolvedValueOnce('folder-src') // source resolves as folder - mocks.performUpdateWorkspaceFileFolder.mockResolvedValue({ - success: true, - folder: { name: 'Reports 2025' }, - }) const result = await executeVfsMv( { sources: ['files/Reports'], destination: 'files/Archive/Reports 2025' }, context ) - expect(mocks.performUpdateWorkspaceFileFolder).toHaveBeenCalledWith({ - workspaceId: 'ws-1', - folderId: 'folder-src', - userId: 'user-1', - name: 'Reports 2025', - parentId: 'ensured-folder', - }) + expect(mocks.updateWorkspaceFileFolder).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + folderId: 'folder-src', + name: 'Reports 2025', + parentId: 'ensured-folder', + }), + }) + ) expect(result.success).toBe(true) }) }) @@ -438,11 +504,10 @@ describe('vfs mv/cp', () => { it('creates a nested file folder chain', async () => { const result = await executeVfsMkdir({ paths: ['files/Reports/2026'] }, context) - expect(mocks.ensureWorkspaceFileFolderPath).toHaveBeenCalledWith({ - workspaceId: 'ws-1', - userId: 'user-1', - pathSegments: ['Reports', '2026'], - }) + expect(mocks.ensureCopilotFileFolderPath).toHaveBeenCalledWith(context, 'ws-1', [ + 'Reports', + '2026', + ]) expect(result.success).toBe(true) expect(result.output).toMatchObject({ results: [{ from: 'files/Reports/2026', to: 'files/Reports/2026', kind: 'file_folder' }], @@ -474,7 +539,7 @@ describe('vfs mv/cp', () => { expect(result.output).toMatchObject({ results: [{ from: 'tables/CRM', error: expect.stringContaining('flat namespace') }], }) - expect(mocks.ensureWorkspaceFileFolderPath).not.toHaveBeenCalled() + expect(mocks.ensureCopilotFileFolderPath).not.toHaveBeenCalled() }) it('rejects creation inside a locked workflow folder', async () => { diff --git a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts index 2fb94a95526..6c332d24ce0 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts @@ -8,11 +8,12 @@ import { messageForCopilotFileError, } from '@/lib/copilot/auth/file-delegation' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' +import { ensureWorkflowAccess, ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' import { - ensureWorkflowAccess, - ensureWorkspaceAccess, - getDefaultWorkspaceId, -} from '@/lib/copilot/tools/handlers/access' + copilotFilePrincipal, + ensureCopilotFileFolderPath, + requireCopilotWorkspace, +} from '@/lib/copilot/tools/server/files/file-folder-application' import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' import { buildVfsFolderPathMap, @@ -20,6 +21,7 @@ import { decodeVfsPathSegments, encodeVfsPathSegments, } from '@/lib/copilot/vfs/path-utils' +import { asOrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { createFolder, deleteFolder, updateFolder } from '@/lib/folders/orchestration' import { @@ -29,25 +31,18 @@ import { } from '@/lib/knowledge/service' import { performDeleteTable, performRenameTable } from '@/lib/table/orchestration' import { listTables } from '@/lib/table/service' -import { - ensureWorkspaceFileFolderPath, - findWorkspaceFileFolderIdByPath, - normalizeWorkspaceFileItemName, -} from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' -import { - getWorkspaceFileByName, - resolveWorkspaceFileReference, - type WorkspaceFileRecord, -} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { findWorkspaceFileFolderIdByPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' +import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { performDeleteWorkflow, performUpdateWorkflow } from '@/lib/workflows/orchestration' import { duplicateWorkflow } from '@/lib/workflows/persistence/duplicate' import { listFolders, verifyFolderWorkspace } from '@/lib/workflows/utils' +import { archiveWorkspaceFileItemsOperation } from '@/lib/workspace-files/application/archive-workspace-file-items' +import { deleteWorkspaceFileOperation } from '@/lib/workspace-files/application/delete-workspace-file' +import { moveWorkspaceFileItemsOperation } from '@/lib/workspace-files/application/move-workspace-file-items' +import { fileOperations } from '@/lib/workspace-files/application/operations' import { renameWorkspaceFile } from '@/lib/workspace-files/application/rename-workspace-file' -import { - performDeleteWorkspaceFileItems, - performMoveRenameWorkspaceFile, - performUpdateWorkspaceFileFolder, -} from '@/lib/workspace-files/orchestration' +import { resolveWorkspaceFileReference } from '@/lib/workspace-files/application/resolve-workspace-file-reference' +import { updateWorkspaceFileFolderOperation } from '@/lib/workspace-files/application/workspace-file-folders' import { checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' const logger = createLogger('VfsMutateTools') @@ -162,7 +157,7 @@ export async function executeVfsMkdir( return { success: false, error: 'paths is required (an array of folder VFS paths)' } } - const workspaceId = context.workspaceId || (await getDefaultWorkspaceId(context.userId)) + const workspaceId = requireCopilotWorkspace(context) await ensureWorkspaceAccess(workspaceId, context.userId, 'write') assertMutationNotAborted(context) @@ -191,11 +186,7 @@ export async function executeVfsMkdir( assertMutationNotAborted(context) let folderId: string | null if (top === 'files') { - folderId = await ensureWorkspaceFileFolderPath({ - workspaceId, - userId: context.userId, - pathSegments: segments, - }) + folderId = await ensureCopilotFileFolderPath(context, workspaceId, segments) } else { ensureWorkflowFolder ??= makeWorkflowFolderEnsurer( workspaceId, @@ -211,13 +202,25 @@ export async function executeVfsMkdir( id: folderId ?? undefined, }) } catch (error) { - outcomes.push({ from: path, kind, error: toError(error).message }) + outcomes.push({ + from: path, + kind, + error: + top === 'files' + ? messageForCopilotFileError(error, 'File folder creation failed') + : toError(error).message, + }) } } return buildResult('mkdir', outcomes) } catch (error) { - return { success: false, error: toError(error).message } + return { + success: false, + error: context.abortSignal?.aborted + ? 'Request aborted before the mutation could be applied.' + : 'Mutation failed', + } } } @@ -236,7 +239,7 @@ async function executeVfsMutate( return { success: false, error: 'destination is required' } } - const workspaceId = context.workspaceId || (await getDefaultWorkspaceId(context.userId)) + const workspaceId = requireCopilotWorkspace(context) await ensureWorkspaceAccess(workspaceId, context.userId, 'write') assertMutationNotAborted(context) @@ -272,7 +275,12 @@ async function executeVfsMutate( return await renameFlatResource(verb, category, sources, destination, context, workspaceId) } } catch (error) { - return { success: false, error: toError(error).message } + return { + success: false, + error: context.abortSignal?.aborted + ? 'Request aborted before the mutation could be applied.' + : 'Mutation failed', + } } } @@ -345,15 +353,21 @@ async function planDestination(args: { */ async function resolveFileAtExactPath( workspaceId: string, - segments: string[] + segments: string[], + context: ExecutionContext ): Promise { - const fileName = normalizeWorkspaceFileItemName(segments.at(-1) ?? '', 'File') - if (segments.length === 1) { - return getWorkspaceFileByName(workspaceId, fileName, { folderId: null }) + try { + return await resolveWorkspaceFileReference({ + principal: copilotFilePrincipal(context, workspaceId), + operation: fileOperations.move, + workspaceId, + reference: `files/${encodeVfsPathSegments(segments)}`, + }) + } catch (error) { + const classified = asOrchestrationError(error) + if (classified?.code !== 'not_found') throw error + return null } - const folderId = await findWorkspaceFileFolderIdByPath(workspaceId, segments.slice(0, -1)) - if (!folderId) return null - return getWorkspaceFileByName(workspaceId, fileName, { folderId }) } async function mutateWorkspaceFiles( @@ -373,12 +387,7 @@ async function mutateWorkspaceFiles( destination, sourceCount: sources.length, lookupFolder: (segments) => findWorkspaceFileFolderIdByPath(workspaceId, segments), - ensureFolderPath: (segments) => - ensureWorkspaceFileFolderPath({ - workspaceId, - userId: context.userId, - pathSegments: segments, - }), + ensureFolderPath: (segments) => ensureCopilotFileFolderPath(context, workspaceId, segments), }) if ('error' in dest) return { success: false, error: dest.error } @@ -395,7 +404,7 @@ async function mutateWorkspaceFiles( refs.push({ source, error: 'Source must name a file or folder under files/' }) continue } - const file = await resolveFileAtExactPath(workspaceId, segments) + const file = await resolveFileAtExactPath(workspaceId, segments, context) if (file) { refs.push({ source, file }) continue @@ -441,23 +450,37 @@ async function mutateWorkspaceFiles( } continue } - const result = await performMoveRenameWorkspaceFile({ - workspaceId, - userId: context.userId, - fileId: ref.file.id, - targetFolderId, - newName: targetName, - }) - outcomes.push( - result.success && result.file - ? { - from: ref.source, - to: `files/${encodeVfsPathSegments([...dest.folderSegments, result.file.name])}`, - kind: 'file', - id: ref.file.id, - } - : { from: ref.source, kind: 'file', error: result.error || 'Failed to move file' } - ) + try { + const principal = copilotFilePrincipal(context, workspaceId, ref.file.id) + await moveWorkspaceFileItemsOperation.execute({ + principal, + input: { workspaceId, fileIds: [ref.file.id], targetFolderId }, + }) + let finalName = ref.file.name + if (targetName !== ref.file.name) { + const renamed = await renameWorkspaceFile.execute({ + principal, + input: { + fileId: ref.file.id, + assertedWorkspaceId: workspaceId, + name: targetName, + }, + }) + finalName = renamed.file.name + } + outcomes.push({ + from: ref.source, + to: `files/${encodeVfsPathSegments([...dest.folderSegments, finalName])}`, + kind: 'file', + id: ref.file.id, + }) + } catch (error) { + outcomes.push({ + from: ref.source, + kind: 'file', + error: messageForCopilotFileError(error, 'Failed to move file'), + }) + } continue } @@ -471,23 +494,29 @@ async function mutateWorkspaceFiles( }) continue } - const result = await performUpdateWorkspaceFileFolder({ - workspaceId, - folderId: ref.folderId, - userId: context.userId, - name: dest.dirMode ? undefined : dest.leafName, - parentId: targetFolderId, - }) - outcomes.push( - result.success && result.folder - ? { - from: ref.source, - to: `files/${encodeVfsPathSegments([...dest.folderSegments, result.folder.name])}`, - kind: 'file_folder', - id: ref.folderId, - } - : { from: ref.source, kind: 'file_folder', error: result.error || 'Failed to move folder' } - ) + try { + const result = await updateWorkspaceFileFolderOperation.execute({ + principal: copilotFilePrincipal(context, workspaceId), + input: { + workspaceId, + folderId: ref.folderId, + name: dest.dirMode ? undefined : dest.leafName, + parentId: targetFolderId, + }, + }) + outcomes.push({ + from: ref.source, + to: `files/${encodeVfsPathSegments([...dest.folderSegments, result.folder.name])}`, + kind: 'file_folder', + id: ref.folderId, + }) + } catch (error) { + outcomes.push({ + from: ref.source, + kind: 'file_folder', + error: messageForCopilotFileError(error, 'Failed to move folder'), + }) + } } return buildResult(verb, outcomes) @@ -848,7 +877,7 @@ export async function executeVfsRm( return { success: false, error: 'paths is required (an array of VFS paths to delete)' } } - const workspaceId = context.workspaceId || (await getDefaultWorkspaceId(context.userId)) + const workspaceId = requireCopilotWorkspace(context) await ensureWorkspaceAccess(workspaceId, context.userId, 'write') assertMutationNotAborted(context) @@ -869,13 +898,25 @@ export async function executeVfsRm( await removeOne(classified.category, path, context, workspaceId, getWorkflowIndex) ) } catch (error) { - outcomes.push({ from: path, kind: defaultKindFor(path), error: toError(error).message }) + outcomes.push({ + from: path, + kind: defaultKindFor(path), + error: + classified.category === 'files' + ? messageForCopilotFileError(error, 'File deletion failed') + : toError(error).message, + }) } } return buildResult('rm', outcomes) } catch (error) { - return { success: false, error: toError(error).message } + return { + success: false, + error: context.abortSignal?.aborted + ? 'Request aborted before the mutation could be applied.' + : 'Delete failed', + } } } @@ -923,16 +964,23 @@ async function removeWorkspaceFilePath( context: ExecutionContext, workspaceId: string ): Promise { - const file = await resolveWorkspaceFileReference(workspaceId, path) - if (file) { - const result = await performDeleteWorkspaceFileItems({ + let file: WorkspaceFileRecord | undefined + try { + file = await resolveWorkspaceFileReference({ + principal: copilotFilePrincipal(context, workspaceId), + operation: fileOperations.delete, workspaceId, - userId: context.userId, - fileIds: [file.id], + reference: path, + }) + } catch (error) { + const classified = asOrchestrationError(error) + if (classified?.code !== 'not_found') throw error + } + if (file) { + await deleteWorkspaceFileOperation.execute({ + principal: copilotFilePrincipal(context, workspaceId, file.id), + input: { fileId: file.id, assertedWorkspaceId: workspaceId }, }) - if (!result.success) { - return { from: path, kind: 'file', id: file.id, error: result.error || 'Failed to delete' } - } logger.info('Deleted workspace file via rm', { fileId: file.id, workspaceId }) return { from: path, kind: 'file', id: file.id } } @@ -944,21 +992,21 @@ async function removeWorkspaceFilePath( const folderId = await findWorkspaceFileFolderIdByPath(workspaceId, segments) if (!folderId) return { from: path, kind: 'file', error: `Not found: ${path}` } - const result = await performDeleteWorkspaceFileItems({ - workspaceId, - userId: context.userId, - folderIds: [folderId], - }) - if (!result.success) { + try { + const result = await archiveWorkspaceFileItemsOperation.execute({ + principal: copilotFilePrincipal(context, workspaceId), + input: { workspaceId, folderIds: [folderId] }, + }) + logger.info('Deleted file folder via rm', { folderId, workspaceId }) + return { from: path, kind: 'file_folder', id: folderId } + } catch (error) { return { from: path, kind: 'file_folder', id: folderId, - error: result.error || 'Failed to delete', + error: messageForCopilotFileError(error, 'Failed to delete'), } } - logger.info('Deleted file folder via rm', { folderId, workspaceId }) - return { from: path, kind: 'file_folder', id: folderId } } interface WorkflowRemoveIndex { diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.ts b/apps/sim/lib/copilot/tools/handlers/vfs.ts index 8593b714946..4fff1d8d4e7 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs.ts @@ -1,9 +1,9 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import { createCopilotFilePrincipal } from '@/lib/copilot/auth/file-delegation' import { getBlockVisibilityForCopilot } from '@/lib/copilot/block-visibility' import { TOOL_RESULT_MAX_INLINE_CHARS } from '@/lib/copilot/constants' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' -import type { SecretMountPolicy } from '@/lib/copilot/secret-mount-policy' import { getOrMaterializeVFS } from '@/lib/copilot/vfs' import type { GrepCountEntry, GrepMatch } from '@/lib/copilot/vfs/operations' import { WorkspaceFileGrepError } from '@/lib/copilot/vfs/operations' @@ -27,14 +27,19 @@ const logger = createLogger('VfsTools') * viewer (unrevealed previews, kill-switched types). Visibility is memoized per * (userId, workspaceId), so repeated tool calls in one turn resolve once. */ -async function getGatedVFS( - workspaceId: string, - userId: string, - secretMountPolicy?: SecretMountPolicy -) { - const vis = await getBlockVisibilityForCopilot(userId, workspaceId) +async function getGatedVFS(context: ExecutionContext) { + const workspaceId = context.workspaceId + if (!workspaceId) throw new Error('No workspace context available') + const vis = await getBlockVisibilityForCopilot(context.userId, workspaceId) + const filePrincipal = + context.copilotToolExecution && context.toolCallId + ? createCopilotFilePrincipal(context, workspaceId) + : undefined return withBlockVisibility(vis, () => - getOrMaterializeVFS(workspaceId, userId, { secretMountPolicy }) + getOrMaterializeVFS(workspaceId, context.userId, { + secretMountPolicy: context.secretMountPolicy, + filePrincipal, + }) ) } @@ -170,7 +175,7 @@ export async function executeVfsGrep( result = envelope.value provenanceFile = envelope.file } else { - const vfs = await getGatedVFS(workspaceId, context.userId, context.secretMountPolicy) + const vfs = await getGatedVFS(context) if (isWorkspaceFileGrepPath(rawPath)) { const envelope = await vfs.grepFileWithProvenance(rawPath, pattern, grepOptions) result = envelope.value @@ -238,7 +243,7 @@ export async function executeVfsGlob( } try { - const vfs = await getGatedVFS(workspaceId, context.userId, context.secretMountPolicy) + const vfs = await getGatedVFS(context) let files = vfs.glob(pattern) if (context.chatId && (pattern === 'uploads/*' || pattern.startsWith('uploads/'))) { @@ -354,7 +359,7 @@ export async function executeVfsRead( } } - const vfs = await getGatedVFS(workspaceId, context.userId, context.secretMountPolicy) + const vfs = await getGatedVFS(context) // Plain canonical file leaves are metadata resources. Dynamic file content // and inspection paths use explicit suffixes like /content, /style, diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts b/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts index 41e1de535c3..d8e4a6d7b04 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts @@ -1,9 +1,9 @@ import { toError } from '@sim/utils/errors' import { mergeSubblockStateWithValues } from '@sim/workflow-persistence/subblocks' +import { createCopilotFilePrincipal } from '@/lib/copilot/auth/file-delegation' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { formatNormalizedWorkflowForCopilot } from '@/lib/copilot/tools/shared/workflow-utils' import { mcpService } from '@/lib/mcp/service' -import { listWorkspaceFiles } from '@/lib/uploads/contexts/workspace' import { getEffectiveBlockOutputPaths } from '@/lib/workflows/blocks/block-outputs' import { BlockPathCalculator } from '@/lib/workflows/blocks/block-path-calculator' import { getBlockReferenceTags } from '@/lib/workflows/blocks/block-reference-tags' @@ -16,6 +16,7 @@ import { import { resolveTriggerRunOptions, toPublicRunOption } from '@/lib/workflows/triggers/run-options' import { hasTriggerCapability } from '@/lib/workflows/triggers/trigger-utils' import { getWorkflowById } from '@/lib/workflows/utils' +import { listAllWorkspaceFiles } from '@/lib/workspace-files/application/list-workspace-files' import { listUserWorkspaces } from '@/lib/workspaces/utils' import { getBlock } from '@/blocks/registry' import { normalizeName } from '@/executor/constants' @@ -191,7 +192,10 @@ export async function executeGetWorkflowData( if (!workspaceId) { return { success: false, error: 'workspaceId is required' } } - const files = await listWorkspaceFiles(workspaceId) + const { files } = await listAllWorkspaceFiles.execute({ + principal: createCopilotFilePrincipal(context, workspaceId), + input: { workspaceId, scope: 'active' }, + }) const fileResults = files.map((file) => ({ id: String(file.id || ''), name: String(file.name || ''), diff --git a/apps/sim/lib/copilot/tools/server/files/create-file.ts b/apps/sim/lib/copilot/tools/server/files/create-file.ts index cd950eb3406..68ff4023970 100644 --- a/apps/sim/lib/copilot/tools/server/files/create-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/create-file.ts @@ -1,12 +1,18 @@ import { createLogger } from '@sim/logger' -import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' +import { + createCopilotFilePrincipal, + messageForCopilotFileError, +} from '@/lib/copilot/auth/file-delegation' import { assertServerToolNotAborted, type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' -import { writeWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer' -import { inferContentType } from './workspace-file' +import { inferContentType } from '@/lib/copilot/tools/server/files/workspace-file' +import { + createWorkspaceFileByPath, + updateWorkspaceFileContentByPath, +} from '@/lib/workspace-files/application/write-workspace-file-by-path' const logger = createLogger('CreateFileServerTool') const CREATE_FILE_TOOL_ID = 'create_file' @@ -39,8 +45,6 @@ export const createFileServerTool: BaseServerTool { return ids } -async function stageReferencedImages(source: string, workspaceId: string): Promise { +async function stageReferencedImages( + source: string, + workspaceId: string, + principal: Principal +): Promise { const ids = collectReferencedFileIds(source) if (ids.size > MAX_STAGED_INPUTS) { throw new Error( @@ -150,9 +153,13 @@ async function stageReferencedImages(source: string, workspaceId: string): Promi const files: SandboxFile[] = [] let totalBytes = 0 for (const fileId of ids) { - let record: Awaited> + let record: Awaited>['file'] try { - record = await getWorkspaceFile(workspaceId, fileId) + const metadata = await readWorkspaceFileMetadata.execute({ + principal, + input: { fileId, assertedWorkspaceId: workspaceId }, + }) + record = metadata.file } catch (err) { logger.warn('Failed to resolve referenced image for doc compile', { workspaceId, @@ -177,7 +184,15 @@ async function stageReferencedImages(source: string, workspaceId: string): Promi } let buffer: Buffer try { - buffer = await fetchWorkspaceFileBuffer(record) + const content = await readWorkspaceFileContent.execute({ + principal, + input: { + fileId: record.id, + assertedWorkspaceId: workspaceId, + maxBytes: MAX_STAGED_FILE_BYTES, + }, + }) + buffer = content.content } catch (err) { logger.warn('Failed to stage referenced image for doc compile', { workspaceId, @@ -241,6 +256,7 @@ interface CompileArgs { source: string fileName: string workspaceId: string + filePrincipal: Principal } /** @@ -250,10 +266,10 @@ interface CompileArgs { * Internal — callers use compileDoc (load-or-build + store). */ async function compileDocViaE2BPython( - { source, workspaceId }: CompileArgs, + { source, workspaceId, filePrincipal }: CompileArgs, fmt: E2BDocFormat ): Promise { - const sandboxFiles = await stageReferencedImages(source, workspaceId) + const sandboxFiles = await stageReferencedImages(source, workspaceId, filePrincipal) const outputSandboxPath = `/home/user/output.${fmt.ext}` // openpyxl writes formula strings but no cached values, so a web viewer (SheetJS) @@ -331,10 +347,10 @@ fs.writeFileSync('/home/user/output.docx', __buf); * engines. Throws DocCompileUserError on a script error. */ async function compileDocViaE2BNode( - { source, fileName, workspaceId }: CompileArgs, + { source, fileName, workspaceId, filePrincipal }: CompileArgs, ext: 'pptx' | 'docx' ): Promise { - const sandboxFiles = await stageReferencedImages(source, workspaceId) + const sandboxFiles = await stageReferencedImages(source, workspaceId, filePrincipal) const outputSandboxPath = `/home/user/output.${ext}` const preamble = ext === 'pptx' ? PPTX_NODE_PREAMBLE : DOCX_NODE_PREAMBLE const finalize = ext === 'pptx' ? PPTX_NODE_FINALIZE : DOCX_NODE_FINALIZE @@ -394,7 +410,7 @@ ${finalize} export async function compileDoc( args: CompileArgs ): Promise<{ buffer: Buffer; contentType: string }> { - const { source, fileName, workspaceId } = args + const { source, fileName, workspaceId, filePrincipal } = args const fmt = await getE2BDocFormat(fileName) if (!fmt) throw new Error(`Unsupported document format: ${fileName}`) @@ -403,8 +419,11 @@ export async function compileDoc( const buffer = fmt.engine === 'node' - ? await compileDocViaE2BNode({ source, fileName, workspaceId }, fmt.ext as 'pptx' | 'docx') - : await compileDocViaE2BPython({ source, fileName, workspaceId }, fmt) + ? await compileDocViaE2BNode( + { source, fileName, workspaceId, filePrincipal }, + fmt.ext as 'pptx' | 'docx' + ) + : await compileDocViaE2BPython({ source, fileName, workspaceId, filePrincipal }, fmt) await storeCompiledDoc(workspaceId, source, fmt.ext, fmt.contentType, buffer) return { buffer, contentType: fmt.contentType } } diff --git a/apps/sim/lib/copilot/tools/server/files/doc-recalc.ts b/apps/sim/lib/copilot/tools/server/files/doc-recalc.ts index c85b9249110..08dc72e16d4 100644 --- a/apps/sim/lib/copilot/tools/server/files/doc-recalc.ts +++ b/apps/sim/lib/copilot/tools/server/files/doc-recalc.ts @@ -1,3 +1,4 @@ +import type { Principal } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { CodeLanguage } from '@/lib/execution/languages' import { executeInSandbox } from '@/lib/execution/remote-sandbox' @@ -101,12 +102,14 @@ export async function runE2BCompiledCheck(args: { fileName: string workspaceId: string ext: string + principal: Principal }): Promise { try { const compiled = await compileDoc({ source: args.source, fileName: args.fileName, workspaceId: args.workspaceId, + filePrincipal: args.principal, }) if (args.ext === 'xlsx') { const recalc = await recalcXlsx({ binary: compiled.buffer, workspaceId: args.workspaceId }) diff --git a/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts b/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts index d5ec0650171..6d15a7bcd6c 100644 --- a/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts @@ -1,20 +1,26 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import { z } from 'zod' +import { + createCopilotFilePrincipal, + messageForCopilotFileError, +} from '@/lib/copilot/auth/file-delegation' import { DownloadToWorkspaceFile } from '@/lib/copilot/generated/tool-catalog-v1' -import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' import { assertServerToolNotAborted, type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' -import { writeWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer' import { secureFetchWithValidation } from '@/lib/core/security/input-validation.server' import { getExtensionFromMimeType, getFileExtension, getMimeTypeFromExtension, } from '@/lib/uploads/utils/file-utils' +import { + createWorkspaceFileByPath, + updateWorkspaceFileContentByPath, +} from '@/lib/workspace-files/application/write-workspace-file-by-path' const logger = createLogger('DownloadToWorkspaceFileTool') @@ -152,8 +158,6 @@ export const downloadToWorkspaceFileServerTool: BaseServerTool< if (!workspaceId) { return { success: false, message: 'Workspace ID is required' } } - await ensureWorkspaceAccess(workspaceId, context.userId, 'write') - try { assertServerToolNotAborted(context) @@ -189,17 +193,20 @@ export const downloadToWorkspaceFileServerTool: BaseServerTool< } assertServerToolNotAborted(context) - const written = await writeWorkspaceFileByPath({ + const principal = createCopilotFilePrincipal(context, workspaceId) + const mode = outputFile?.mode ?? 'create' + const writeInput = { workspaceId, - userId: context.userId, - target: { - path: outputPath, - mode: outputFile?.mode ?? 'create', - mimeType: outputFile?.mimeType, - }, - buffer: fileBuffer, - inferredMimeType: outputFile?.mimeType ?? mimeType, - }) + path: outputPath, + mode, + content: fileBuffer.toString('base64'), + encoding: 'base64' as const, + contentType: outputFile?.mimeType ?? mimeType, + } + const written = + mode === 'overwrite' + ? await updateWorkspaceFileContentByPath.execute({ principal, input: writeInput }) + : await createWorkspaceFileByPath.execute({ principal, input: writeInput }) logger.info('Downloaded remote file to workspace', { sourceUrl: params.url, @@ -224,7 +231,10 @@ export const downloadToWorkspaceFileServerTool: BaseServerTool< url: params.url, error: msg, }) - return { success: false, message: `Failed to download file: ${msg}` } + return { + success: false, + message: `Failed to download file: ${messageForCopilotFileError(error, 'Unable to write downloaded file')}`, + } } }, } diff --git a/apps/sim/lib/copilot/tools/server/files/edit-content.ts b/apps/sim/lib/copilot/tools/server/files/edit-content.ts index c09a39beb9d..a502e4ea906 100644 --- a/apps/sim/lib/copilot/tools/server/files/edit-content.ts +++ b/apps/sim/lib/copilot/tools/server/files/edit-content.ts @@ -1,12 +1,16 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { + createCopilotFilePrincipal, + messageForCopilotFileError, +} from '@/lib/copilot/auth/file-delegation' import { assertServerToolNotAborted, type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' import { isDocSandboxEnabled } from '@/lib/core/config/env-flags' -import { updateWorkspaceFileContent } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { updateWorkspaceFileContent } from '@/lib/workspace-files/application/update-workspace-file-content' import { getE2BDocFormat } from './doc-compile' import { buildEmbeddedImageRefWarning } from './embedded-image-refs' import { consumeLatestFileIntent } from './file-intent-store' @@ -219,10 +223,12 @@ export const editContentServerTool: BaseServerTool { + let parentId: string | null = null + for (const [index, name] of pathSegments.entries()) { + const existing = await findWorkspaceFileFolderIdByPath( + workspaceId, + pathSegments.slice(0, index + 1) + ) + if (existing) { + parentId = existing + continue + } + const result = await createWorkspaceFileFolderOperation.execute({ + principal: copilotFilePrincipal(context, workspaceId), + input: { workspaceId, name, parentId }, + }) + parentId = result.folder.id + } + return parentId +} diff --git a/apps/sim/lib/copilot/tools/server/files/file-folders.ts b/apps/sim/lib/copilot/tools/server/files/file-folders.ts index 22c26d4b7b3..1fe5fbb5569 100644 --- a/apps/sim/lib/copilot/tools/server/files/file-folders.ts +++ b/apps/sim/lib/copilot/tools/server/files/file-folders.ts @@ -1,25 +1,30 @@ import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' -import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' +import { messageForCopilotFileError } from '@/lib/copilot/auth/file-delegation' import { assertServerToolNotAborted, type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' +import { + copilotFilePrincipal, + ensureCopilotFileFolderPath, + requireCopilotWorkspace, +} from '@/lib/copilot/tools/server/files/file-folder-application' import { decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' +import { asOrchestrationError } from '@/lib/core/orchestration/types' import { - ensureWorkspaceFileFolderPath, findWorkspaceFileFolderIdByPath, getWorkspaceFileFolder, - listWorkspaceFileFolders, type WorkspaceFileFolderRecord, } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' -import { resolveWorkspaceFileReference } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { moveWorkspaceFileItemsOperation } from '@/lib/workspace-files/application/move-workspace-file-items' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { resolveWorkspaceFileReference } from '@/lib/workspace-files/application/resolve-workspace-file-reference' import { - performCreateWorkspaceFileFolder, - performMoveWorkspaceFileItems, - performUpdateWorkspaceFileFolder, -} from '@/lib/workspace-files/orchestration' + createWorkspaceFileFolderOperation, + listWorkspaceFileFoldersOperation, + updateWorkspaceFileFolderOperation, +} from '@/lib/workspace-files/application/workspace-file-folders' const logger = createLogger('FileFolderServerTools') @@ -134,41 +139,46 @@ async function resolveOptionalFolderId( async function resolveFileIdsFromPaths( workspaceId: string, - paths: string[] + paths: string[], + context: ServerToolContext ): Promise<{ fileIds: string[] failed: string[] }> { const fileIds: string[] = [] const failed: string[] = [] + const principal = copilotFilePrincipal(context, workspaceId) for (const path of paths) { - const file = await resolveWorkspaceFileReference(workspaceId, path) - if (!file) { + try { + const file = await resolveWorkspaceFileReference({ + principal, + operation: fileOperations.move, + workspaceId, + reference: path, + }) + fileIds.push(file.id) + } catch (error) { + const classified = asOrchestrationError(error) + if (classified?.code !== 'not_found') throw error failed.push(path) - continue } - fileIds.push(file.id) } return { fileIds, failed } } async function resolveWorkspaceId( params: WorkspaceScopedArgs, - context: ServerToolContext | undefined, - permission: 'read' | 'write' + context: ServerToolContext | undefined ): Promise { if (!context?.userId) { throw new Error('Authentication required') } const payload = nested(params) - const workspaceId = - stringValue(params.workspaceId) || stringValue(payload?.workspaceId) || context.workspaceId - if (!workspaceId) { - return { success: false, message: 'Workspace ID is required' } - } + const assertedWorkspaceId = + stringValue(params.workspaceId) || stringValue(payload?.workspaceId) || undefined + const workspaceId = requireCopilotWorkspace(context, assertedWorkspaceId) - await ensureWorkspaceAccess(workspaceId, context.userId, permission) return workspaceId } @@ -183,10 +193,14 @@ export const listFileFoldersServerTool: BaseServerTool { try { - const workspaceId = await resolveWorkspaceId(params, context, 'read') + const workspaceId = await resolveWorkspaceId(params, context) if (typeof workspaceId !== 'string') return workspaceId - const folders = await listWorkspaceFileFolders(workspaceId) + const result = await listWorkspaceFileFoldersOperation.execute({ + principal: copilotFilePrincipal(context, workspaceId), + input: { workspaceId }, + }) + const folders = result.folders return { success: true, message: @@ -194,7 +208,10 @@ export const listFileFoldersServerTool: BaseServerTool { try { - const workspaceId = await resolveWorkspaceId(params, context, 'write') + const workspaceId = await resolveWorkspaceId(params, context) if (typeof workspaceId !== 'string') return workspaceId if (!context?.userId) throw new Error('Authentication required') @@ -226,23 +243,18 @@ export const createFileFolderServerTool: BaseServerTool 1) { - parentId = await ensureWorkspaceFileFolderPath({ + parentId = await ensureCopilotFileFolderPath( + context, workspaceId, - userId: context.userId, - pathSegments: pathSegments.slice(0, -1), - }) + pathSegments.slice(0, -1) + ) } assertServerToolNotAborted(context) - const result = await performCreateWorkspaceFileFolder({ - workspaceId, - userId: context.userId, - name, - parentId, + const result = await createWorkspaceFileFolderOperation.execute({ + principal: copilotFilePrincipal(context, workspaceId), + input: { workspaceId, name, parentId }, }) - if (!result.success || !result.folder) { - return { success: false, message: result.error || 'Failed to create file folder' } - } const { folder } = result logger.info('File folder created via create_file_folder', { @@ -258,7 +270,10 @@ export const createFileFolderServerTool: BaseServerTool { try { - const workspaceId = await resolveWorkspaceId(params, context, 'write') + const workspaceId = await resolveWorkspaceId(params, context) if (typeof workspaceId !== 'string') return workspaceId if (!context?.userId) throw new Error('Authentication required') @@ -289,15 +304,10 @@ export const renameFileFolderServerTool: BaseServerTool { try { - const workspaceId = await resolveWorkspaceId(params, context, 'write') + const workspaceId = await resolveWorkspaceId(params, context) if (typeof workspaceId !== 'string') return workspaceId if (!context?.userId) throw new Error('Authentication required') @@ -347,15 +360,10 @@ export const moveFileFolderServerTool: BaseServerTool name: 'move_file', async execute(params: MoveFileArgs, context?: ServerToolContext): Promise { try { - const workspaceId = await resolveWorkspaceId(params, context, 'write') + const workspaceId = await resolveWorkspaceId(params, context) if (typeof workspaceId !== 'string') return workspaceId if (!context?.userId) throw new Error('Authentication required') const payload = nested(params) const paths = stringListFromValues(params.paths, payload?.paths, params.path, payload?.path) const resolvedByPath = - paths.length > 0 ? await resolveFileIdsFromPaths(workspaceId, paths) : undefined + paths.length > 0 ? await resolveFileIdsFromPaths(workspaceId, paths, context) : undefined if (resolvedByPath?.failed.length) { return { success: false, @@ -412,15 +423,10 @@ export const moveFileServerTool: BaseServerTool null assertServerToolNotAborted(context) - const result = await performMoveWorkspaceFileItems({ - workspaceId, - userId: context.userId, - fileIds, - targetFolderId: folderId, + const result = await moveWorkspaceFileItemsOperation.execute({ + principal: copilotFilePrincipal(context, workspaceId), + input: { workspaceId, fileIds, targetFolderId: folderId }, }) - if (!result.success || !result.movedItems) { - return { success: false, message: result.error || 'Failed to move files' } - } logger.info('Files moved via move_file', { workspaceId, @@ -438,7 +444,7 @@ export const moveFileServerTool: BaseServerTool data: result.movedItems, } } catch (error) { - return { success: false, message: toError(error).message } + return { success: false, message: messageForCopilotFileError(error, 'Failed to move files') } } }, } diff --git a/apps/sim/lib/copilot/tools/server/files/file-preview.ts b/apps/sim/lib/copilot/tools/server/files/file-preview.ts index 11b09b61c3a..64ae2f03079 100644 --- a/apps/sim/lib/copilot/tools/server/files/file-preview.ts +++ b/apps/sim/lib/copilot/tools/server/files/file-preview.ts @@ -1,11 +1,11 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { - fetchWorkspaceFileBuffer, - getWorkspaceFile, -} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { createCopilotFilePrincipal } from '@/lib/copilot/auth/file-delegation' +import type { ExecutionContext } from '@/lib/copilot/request/types' +import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' const logger = createLogger('CopilotFilePreview') +const MAX_PREVIEW_SOURCE_BYTES = 5 * 1024 * 1024 type FilePreviewEdit = { strategy?: string @@ -147,13 +147,19 @@ export interface WorkspaceFilePreviewBase { } export async function loadWorkspaceFileTextForPreview( + context: ExecutionContext, workspaceId: string, fileId: string ): Promise { try { - const record = await getWorkspaceFile(workspaceId, fileId) - if (!record) return undefined - const buffer = await fetchWorkspaceFileBuffer(record) + const { content: buffer } = await readWorkspaceFileContent.execute({ + principal: createCopilotFilePrincipal(context, workspaceId, fileId), + input: { + fileId, + assertedWorkspaceId: workspaceId, + maxBytes: MAX_PREVIEW_SOURCE_BYTES, + }, + }) return { text: buffer.toString('utf-8'), } diff --git a/apps/sim/lib/copilot/tools/server/files/rename-file.ts b/apps/sim/lib/copilot/tools/server/files/rename-file.ts index bdb2f9441dc..b141aec6bf0 100644 --- a/apps/sim/lib/copilot/tools/server/files/rename-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/rename-file.ts @@ -8,11 +8,11 @@ import { type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' -import { - getWorkspaceFile, - resolveWorkspaceFileReference, -} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { asOrchestrationError } from '@/lib/core/orchestration/types' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' import { renameWorkspaceFile } from '@/lib/workspace-files/application/rename-workspace-file' +import { resolveWorkspaceFileReference } from '@/lib/workspace-files/application/resolve-workspace-file-reference' import { validateFlatWorkspaceFileName } from './workspace-file' const logger = createLogger('RenameFileServerTool') @@ -59,9 +59,27 @@ export const renameFileServerTool: BaseServerTool if (!workspaceId) { return { success: false, message: 'Workspace ID is required' } } - await ensureWorkspaceAccess(workspaceId, context.userId, 'write') - const nested = params.args const path = params.path || (nested?.path as string) || '' const legacyFileId = params.fileId || (nested?.fileId as string) || '' @@ -72,86 +67,80 @@ export const shareFileServerTool: BaseServerTool const targetRef = path || legacyFileId if (!targetRef) return { success: false, message: 'path is required' } - const existingFile = path - ? await resolveWorkspaceFileReference(workspaceId, path) - : await getWorkspaceFile(workspaceId, legacyFileId) - if (!existingFile) { + const principal = createCopilotFilePrincipal(context, workspaceId) + let existingFile + try { + existingFile = path + ? await resolveWorkspaceFileReference({ + principal, + operation: fileOperations.updateShare, + workspaceId, + reference: path, + }) + : ( + await readWorkspaceFileMetadata.execute({ + principal, + input: { fileId: legacyFileId, assertedWorkspaceId: workspaceId }, + }) + ).file + } catch (error) { + const classified = asOrchestrationError(error) + if (classified?.code !== 'not_found') throw error return { success: false, message: `File not found: ${targetRef}` } } - const fileId = existingFile.id - const isActive = action !== 'unshare' - const existingShare = await getShareForResource('file', fileId) - - // Unsharing a file that was never shared (or is already disabled) is a no-op: - // never insert an inactive row, emit a FILE_SHARE_DISABLED audit, or return a - // link claiming a share was revoked when none existed. - if (!isActive && !existingShare?.isActive) { - return { - success: true, - message: `"${existingFile.name}" isn't shared — nothing to unshare.`, - } - } - - // Enabling a share is gated by the org's access-control policy (both the - // master on/off and the per-auth-type allow-list); disabling is always - // allowed so users can still un-share after the policy is turned on. - if (isActive) { - // Validate the auth type that will ACTUALLY be persisted. upsertFileShare - // falls back to the existing share's authType when none is passed, so a bare - // re-enable must be checked against that stored mode — not 'public' — or a - // now-disallowed password/email/sso share could be silently reactivated. - const effectiveAuthType = authType ?? existingShare?.authType ?? 'public' - try { - await validatePublicFileSharing(context.userId, workspaceId, effectiveAuthType) - } catch (error) { - if (error instanceof PublicFileSharingNotAllowedError) { - return { success: false, message: error.message } - } - throw error - } + if (!existingFile) { + return { success: false, message: `File not found: ${targetRef}` } } - assertServerToolNotAborted(context) - - let share + const principalWithFile = createCopilotFilePrincipal(context, workspaceId, existingFile.id) + const isActive = action !== 'unshare' try { - share = await upsertFileShare({ + const result = await updateWorkspaceFileShare.execute({ + principal: principalWithFile, + input: { + fileId: existingFile.id, + assertedWorkspaceId: workspaceId, + isActive, + authType, + password, + allowedEmails, + noOpIfInactive: !isActive, + }, + }) + const share = result.share + logger.info(`${isActive ? 'Enabled' : 'Disabled'} share for file via share_file`, { + fileId: existingFile.id, workspaceId, - fileId, + authType: share.authType, userId: context.userId, - isActive, - authType, - password, - allowedEmails, }) - } catch (error) { - if (error instanceof ShareValidationError) { - return { success: false, message: error.message } - } - throw error - } - logger.info(`${isActive ? 'Enabled' : 'Disabled'} share for file via share_file`, { - fileId, - workspaceId, - authType: share.authType, - userId: context.userId, - }) + if (!isActive) { + return { + success: true, + message: `Stopped sharing "${existingFile.name}". The previous link no longer works.`, + data: { + url: share.url, + token: share.token, + authType: share.authType, + hasPassword: share.hasPassword, + isActive: share.isActive, + }, + } + } - recordAudit({ - workspaceId, - actorId: context.userId, - action: isActive ? AuditAction.FILE_SHARED : AuditAction.FILE_SHARE_DISABLED, - resourceType: AuditResourceType.FILE, - resourceId: fileId, - resourceName: existingFile.name, - description: `${isActive ? 'Enabled' : 'Disabled'} public share for "${existingFile.name}"`, - }) + const authNote = + share.authType === 'password' + ? ' (password-protected — share the password separately)' + : share.authType === 'email' + ? ' (restricted to allowed emails via one-time code)' + : share.authType === 'sso' + ? ' (restricted to allowed emails via SSO)' + : '' - if (!isActive) { return { success: true, - message: `Stopped sharing "${existingFile.name}". The previous link no longer works.`, + message: `Shared "${existingFile.name}"${authNote}: ${share.url}`, data: { url: share.url, token: share.token, @@ -160,27 +149,17 @@ export const shareFileServerTool: BaseServerTool isActive: share.isActive, }, } - } - - const authNote = - share.authType === 'password' - ? ' (password-protected — share the password separately)' - : share.authType === 'email' - ? ' (restricted to allowed emails via one-time code)' - : share.authType === 'sso' - ? ' (restricted to allowed emails via SSO)' - : '' - - return { - success: true, - message: `Shared "${existingFile.name}"${authNote}: ${share.url}`, - data: { - url: share.url, - token: share.token, - authType: share.authType, - hasPassword: share.hasPassword, - isActive: share.isActive, - }, + } catch (error) { + if (error instanceof WorkspaceFileShareNoopError) { + return { + success: true, + message: `"${existingFile.name}" isn't shared — nothing to unshare.`, + } + } + return { + success: false, + message: messageForCopilotFileError(error, 'Unable to update file sharing'), + } } }, } diff --git a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts index 27014b7ad0a..3e5544e492d 100644 --- a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts @@ -1,3 +1,4 @@ +import type { Principal } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import { truncate } from '@sim/utils/string' @@ -6,26 +7,22 @@ import { messageForCopilotFileError, } from '@/lib/copilot/auth/file-delegation' import { WorkspaceFile } from '@/lib/copilot/generated/tool-catalog-v1' -import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' import { assertServerToolNotAborted, type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' import { isDocSandboxEnabled } from '@/lib/core/config/env-flags' +import { asOrchestrationError } from '@/lib/core/orchestration/types' import { runSandboxTask } from '@/lib/execution/sandbox/run-task' -import { ensureWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' -import { - fetchWorkspaceFileBuffer as downloadWsFile, - getWorkspaceFile, - getWorkspaceFileByName, - resolveWorkspaceFileReference, - uploadWorkspaceFile, - type WorkspaceFileRecord, -} from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' +import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { createWorkspaceFile } from '@/lib/workspace-files/application/create-workspace-file' +import { deleteWorkspaceFileOperation } from '@/lib/workspace-files/application/delete-workspace-file' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' +import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' import { renameWorkspaceFile } from '@/lib/workspace-files/application/rename-workspace-file' -import { performDeleteWorkspaceFileItems } from '@/lib/workspace-files/orchestration' +import { resolveWorkspaceFileReference } from '@/lib/workspace-files/application/resolve-workspace-file-reference' import type { SandboxTaskId } from '@/sandbox-tasks/registry' import { compileDoc, @@ -35,6 +32,7 @@ import { PPTXGENJS_SOURCE_MIME, } from './doc-compile' import { buildEmbeddedImageRefWarning } from './embedded-image-refs' +import { ensureCopilotFileFolderPath } from './file-folder-application' import { storeFileIntent } from './file-intent-store' const logger = createLogger('WorkspaceFileServerTool') @@ -42,7 +40,7 @@ const logger = createLogger('WorkspaceFileServerTool') const PPTX_MIME = 'application/vnd.openxmlformats-officedocument.presentationml.presentation' const DOCX_MIME = 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' const PDF_MIME = 'application/pdf' -// Single source of the JS source MIMEs is doc-compile.ts; reuse to avoid drift. +/** Document source MIME aliases stay anchored to the compiler definitions. */ const PPTX_SOURCE_MIME = PPTXGENJS_SOURCE_MIME const DOCX_SOURCE_MIME = DOCXJS_SOURCE_MIME const PDF_SOURCE_MIME = 'text/x-pdflibjs' @@ -198,11 +196,12 @@ export async function compileDocForWrite(args: { source: string fileName: string workspaceId: string + principal: Principal ownerKey: string signal?: AbortSignal fallbackMime: string }): Promise { - const { source, fileName, workspaceId, ownerKey, signal, fallbackMime } = args + const { source, fileName, workspaceId, principal, ownerKey, signal, fallbackMime } = args const docInfo = getDocumentFormatInfo(fileName) const e2bFmt = isDocSandboxEnabled ? await getE2BDocFormat(fileName) : null @@ -218,7 +217,7 @@ export async function compileDocForWrite(args: { // compileDoc is load-or-build, so an identical re-write reuses the cached // binary instead of re-running E2B. try { - await compileDoc({ source, fileName, workspaceId }) + await compileDoc({ source, fileName, workspaceId, filePrincipal: principal }) } catch (err) { if (err instanceof DocCompileUserError) { return { @@ -282,6 +281,11 @@ export const workspaceFileServerTool: BaseServerTool> + try { + result = await createWorkspaceFile.execute({ + principal, + input: { + workspaceId, + name: fileName, + contentType, + content, + encoding: 'utf-8', + folderId, + exactName: false, + }, + }) + } catch (error) { + return { + success: false, + message: messageForCopilotFileError(error, 'Failed to create file'), + } + } logger.info('Workspace file created via copilot', { - fileId: result.id, + fileId: result.file.id, name: fileName, size: fileBuffer.length, contentType, @@ -382,11 +417,11 @@ export const workspaceFileServerTool: BaseServerTool ({ vi.mock('@/lib/knowledge/secret-provenance', () => ({ importKnowledgeSearchResultSecretProvenance: mockImportKnowledgeSearchResultSecretProvenance, })) +vi.mock('@/lib/knowledge/documents/service', () => ({ + createSingleDocument: vi.fn(), +})) vi.mock('@/lib/knowledge/tags/service', () => ({ createTagDefinition: vi.fn(), deleteTagDefinition: vi.fn(), @@ -72,7 +75,7 @@ vi.mock('@/lib/knowledge/tags/service', () => ({ updateTagDefinition: vi.fn(), })) vi.mock('@/lib/uploads', () => ({ StorageService: {} })) -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ +vi.mock('@/lib/workspace-files/application/resolve-workspace-file-reference', () => ({ resolveWorkspaceFileReference: vi.fn(), })) vi.mock('@/app/api/auth/oauth/utils', () => ({ getCredential: vi.fn() })) @@ -95,7 +98,7 @@ import { createSingleDocument } from '@/lib/knowledge/documents/service' import { generateSearchEmbedding, recordSearchEmbeddingUsage } from '@/lib/knowledge/embeddings' import { executeKnowledgeSearch } from '@/lib/knowledge/search/queries' import { getKnowledgeBaseById } from '@/lib/knowledge/service' -import { resolveWorkspaceFileReference } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { resolveWorkspaceFileReference } from '@/lib/workspace-files/application/resolve-workspace-file-reference' import { checkKnowledgeBaseAccess } from '@/app/api/knowledge/utils' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' @@ -294,6 +297,8 @@ describe('knowledge base query model boundary', () => { { userId: 'external-admin', workspaceId: 'workspace-paid', + toolCallId: 'tool-1', + copilotToolExecution: true, billingAttribution: BILLING_ATTRIBUTION, resolvedSecretTraceRegistry: registry, } @@ -358,6 +363,8 @@ describe('knowledge base query model boundary', () => { { userId: 'external-admin', workspaceId: 'workspace-paid', + toolCallId: 'tool-1', + copilotToolExecution: true, billingAttribution: BILLING_ATTRIBUTION, resolvedSecretTraceRegistry: registry, } @@ -400,6 +407,8 @@ describe('knowledge base query model boundary', () => { { userId: 'external-admin', workspaceId: 'workspace-paid', + toolCallId: 'tool-1', + copilotToolExecution: true, billingAttribution: BILLING_ATTRIBUTION, resolvedSecretTraceRegistry: registry, } @@ -440,6 +449,8 @@ describe('knowledge base add_file usage gate', () => { { userId: 'external-admin', workspaceId: 'workspace-paid', + toolCallId: 'tool-1', + copilotToolExecution: true, billingAttribution: BILLING_ATTRIBUTION, } ) diff --git a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts index e7271c7e5af..d7b2b9a3d87 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts @@ -12,6 +12,7 @@ import { type BillingAttributionSnapshot, checkAttributedUsageLimits, } from '@/lib/billing/core/billing-attribution' +import { createCopilotFilePrincipal } from '@/lib/copilot/auth/file-delegation' import { KnowledgeBase } from '@/lib/copilot/generated/tool-catalog-v1' import { projectToolErrorMessageForCopilot } from '@/lib/copilot/request/tools/resolved-secret-result' import { @@ -50,8 +51,9 @@ import { updateTagDefinition, } from '@/lib/knowledge/tags/service' import { StorageService } from '@/lib/uploads' -import { resolveWorkspaceFileReference } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { getBoundWorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { resolveWorkspaceFileReference } from '@/lib/workspace-files/application/resolve-workspace-file-reference' import { getCredential } from '@/app/api/auth/oauth/utils' import { checkDocumentWriteAccess, @@ -395,10 +397,18 @@ export const knowledgeBaseServerTool: BaseServerTool = [] const failedFiles: string[] = [] + const filePrincipal = createCopilotFilePrincipal(context, kbWorkspaceId) for (const fileRef of fileRefs) { - const fileRecord = await resolveWorkspaceFileReference(kbWorkspaceId, fileRef) - if (!fileRecord) { + let fileRecord + try { + fileRecord = await resolveWorkspaceFileReference({ + principal: filePrincipal, + operation: fileOperations.readContent, + workspaceId: kbWorkspaceId, + reference: fileRef, + }) + } catch { failedFiles.push(fileRef) continue } diff --git a/apps/sim/lib/copilot/tools/server/media/ffmpeg.ts b/apps/sim/lib/copilot/tools/server/media/ffmpeg.ts index fa2e1381221..a395a96b5a4 100644 --- a/apps/sim/lib/copilot/tools/server/media/ffmpeg.ts +++ b/apps/sim/lib/copilot/tools/server/media/ffmpeg.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { createCopilotFilePrincipal } from '@/lib/copilot/auth/file-delegation' import { Ffmpeg } from '@/lib/copilot/generated/tool-catalog-v1' import { assertServerToolNotAborted, @@ -7,11 +8,11 @@ import { type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' import { writeWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer' +import { MAX_MEDIA_BYTES } from '@/lib/media/falai' import { type FfmpegOperation, type MediaFile, runFfmpegOperation } from '@/lib/media/ffmpeg' -import { - fetchWorkspaceFileBuffer, - resolveWorkspaceFileReference, -} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +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' const logger = createLogger('FfmpegTool') @@ -82,12 +83,27 @@ export const ffmpegServerTool: BaseServerTool = { try { const mediaFiles: MediaFile[] = [] + let totalInputBytes = 0 + const principal = createCopilotFilePrincipal(context, workspaceId) for (const filePath of inputPaths) { - const fileRecord = await resolveWorkspaceFileReference(workspaceId, filePath) - if (!fileRecord) { - return { success: false, message: `Input file not found: ${filePath}` } + const fileRecord = await resolveWorkspaceFileReference({ + principal, + operation: fileOperations.readContent, + workspaceId, + reference: filePath, + }) + const { content: buffer } = await readWorkspaceFileContent.execute({ + principal, + input: { + fileId: fileRecord.id, + assertedWorkspaceId: workspaceId, + maxBytes: MAX_MEDIA_BYTES, + }, + }) + totalInputBytes += buffer.length + if (totalInputBytes > MAX_MEDIA_BYTES) { + throw new Error(`Input files exceed the ${MAX_MEDIA_BYTES} byte limit`) } - const buffer = await fetchWorkspaceFileBuffer(fileRecord) mediaFiles.push({ buffer, mimeType: fileRecord.type || 'application/octet-stream', @@ -130,7 +146,7 @@ export const ffmpegServerTool: BaseServerTool = { assertServerToolNotAborted(context) const written = await writeWorkspaceFileByPath({ workspaceId, - userId: context.userId, + principal, target: { path: outputPath, mode, mimeType: outputFile?.mimeType }, buffer: result.buffer, inferredMimeType: result.contentType || 'application/octet-stream', diff --git a/apps/sim/lib/copilot/tools/server/media/generate-audio.ts b/apps/sim/lib/copilot/tools/server/media/generate-audio.ts index d13a6669019..4d4eadbc458 100644 --- a/apps/sim/lib/copilot/tools/server/media/generate-audio.ts +++ b/apps/sim/lib/copilot/tools/server/media/generate-audio.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' +import { createCopilotFilePrincipal } from '@/lib/copilot/auth/file-delegation' import { GenerateAudio } from '@/lib/copilot/generated/tool-catalog-v1' import { assertServerToolNotAborted, @@ -11,11 +12,11 @@ import { projectServerToolModelInput, } from '@/lib/copilot/tools/server/model-input' import { writeWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer' +import { MAX_MEDIA_BYTES } from '@/lib/media/falai' import { type AudioType, generateFalAudio } from '@/lib/media/falai-audio' -import { - fetchWorkspaceFileBuffer, - resolveWorkspaceFileReference, -} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +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' const logger = createLogger('GenerateAudioTool') @@ -94,12 +95,18 @@ export const generateAudioServerTool: BaseServerTool ({ - mockFetchWorkspaceFileBuffer: vi.fn(), mockGenerateContent: vi.fn(), mockGenerateFalAudio: vi.fn(), mockGenerateFalVideo: vi.fn(), mockImportWorkspaceFileSecretProvenanceForValue: vi.fn(), mockResolveWorkspaceFileReference: vi.fn(), + mockReadWorkspaceFileContent: vi.fn(), mockWriteWorkspaceFileByPath: vi.fn(), })) @@ -32,10 +32,12 @@ vi.mock('@/lib/copilot/vfs/resource-writer', () => ({ })) vi.mock('@/lib/media/falai-audio', () => ({ generateFalAudio: mockGenerateFalAudio })) vi.mock('@/lib/media/falai-video', () => ({ generateFalVideo: mockGenerateFalVideo })) -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ - fetchWorkspaceFileBuffer: mockFetchWorkspaceFileBuffer, +vi.mock('@/lib/workspace-files/application/resolve-workspace-file-reference', () => ({ resolveWorkspaceFileReference: mockResolveWorkspaceFileReference, })) +vi.mock('@/lib/workspace-files/application/read-workspace-file-content', () => ({ + readWorkspaceFileContent: { execute: mockReadWorkspaceFileContent }, +})) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', () => ({ importWorkspaceFileSecretProvenanceForValue: mockImportWorkspaceFileSecretProvenanceForValue, MODEL_UNSAFE_WORKSPACE_FILE_ERROR_MESSAGE: @@ -72,6 +74,8 @@ function contextWithSecrets( return { userId: 'user-1', workspaceId: 'workspace-1', + toolCallId: 'tool-1', + copilotToolExecution: true, resolvedSecretTraceRegistry: registry, } } @@ -81,7 +85,7 @@ describe('Mothership media model boundaries', () => { vi.clearAllMocks() mockImportWorkspaceFileSecretProvenanceForValue.mockResolvedValue(true) mockResolveWorkspaceFileReference.mockResolvedValue(file) - mockFetchWorkspaceFileBuffer.mockResolvedValue(Buffer.from('opaque-media')) + mockReadWorkspaceFileContent.mockResolvedValue({ file, content: Buffer.from('opaque-media') }) mockWriteWorkspaceFileByPath.mockResolvedValue({ id: 'output-1', name: 'output.bin', @@ -196,7 +200,7 @@ describe('Mothership media model boundaries', () => { }) ) - expect(mockFetchWorkspaceFileBuffer).not.toHaveBeenCalled() + expect(mockReadWorkspaceFileContent).not.toHaveBeenCalled() expect(mockGenerateContent).not.toHaveBeenCalled() expect(mockGenerateFalVideo).not.toHaveBeenCalled() expect(mockGenerateFalAudio).not.toHaveBeenCalled() 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 1162535b7a0..6b5c6c9089d 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 @@ -71,6 +71,26 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ resolveWorkspaceFileReference: mockResolveWorkspaceFileReference, fetchWorkspaceFileBuffer: mockDownloadWorkspaceFile, })) +vi.mock('@/lib/workspace-files/application/resolve-workspace-file-reference', () => ({ + resolveWorkspaceFileReference: mockResolveWorkspaceFileReference, +})) +vi.mock('@/lib/workspace-files/application/read-workspace-file-content', () => ({ + readWorkspaceFileContent: { + execute: async () => ({ content: await mockDownloadWorkspaceFile() }), + }, +})) +vi.mock('@/lib/copilot/auth/file-delegation', () => ({ + createCopilotFilePrincipal: vi.fn(() => ({ + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'test-tool', + audience: 'sim:workspace-files', + issuedAt: new Date(0), + expiresAt: new Date(Date.now() + 60_000), + })), +})) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', () => ({ getBoundWorkspaceFileSecretProvenance: mockGetBoundWorkspaceFileSecretProvenance, 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 894bb88a171..b9c5ba4830b 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -2,6 +2,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' +import { createCopilotFilePrincipal } from '@/lib/copilot/auth/file-delegation' import { UserTable } from '@/lib/copilot/generated/tool-catalog-v1' import { assertServerToolNotAborted, @@ -95,16 +96,15 @@ import { deleteWorkflowGroupOutput, updateWorkflowGroup, } from '@/lib/table/workflow-groups/service' -import { - fetchWorkspaceFileBuffer, - resolveWorkspaceFileReference, -} from '@/lib/uploads/contexts/workspace/workspace-file-manager' 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' const logger = createLogger('UserTableServerTool') @@ -120,10 +120,22 @@ 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) { - const record = await resolveWorkspaceFileReference(workspaceId, fileReference) - if (!record) { +async function resolveWorkspaceFileRecordOrThrow( + fileReference: string, + workspaceId: string, + principal: ReturnType +) { + let record + try { + record = await resolveWorkspaceFileReference({ + principal, + operation: fileOperations.readContent, + workspaceId, + reference: fileReference, + }) + } catch { // Only workspace files resolve here. A chat upload is a real, correctly-copied // path, so pointing it at glob("files/**") would send the agent looking for a // file that is not in that tree until materialize_file moves it there. @@ -136,6 +148,16 @@ async function resolveWorkspaceFileRecordOrThrow(fileReference: string, workspac `File not found: "${fileReference}". Use glob("files/**") and read the canonical file path metadata to find workspace files.` ) } + if (!record) { + if (fileReference.replace(/^\/+/, '').startsWith('uploads/')) { + throw new Error( + `Cannot import "${fileReference}": chat uploads are not workspace files. Use materialize_file to save it to a files/... path first, then pass that canonical path.` + ) + } + throw new Error( + `File not found: "${fileReference}". Use glob("files/**") and read the canonical file path metadata to find workspace files.` + ) + } const provenance = await getBoundWorkspaceFileSecretProvenance(workspaceId, { fileId: record.id, @@ -1250,7 +1272,12 @@ export const userTableServerTool: BaseServerTool return { success: false, message: 'Workspace ID is required' } } - const record = await resolveWorkspaceFileRecordOrThrow(fileReference, workspaceId) + const filePrincipal = createCopilotFilePrincipal(context, workspaceId) + const record = await resolveWorkspaceFileRecordOrThrow( + fileReference, + workspaceId, + filePrincipal + ) // Large CSV/TSV: create a placeholder table whose creation claims the // job slot, then let the streaming import worker infer the schema and @@ -1311,7 +1338,16 @@ export const userTableServerTool: BaseServerTool } const file = { - buffer: await fetchWorkspaceFileBuffer(record), + buffer: ( + await readWorkspaceFileContent.execute({ + principal: filePrincipal, + input: { + fileId: record.id, + assertedWorkspaceId: workspaceId, + maxBytes: MAX_INLINE_FILE_BYTES, + }, + }) + ).content, name: record.name, type: record.type, } @@ -1438,7 +1474,12 @@ export const userTableServerTool: BaseServerTool return { success: false, message: `Table is archived: ${tableId}` } } - const record = await resolveWorkspaceFileRecordOrThrow(fileReference, workspaceId) + const filePrincipal = createCopilotFilePrincipal(context, workspaceId) + const record = await resolveWorkspaceFileRecordOrThrow( + fileReference, + workspaceId, + filePrincipal + ) // Large CSV/TSV: claim the table's one-write-job slot and hand the // file to the streaming import worker (mirrors @@ -1481,7 +1522,16 @@ export const userTableServerTool: BaseServerTool } try { const file = { - buffer: await fetchWorkspaceFileBuffer(record), + buffer: ( + await readWorkspaceFileContent.execute({ + principal: filePrincipal, + input: { + fileId: record.id, + assertedWorkspaceId: workspaceId, + maxBytes: MAX_INLINE_FILE_BYTES, + }, + }) + ).content, name: record.name, type: record.type, } diff --git a/apps/sim/lib/copilot/vfs/file-reader.ts b/apps/sim/lib/copilot/vfs/file-reader.ts index 30ad2848c0d..02181e88d9d 100644 --- a/apps/sim/lib/copilot/vfs/file-reader.ts +++ b/apps/sim/lib/copilot/vfs/file-reader.ts @@ -30,6 +30,7 @@ const logger = createLogger('FileReader') /** Inline text-read cap — exported so callers can align their own byte-sniff budgets with what read() can actually display. */ export const MAX_TEXT_READ_BYTES = 5 * 1024 * 1024 // 5 MB const MAX_IMAGE_READ_BYTES = 5 * 1024 * 1024 // 5 MB +const MAX_IMAGE_SOURCE_BYTES = 50 * 1024 * 1024 // 50 MB // Parseable-document byte cap. Large office/PDF files can still // produce huge extracted text; reject up front to avoid wasting a // download + parse only to blow past the tool-result budget. @@ -286,7 +287,11 @@ export interface FileReadResult { * binary), and any size rejection. The `prepareImageForVision` span * nests underneath for the image-resize path. */ -export async function readFileRecord(record: WorkspaceFileRecord): Promise { +export async function readFileRecord( + record: WorkspaceFileRecord, + /** Pre-authorized workspace bytes; omitted only for chat-upload records in the mothership store. */ + authorizedContent?: Buffer +): Promise { const startedAt = Date.now() const result = await getVfsTracer().startActiveSpan( TraceSpan.CopilotVfsReadFile, @@ -302,7 +307,14 @@ export async function readFileRecord(record: WorkspaceFileRecord): Promise MAX_IMAGE_SOURCE_BYTES) { + span.setAttribute(TraceAttr.CopilotVfsReadOutcome, CopilotVfsReadOutcome.ImageTooLarge) + return { + content: `[Image too large to process: ${record.name} (${(record.size / 1024 / 1024).toFixed(1)}MB, source limit 50MB)]`, + totalLines: 1, + } + } + const originalBuffer = authorizedContent ?? (await fetchWorkspaceFileBuffer(record)) const prepared = await prepareImageForVision(originalBuffer, record.type) if (!prepared) { span.setAttribute(TraceAttr.CopilotVfsReadOutcome, CopilotVfsReadOutcome.ImageTooLarge) @@ -344,7 +356,7 @@ export async function readFileRecord(record: WorkspaceFileRecord): Promise { resolveWorkspaceFileReference: vi.fn(), updateWorkspaceFileContent: vi.fn(), uploadWorkspaceFile: vi.fn(), + createWorkspaceFileBufferByPath: { execute: vi.fn() }, + updateWorkspaceFileContentBufferByPath: { execute: vi.fn() }, } }) @@ -26,11 +28,18 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ FileConflictError: mocks.FileConflictError, getWorkspaceFileByName: mocks.getWorkspaceFileByName, - resolveWorkspaceFileReference: mocks.resolveWorkspaceFileReference, updateWorkspaceFileContent: mocks.updateWorkspaceFileContent, uploadWorkspaceFile: mocks.uploadWorkspaceFile, })) +vi.mock('@/lib/workspace-files/application/write-workspace-file-by-path', () => ({ + createWorkspaceFileBufferByPath: mocks.createWorkspaceFileBufferByPath, + updateWorkspaceFileContentBufferByPath: mocks.updateWorkspaceFileContentBufferByPath, +})) +vi.mock('@/lib/workspace-files/application/resolve-workspace-file-reference', () => ({ + resolveWorkspaceFileReference: mocks.resolveWorkspaceFileReference, +})) + import { validateWorkspaceFileWriteTarget, writeWorkspaceFileByPath } from './resource-writer' describe('resource writer', () => { @@ -41,18 +50,19 @@ describe('resource writer', () => { it('auto-creates missing parent folders for plain workspace file creates', async () => { mocks.ensureWorkspaceFileFolderPath.mockResolvedValue('folder-nested') - mocks.getWorkspaceFileByName.mockResolvedValue(null) - mocks.uploadWorkspaceFile.mockResolvedValue({ + mocks.createWorkspaceFileBufferByPath.execute.mockResolvedValue({ id: 'file-report', name: 'summary.csv', size: 7, - type: 'text/csv', - url: '/download', + contentType: 'text/csv', + downloadUrl: '/download', + vfsPath: 'files/Reports/2026/summary.csv', + mode: 'create', }) const result = await writeWorkspaceFileByPath({ workspaceId: 'workspace-1', - userId: 'user-1', + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, target: { path: 'files/Reports/2026/summary.csv', mode: 'create', @@ -61,23 +71,15 @@ describe('resource writer', () => { inferredMimeType: 'text/csv', }) - expect(mocks.ensureWorkspaceFileFolderPath).toHaveBeenCalledWith({ - workspaceId: 'workspace-1', - userId: 'user-1', - pathSegments: ['Reports', '2026'], + expect(mocks.createWorkspaceFileBufferByPath.execute).toHaveBeenCalledWith({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: expect.objectContaining({ + workspaceId: 'workspace-1', + path: 'files/Reports/2026/summary.csv', + content: Buffer.from('content'), + contentType: 'text/csv', + }), }) - expect(mocks.findWorkspaceFileFolderIdByPath).not.toHaveBeenCalled() - expect(mocks.uploadWorkspaceFile).toHaveBeenCalledWith( - 'workspace-1', - 'user-1', - Buffer.from('content'), - 'summary.csv', - 'text/csv', - { - folderId: 'folder-nested', - secretProvenance: { status: 'exact', entries: [] }, - } - ) expect(result).toMatchObject({ id: 'file-report', vfsPath: 'files/Reports/2026/summary.csv', @@ -91,7 +93,7 @@ describe('resource writer', () => { const validation = await validateWorkspaceFileWriteTarget({ workspaceId: 'workspace-1', - userId: 'user-1', + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, target: { path: 'files/Reports/2026/summary.csv', mode: 'create', @@ -112,7 +114,7 @@ describe('resource writer', () => { const validation = await validateWorkspaceFileWriteTarget({ workspaceId: 'workspace-1', - userId: 'user-1', + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, target: { path: 'files/Reports/2026/summary.csv', mode: 'create', @@ -128,4 +130,33 @@ describe('resource writer', () => { folderId: null, }) }) + + it('authorizes overwrite target resolution through the shared application resolver', async () => { + mocks.resolveWorkspaceFileReference.mockResolvedValue({ + id: 'file-report', + name: 'summary.csv', + size: 7, + type: 'text/csv', + folderPath: 'Reports/2026', + }) + + const principal = { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' } + const validation = await validateWorkspaceFileWriteTarget({ + workspaceId: 'workspace-1', + principal, + target: { path: 'files/Reports/2026/summary.csv', mode: 'overwrite' }, + }) + + expect(mocks.resolveWorkspaceFileReference).toHaveBeenCalledWith({ + principal, + operation: expect.objectContaining({ id: 'files.update_content' }), + workspaceId: 'workspace-1', + reference: 'files/Reports/2026/summary.csv', + }) + expect(validation).toMatchObject({ + mode: 'overwrite', + existingFileId: 'file-report', + vfsPath: 'files/Reports/2026/summary.csv', + }) + }) }) diff --git a/apps/sim/lib/copilot/vfs/resource-writer.ts b/apps/sim/lib/copilot/vfs/resource-writer.ts index 9dfa24141f2..04a3069d35c 100644 --- a/apps/sim/lib/copilot/vfs/resource-writer.ts +++ b/apps/sim/lib/copilot/vfs/resource-writer.ts @@ -1,20 +1,18 @@ -import { canonicalWorkspaceFilePath, decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' -import { - ensureWorkspaceFileFolderPath, - findWorkspaceFileFolderIdByPath, - normalizeWorkspaceFileItemName, -} from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' +import type { Principal } from '@sim/auth/principal' +import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' +import { findWorkspaceFileFolderIdByPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { getWorkspaceFileByName, - resolveWorkspaceFileReference, - updateWorkspaceFileContent, - uploadWorkspaceFile, type WorkspaceFileRecord, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import type { WorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { resolveWorkspaceFileReference } from '@/lib/workspace-files/application/resolve-workspace-file-reference' import { - EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE, - type WorkspaceFileSecretProvenance, -} from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' + createWorkspaceFileBufferByPath, + updateWorkspaceFileContentBufferByPath, +} from '@/lib/workspace-files/application/write-workspace-file-by-path' +import { parseWorkspaceFileCreatePath } from '@/lib/workspace-files/workspace-file-path' export type WorkspaceFileWriteMode = 'create' | 'overwrite' @@ -54,68 +52,20 @@ export type WorkspaceFileWriteValidation = existingFileId: string } -function displayFolderPath(segments: string[]): string { - return segments.length > 0 ? `files/${segments.join('/')}` : 'files/' -} - -export function parseWorkspaceFileCreatePath(path: string): { - folderSegments: string[] - fileName: string - vfsPath: string -} { - const trimmed = path.trim().replace(/^\/+/, '') - if (!trimmed.startsWith('files/')) { - throw new Error('Workspace file paths must start with "files/"') - } - - const decoded = decodeVfsPathSegments(trimmed.slice('files/'.length)) - if (decoded.length === 0) { - throw new Error('Workspace file path must include a file name') - } - - const fileName = normalizeWorkspaceFileItemName(decoded.at(-1) ?? '', 'File') - const folderSegments = decoded - .slice(0, -1) - .map((segment) => normalizeWorkspaceFileItemName(segment, 'Folder')) - - return { - folderSegments, - fileName, - vfsPath: canonicalWorkspaceFilePath({ folderPath: folderSegments.join('/'), name: fileName }), - } -} - -/** - * Resolve a create-mode write target. Pass `createFolders` (the write path) to - * create missing parent folders; without it (the validation path) resolution - * is read-only — a missing parent chain yields `folderId: null`, since the - * folders are created at write time and nothing can conflict there yet. - */ +/** Resolves a create-mode target without mutating missing parent folders. */ async function resolveCreateTarget( workspaceId: string, - path: string, - createFolders?: { userId: string } + path: string ): Promise { const parsed = parseWorkspaceFileCreatePath(path) let folderId: string | null = null if (parsed.folderSegments.length > 0) { - if (createFolders) { - folderId = await ensureWorkspaceFileFolderPath({ - workspaceId, - userId: createFolders.userId, - pathSegments: parsed.folderSegments, - }) - if (!folderId) { - throw new Error(`Failed to create directory: ${displayFolderPath(parsed.folderSegments)}`) - } - } else { - folderId = await findWorkspaceFileFolderIdByPath(workspaceId, parsed.folderSegments) - if (!folderId) { - return { - fileName: parsed.fileName, - folderId: null, - vfsPath: parsed.vfsPath, - } + folderId = await findWorkspaceFileFolderIdByPath(workspaceId, parsed.folderSegments) + if (!folderId) { + return { + fileName: parsed.fileName, + folderId: null, + vfsPath: parsed.vfsPath, } } } @@ -138,14 +88,16 @@ function vfsPathForRecord(record: WorkspaceFileRecord): string { export async function validateWorkspaceFileWriteTarget(args: { workspaceId: string - userId?: string + principal: Principal target: WorkspaceFileWriteTarget }): Promise { if (args.target.mode === 'overwrite') { - const existing = await resolveWorkspaceFileReference(args.workspaceId, args.target.path) - if (!existing) { - throw new Error(`File not found for overwrite: ${args.target.path}`) - } + const existing = await resolveWorkspaceFileReference({ + principal: args.principal, + operation: fileOperations.updateContent, + workspaceId: args.workspaceId, + reference: args.target.path, + }) return { mode: 'overwrite', vfsPath: vfsPathForRecord(existing), @@ -164,7 +116,7 @@ export async function validateWorkspaceFileWriteTarget(args: { export async function writeWorkspaceFileByPath(args: { workspaceId: string - userId: string + principal: Principal target: WorkspaceFileWriteTarget buffer: Buffer inferredMimeType: string @@ -179,59 +131,50 @@ export async function writeWorkspaceFileByPath(args: { }): Promise { const contentType = args.target.mimeType || args.inferredMimeType if (args.target.mode === 'overwrite') { - const existing = await resolveWorkspaceFileReference(args.workspaceId, args.target.path) - if (!existing) { - throw new Error(`File not found for overwrite: ${args.target.path}`) - } - - const updated = await updateWorkspaceFileContent( - args.workspaceId, - existing.id, - args.userId, - args.buffer, - contentType || existing.type, - { + const updated = await updateWorkspaceFileContentBufferByPath.execute({ + principal: args.principal, + input: { + workspaceId: args.workspaceId, + path: args.target.path, + mode: 'overwrite', + content: args.buffer, + contentType, syncLiveDoc: args.syncLiveDoc, - secretProvenancePolicy: { - mode: 'replace', - provenance: args.secretProvenance ?? { status: 'exact', entries: [] }, - }, - } - ) + secretProvenance: args.secretProvenance, + }, + }) return { id: updated.id, name: updated.name, size: updated.size, - contentType: updated.type, - downloadUrl: updated.url, - vfsPath: vfsPathForRecord(updated), + contentType: updated.contentType, + downloadUrl: updated.downloadUrl, + vfsPath: updated.vfsPath, mode: 'overwrite', } } - const createTarget = await resolveCreateTarget(args.workspaceId, args.target.path, { - userId: args.userId, + const created = await createWorkspaceFileBufferByPath.execute({ + principal: args.principal, + input: { + workspaceId: args.workspaceId, + path: args.target.path, + mode: 'create', + content: args.buffer, + contentType, + exactName: true, + secretProvenance: args.secretProvenance, + }, }) - const uploaded = await uploadWorkspaceFile( - args.workspaceId, - args.userId, - args.buffer, - createTarget.fileName, - contentType, - { - folderId: createTarget.folderId, - secretProvenance: args.secretProvenance ?? EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE, - } - ) return { - id: uploaded.id, - name: uploaded.name, - size: uploaded.size, - contentType: uploaded.type, - downloadUrl: uploaded.url, - vfsPath: createTarget.vfsPath, + id: created.id, + name: created.name, + size: created.size, + contentType: created.contentType, + downloadUrl: created.downloadUrl, + vfsPath: created.vfsPath, mode: 'create', } } diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index 4a7b340a07c..b451518ff56 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -1,4 +1,5 @@ import { trace } from '@opentelemetry/api' +import type { Principal } from '@sim/auth/principal' import { db } from '@sim/db' import { chat as chatTable, @@ -48,7 +49,11 @@ import { } from '@/lib/copilot/tools/server/workflow/edit-workflow/lint' import { UNRESOLVABLE_AT_LINT_NOTE } from '@/lib/copilot/tools/server/workflow/edit-workflow/validation' import { extractDocumentStyle } from '@/lib/copilot/vfs/document-style' -import { type FileReadResult, readFileRecord } from '@/lib/copilot/vfs/file-reader' +import { + type FileReadResult, + MAX_TEXT_READ_BYTES, + readFileRecord, +} from '@/lib/copilot/vfs/file-reader' import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' import type { GrepMatch, GrepOptions, ReadResult } from '@/lib/copilot/vfs/operations' import * as ops from '@/lib/copilot/vfs/operations' @@ -117,15 +122,9 @@ import { getKnowledgeBases } from '@/lib/knowledge/service' import { validateMermaidSource } from '@/lib/mermaid/validate' import { isBlockTypeAccessControlExempt } from '@/lib/permission-groups/block-access' import { intersectIntegrationAllowlists } from '@/lib/permission-groups/integration-allowlist' -import { getWorkspaceShares } from '@/lib/public-shares/share-manager' import { listTables } from '@/lib/table/service' -import { listWorkspaceFileFolders } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' -import { - fetchWorkspaceFileBuffer, - findWorkspaceFileRecord, - listWorkspaceFiles, - type WorkspaceFileRecord, -} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { findWorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import type { WorkspaceFileSecretProvenanceEnvelope } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' import { listCustomBlocksWithInputsForWorkspace } from '@/lib/workflows/custom-blocks/operations' import { getCustomToolById } from '@/lib/workflows/custom-tools/operations' @@ -133,6 +132,9 @@ import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/ut import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer' import { getSkillById } from '@/lib/workflows/skills/operations' import { listFolders, listWorkflows } from '@/lib/workflows/utils' +import { listAllWorkspaceFiles } from '@/lib/workspace-files/application/list-workspace-files' +import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' +import { listWorkspaceFileFoldersOperation } from '@/lib/workspace-files/application/workspace-file-folders' import { assertActiveWorkspaceAccess, getUsersWithPermissions, @@ -563,6 +565,7 @@ function getStaticComponentFiles(): Map { * components/triggers/{provider}/{id}.json (external triggers: github, slack, etc.) */ export class WorkspaceVFS { + private readonly filePrincipal?: Principal // Eagerly-materialized, cheap content (structure + metadata): folder markers, // per-resource meta.json, WORKSPACE.md/WORKSPACE_CONTEXT.md, static components. private files: Map = new Map() @@ -595,6 +598,10 @@ export class WorkspaceVFS { */ private _customBlockTypes: Set | null = null + constructor(filePrincipal?: Principal) { + this.filePrincipal = filePrincipal + } + get workspaceId(): string { return this._workspaceId } @@ -1035,10 +1042,23 @@ export class WorkspaceVFS { const canonicalMatch = path.match(new RegExp(`^files/(.+)/${suffix}$`)) if (!canonicalMatch?.[1]) return null - const files = await listWorkspaceFiles(this._workspaceId) + if (!this.filePrincipal) { + throw new Error('Workspace file reads require a trusted Copilot principal') + } + const { files } = await listAllWorkspaceFiles.execute({ + principal: this.filePrincipal, + input: { workspaceId: this._workspaceId, scope: 'active' }, + }) return findWorkspaceFileRecord(files, `files/${canonicalMatch[1]}`) } + private requireFilePrincipal(): Principal { + if (!this.filePrincipal) { + throw new Error('Workspace file reads require a trusted Copilot principal') + } + return this.filePrincipal + } + /** * Renders a renderable doc (pptx/docx/pdf) record to a contact-sheet image and * returns it as a model readable JPEG attachment. Shared by the `/render` and @@ -1059,7 +1079,14 @@ export class WorkspaceVFS { totalLines: 1, } } - const buffer = await fetchWorkspaceFileBuffer(record) + const { content: buffer } = await readWorkspaceFileContent.execute({ + principal: this.requireFilePrincipal(), + input: { + fileId: record.id, + assertedWorkspaceId: this._workspaceId, + maxBytes: MAX_DOC_READ_INPUT_BYTES, + }, + }) if (buffer.length > MAX_DOC_READ_INPUT_BYTES) { return { content: JSON.stringify({ ok: false, error: 'File is too large to render' }), @@ -1082,7 +1109,12 @@ export class WorkspaceVFS { } if (isDocSandboxEnabled && (await getE2BDocFormat(record.name))) { bin = ( - await compileDoc({ source: code, fileName: record.name, workspaceId: this._workspaceId }) + await compileDoc({ + source: code, + fileName: record.name, + workspaceId: this._workspaceId, + filePrincipal: this.requireFilePrincipal(), + }) ).buffer } else { const taskId = BINARY_DOC_TASKS[ext] @@ -1172,7 +1204,14 @@ export class WorkspaceVFS { }) } - const buffer = await fetchWorkspaceFileBuffer(record) + const { content: buffer } = await readWorkspaceFileContent.execute({ + principal: this.requireFilePrincipal(), + input: { + fileId: record.id, + assertedWorkspaceId: this._workspaceId, + maxBytes: MAX_DOC_READ_INPUT_BYTES, + }, + }) const code = buffer.toString('utf-8') if (Buffer.byteLength(code, 'utf-8') > MAX_DOCUMENT_PREVIEW_CODE_BYTES) { return bindWorkspaceFileResult(record, { @@ -1186,6 +1225,7 @@ export class WorkspaceVFS { source: code, fileName: record.name, workspaceId: this._workspaceId, + filePrincipal: this.requireFilePrincipal(), }) ).buffer : await runSandboxTask(taskId, { code, workspaceId: this._workspaceId }) @@ -1296,7 +1336,14 @@ export class WorkspaceVFS { totalLines: 1, }) } - const buffer = await fetchWorkspaceFileBuffer(record) + const { content: buffer } = await readWorkspaceFileContent.execute({ + principal: this.requireFilePrincipal(), + input: { + fileId: record.id, + assertedWorkspaceId: this._workspaceId, + maxBytes: MAX_DOC_READ_INPUT_BYTES, + }, + }) if (buffer.length > MAX_DOC_READ_INPUT_BYTES) { return bindWorkspaceFileResult(record, { content: JSON.stringify({ ok: false, error: 'File is too large to extract' }), @@ -1348,7 +1395,14 @@ export class WorkspaceVFS { const taskId = BINARY_DOC_TASKS[ext] const isMermaidFile = ext === 'mmd' || ext === 'mermaid' if (!e2bFmt && !taskId && !isMermaidFile) return null - const buffer = await fetchWorkspaceFileBuffer(record) + const { content: buffer } = await readWorkspaceFileContent.execute({ + principal: this.requireFilePrincipal(), + input: { + fileId: record.id, + assertedWorkspaceId: this._workspaceId, + maxBytes: MAX_DOC_READ_INPUT_BYTES, + }, + }) const code = buffer.toString('utf-8') if (Buffer.byteLength(code, 'utf-8') > MAX_DOCUMENT_PREVIEW_CODE_BYTES) { return bindWorkspaceFileResult(record, { @@ -1371,6 +1425,7 @@ export class WorkspaceVFS { fileName: record.name, workspaceId: this._workspaceId, ext, + principal: this.requireFilePrincipal(), }) } else { try { @@ -1407,7 +1462,20 @@ export class WorkspaceVFS { const rawExt = record.name.split('.').pop()?.toLowerCase() if (rawExt !== 'docx' && rawExt !== 'pptx' && rawExt !== 'pdf') return null const ext: 'docx' | 'pptx' | 'pdf' = rawExt - const buffer = await fetchWorkspaceFileBuffer(record) + if (typeof record.size === 'number' && record.size > MAX_DOC_READ_INPUT_BYTES) { + return bindWorkspaceFileResult(record, { + content: JSON.stringify({ ok: false, error: 'File is too large to extract style' }), + totalLines: 1, + }) + } + const { content: buffer } = await readWorkspaceFileContent.execute({ + principal: this.requireFilePrincipal(), + input: { + fileId: record.id, + assertedWorkspaceId: this._workspaceId, + maxBytes: MAX_DOC_READ_INPUT_BYTES, + }, + }) const summary = await extractDocumentStyle(buffer, ext) if (!summary) return null const json = JSON.stringify(summary, null, 2) @@ -1440,11 +1508,23 @@ export class WorkspaceVFS { const scope = deletedMatch ? 'archived' : 'active' try { - const files = await listWorkspaceFiles(this._workspaceId, { scope }) + const { files } = await listAllWorkspaceFiles.execute({ + principal: this.requireFilePrincipal(), + input: { workspaceId: this._workspaceId, scope }, + }) const record = findWorkspaceFileRecord(files, fileReference) if (!record) return null - const result = await readFileRecord(record) - return result ? bindWorkspaceFileResult(record, result) : null + const { file, content } = await readWorkspaceFileContent.execute({ + principal: this.requireFilePrincipal(), + input: { + fileId: record.id, + assertedWorkspaceId: this._workspaceId, + includeDeleted: scope === 'archived', + maxBytes: MAX_TEXT_READ_BYTES, + }, + }) + const result = await readFileRecord(file, content) + return result ? bindWorkspaceFileResult(file, result) : null } catch (err) { logger.warn('Failed to list workspace files for readFileContent', { workspaceId: this._workspaceId, @@ -1861,22 +1941,14 @@ export class WorkspaceVFS { */ private async materializeFiles(workspaceId: string): Promise { try { - const folders = await listWorkspaceFileFolders(workspaceId) - const files = await listWorkspaceFiles(workspaceId, { folders, throwOnError: true }) - // Batch-load public share state so each file's metadata carries an ambient - // `shared` flag (mirrors how the files-list UI enriches rows) — no N+1. - // Fail soft: share state is only metadata enrichment, so a lookup failure - // must not drop the whole file tree (the outer catch returns []) — fall back - // to no shares, and files still materialize with `shared: false`. - let shareByFileId: Awaited> = new Map() - try { - shareByFileId = await getWorkspaceShares('file', workspaceId) - } catch (error) { - logger.warn('Failed to load file share state; file metadata will show shared: false', { - workspaceId, - error: toError(error).message, - }) - } + const principal = this.requireFilePrincipal() + const [{ folders }, { files }] = await Promise.all([ + listWorkspaceFileFoldersOperation.execute({ + principal, + input: { workspaceId, scope: 'active' }, + }), + listAllWorkspaceFiles.execute({ principal, input: { workspaceId, scope: 'active' } }), + ]) for (const folder of folders) { this.files.set(`files/${encodeVfsPathSegments(folder.path.split('/'))}/.folder`, '') } @@ -1886,7 +1958,7 @@ export class WorkspaceVFS { folderPath: file.folderPath, name: file.name, }) - const share = shareByFileId.get(file.id) + const share = file.share const shared = share?.isActive ?? false this.files.set( filePath, @@ -2259,8 +2331,18 @@ export class WorkspaceVFS { ) ), listTables(workspaceId, { scope: 'archived' }), - listWorkspaceFiles(workspaceId, { scope: 'archived' }), - listWorkspaceFileFolders(workspaceId, { scope: 'archived' }), + listAllWorkspaceFiles + .execute({ + principal: this.requireFilePrincipal(), + input: { workspaceId, scope: 'archived' }, + }) + .then(({ files }) => files), + listWorkspaceFileFoldersOperation + .execute({ + principal: this.requireFilePrincipal(), + input: { workspaceId, scope: 'archived' }, + }) + .then(({ folders }) => folders), getKnowledgeBases(userId, workspaceId, 'archived'), ]) @@ -2488,10 +2570,10 @@ export class WorkspaceVFS { export async function getOrMaterializeVFS( workspaceId: string, userId: string, - options?: { secretMountPolicy?: SecretMountPolicy } + options?: { secretMountPolicy?: SecretMountPolicy; filePrincipal?: Principal } ): Promise { await assertActiveWorkspaceAccess(workspaceId, userId) - const vfs = new WorkspaceVFS() + const vfs = new WorkspaceVFS(options?.filePrincipal) await vfs.materialize(workspaceId, userId, options) return vfs } diff --git a/apps/sim/lib/uploads/archive.test.ts b/apps/sim/lib/uploads/archive.test.ts index 2f9ad86e98d..d0da45fe10d 100644 --- a/apps/sim/lib/uploads/archive.test.ts +++ b/apps/sim/lib/uploads/archive.test.ts @@ -10,12 +10,20 @@ const { mockEnsureFolder, mockUpload, mockDelete } = vi.hoisted(() => ({ mockUpload: vi.fn(), mockDelete: vi.fn(), })) -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => ({ - ensureWorkspaceFileFolderPath: mockEnsureFolder, +vi.mock('@/lib/workspace-files/application/workspace-file-folders', () => ({ + createWorkspaceFileFolderOperation: { + execute: mockEnsureFolder, + }, })) -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ - uploadWorkspaceFile: mockUpload, - deleteWorkspaceFile: mockDelete, +vi.mock('@/lib/workspace-files/application/create-workspace-file', () => ({ + createWorkspaceFileFromBuffer: { + execute: mockUpload, + }, +})) +vi.mock('@/lib/workspace-files/application/delete-workspace-file', () => ({ + deleteWorkspaceFileOperation: { + execute: mockDelete, + }, })) import { @@ -25,6 +33,12 @@ import { MAX_ARCHIVE_ENTRY_BYTES, } from '@/lib/uploads/archive' +const TEST_PRINCIPAL = { + kind: 'session', + userId: 'u', + sessionId: 'session-1', +} as const + async function buildZip( files: Record, opts?: { symlinks?: string[] } @@ -71,16 +85,20 @@ function craftCentralDirectory(records: number, extraPerRecord: number): Buffer beforeEach(() => { vi.clearAllMocks() - mockEnsureFolder.mockResolvedValue('folder_1') + mockEnsureFolder.mockResolvedValue({ folder: { id: 'folder_1' } }) mockDelete.mockResolvedValue(undefined) - mockUpload.mockImplementation(async (_ws: string, _uid: string, buf: Buffer, name: string) => ({ - id: `f_${name}`, - name, - url: `/api/files/serve/${name}`, - key: `workspace/ws/${name}`, - size: buf.length, - type: 'text/plain', - })) + mockUpload.mockImplementation( + async ({ input }: { input: { content: Buffer; name: string } }) => ({ + file: { + id: `f_${input.name}`, + name: input.name, + url: `/api/files/serve/${input.name}`, + key: `workspace/ws/${input.name}`, + size: input.content.length, + type: 'text/plain', + }, + }) + ) }) describe('decompressArchiveBufferToWorkspaceFiles', () => { @@ -89,21 +107,21 @@ describe('decompressArchiveBufferToWorkspaceFiles', () => { const result = await decompressArchiveBufferToWorkspaceFiles(buffer, { workspaceId: 'ws', - userId: 'u', + principal: TEST_PRINCIPAL, rootFolderSegments: ['bundle'], }) expect(result.extracted).toHaveLength(2) expect(result.skippedUnsafePaths).toEqual([]) expect(mockUpload).toHaveBeenCalledTimes(2) - const leafNames = mockUpload.mock.calls.map((c) => c[3]).sort() + const leafNames = mockUpload.mock.calls.map(([args]) => args.input.name).sort() expect(leafNames).toEqual(['report.txt', 'sheet.csv']) // Entries are rooted under the archive's folder; nested paths are preserved. expect(mockEnsureFolder).toHaveBeenCalledWith( - expect.objectContaining({ pathSegments: ['bundle'] }) + expect.objectContaining({ input: { workspaceId: 'ws', path: 'bundle' } }) ) expect(mockEnsureFolder).toHaveBeenCalledWith( - expect.objectContaining({ pathSegments: ['bundle', 'data'] }) + expect.objectContaining({ input: { workspaceId: 'ws', path: 'bundle/data' } }) ) }) @@ -116,13 +134,13 @@ describe('decompressArchiveBufferToWorkspaceFiles', () => { await decompressArchiveBufferToWorkspaceFiles(buffer, { workspaceId: 'ws', - userId: 'u', + principal: TEST_PRINCIPAL, secretProvenance, }) expect(mockUpload).toHaveBeenCalledTimes(2) for (const call of mockUpload.mock.calls) { - expect(call[5]).toEqual(expect.objectContaining({ secretProvenance })) + expect(call[0].input).toEqual(expect.objectContaining({ secretProvenance })) } }) @@ -134,7 +152,10 @@ describe('decompressArchiveBufferToWorkspaceFiles', () => { const buffer = craftCentralDirectory(MAX_ARCHIVE_CENTRAL_DIR_RECORDS + 1, 0) await expect( - decompressArchiveBufferToWorkspaceFiles(buffer, { workspaceId: 'ws', userId: 'u' }) + decompressArchiveBufferToWorkspaceFiles(buffer, { + workspaceId: 'ws', + principal: TEST_PRINCIPAL, + }) ).rejects.toMatchObject({ name: 'ArchiveError', reason: 'central_dir_too_large', @@ -155,7 +176,10 @@ describe('decompressArchiveBufferToWorkspaceFiles', () => { const buffer = craftCentralDirectory(records, EXTRA_PER_RECORD) await expect( - decompressArchiveBufferToWorkspaceFiles(buffer, { workspaceId: 'ws', userId: 'u' }) + decompressArchiveBufferToWorkspaceFiles(buffer, { + workspaceId: 'ws', + principal: TEST_PRINCIPAL, + }) ).rejects.toMatchObject({ name: 'ArchiveError', reason: 'central_dir_too_large' }) expect(mockUpload).not.toHaveBeenCalled() }) @@ -175,7 +199,7 @@ describe('decompressArchiveBufferToWorkspaceFiles', () => { const result = await decompressArchiveBufferToWorkspaceFiles(buffer, { workspaceId: 'ws', - userId: 'u', + principal: TEST_PRINCIPAL, }) expect(result.extracted).toHaveLength(1) @@ -201,7 +225,10 @@ describe('decompressArchiveBufferToWorkspaceFiles', () => { buffer.fill(0xff, nameOffset + 'bad.bin'.length, nameOffset + 'bad.bin'.length + 256) await expect( - decompressArchiveBufferToWorkspaceFiles(buffer, { workspaceId: 'ws', userId: 'u' }) + decompressArchiveBufferToWorkspaceFiles(buffer, { + workspaceId: 'ws', + principal: TEST_PRINCIPAL, + }) ).rejects.toMatchObject({ name: 'ArchiveError', reason: 'invalid' }) expect(mockUpload).not.toHaveBeenCalled() }) @@ -212,17 +239,28 @@ describe('decompressArchiveBufferToWorkspaceFiles', () => { // must be deleted so callers and retries never observe a partial tree. const buffer = await buildZip({ 'a.txt': 'first', 'b.txt': 'second', 'c.txt': 'third' }) mockUpload - .mockResolvedValueOnce({ id: 'f_a', name: 'a.txt', url: '/a', key: 'k/a', size: 5 }) - .mockResolvedValueOnce({ id: 'f_b', name: 'b.txt', url: '/b', key: 'k/b', size: 6 }) + .mockResolvedValueOnce({ + file: { id: 'f_a', name: 'a.txt', url: '/a', key: 'k/a', size: 5 }, + }) + .mockResolvedValueOnce({ + file: { id: 'f_b', name: 'b.txt', url: '/b', key: 'k/b', size: 6 }, + }) .mockRejectedValueOnce(new Error('storage quota exceeded')) await expect( - decompressArchiveBufferToWorkspaceFiles(buffer, { workspaceId: 'ws', userId: 'u' }) + decompressArchiveBufferToWorkspaceFiles(buffer, { + workspaceId: 'ws', + principal: TEST_PRINCIPAL, + }) ).rejects.toThrow('storage quota exceeded') expect(mockDelete).toHaveBeenCalledTimes(2) - expect(mockDelete).toHaveBeenCalledWith('ws', 'f_a') - expect(mockDelete).toHaveBeenCalledWith('ws', 'f_b') + expect(mockDelete).toHaveBeenCalledWith( + expect.objectContaining({ input: { fileId: 'f_a', assertedWorkspaceId: 'ws' } }) + ) + expect(mockDelete).toHaveBeenCalledWith( + expect.objectContaining({ input: { fileId: 'f_b', assertedWorkspaceId: 'ws' } }) + ) }) it('does not count noise entries toward the extraction cap when they are being skipped', async () => { @@ -239,7 +277,7 @@ describe('decompressArchiveBufferToWorkspaceFiles', () => { const result = await decompressArchiveBufferToWorkspaceFiles(buffer, { workspaceId: 'ws', - userId: 'u', + principal: TEST_PRINCIPAL, skipNoiseEntries: true, }) @@ -251,7 +289,7 @@ describe('decompressArchiveBufferToWorkspaceFiles', () => { await expect( decompressArchiveBufferToWorkspaceFiles(Buffer.from('not a zip at all'), { workspaceId: 'ws', - userId: 'u', + principal: TEST_PRINCIPAL, }) ).rejects.toMatchObject({ name: 'ArchiveError', reason: 'invalid' }) expect(mockUpload).not.toHaveBeenCalled() @@ -269,7 +307,7 @@ describe('decompressArchiveBufferToWorkspaceFiles', () => { const result = await decompressArchiveBufferToWorkspaceFiles(buffer, { workspaceId: 'ws', - userId: 'u', + principal: TEST_PRINCIPAL, }) // Only the traversal entry counts toward `skipped`; the symlink is filtered @@ -279,7 +317,7 @@ describe('decompressArchiveBufferToWorkspaceFiles', () => { expect(result.skipped).toBe(1) expect(result.skippedUnsafePaths).toEqual(['..\\evil.txt']) expect(mockUpload).toHaveBeenCalledTimes(1) - expect(mockUpload.mock.calls[0][3]).toBe('safe.txt') + expect(mockUpload.mock.calls[0][0].input.name).toBe('safe.txt') }) it('extracts macOS/Windows filesystem-noise entries by default (skipNoiseEntries unset)', async () => { @@ -287,7 +325,7 @@ describe('decompressArchiveBufferToWorkspaceFiles', () => { const result = await decompressArchiveBufferToWorkspaceFiles(buffer, { workspaceId: 'ws', - userId: 'u', + principal: TEST_PRINCIPAL, }) // Parity with the HTTP decompress route, which extracts these verbatim. @@ -301,7 +339,7 @@ describe('decompressArchiveBufferToWorkspaceFiles', () => { const result = await decompressArchiveBufferToWorkspaceFiles(buffer, { workspaceId: 'ws', - userId: 'u', + principal: TEST_PRINCIPAL, skipNoiseEntries: true, }) @@ -320,7 +358,10 @@ describe('decompressArchiveBufferToWorkspaceFiles', () => { ) await expect( - decompressArchiveBufferToWorkspaceFiles(buffer, { workspaceId: 'ws', userId: 'u' }) + decompressArchiveBufferToWorkspaceFiles(buffer, { + workspaceId: 'ws', + principal: TEST_PRINCIPAL, + }) ).rejects.toMatchObject({ name: 'ArchiveError', reason: 'entry_too_large' }) expect(mockUpload).not.toHaveBeenCalled() }) diff --git a/apps/sim/lib/uploads/archive.ts b/apps/sim/lib/uploads/archive.ts index 89c118f41f7..856c1a10e40 100644 --- a/apps/sim/lib/uploads/archive.ts +++ b/apps/sim/lib/uploads/archive.ts @@ -1,14 +1,13 @@ import { Buffer } from 'buffer' import type { Readable } from 'stream' +import type { Principal } from '@sim/auth/principal' import JSZip from 'jszip' import { readZipCentralDirectoryStats } from '@/lib/file-parsers/zip-guard' -import { ensureWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' -import { - deleteWorkspaceFile, - uploadWorkspaceFile, -} from '@/lib/uploads/contexts/workspace/workspace-file-manager' import type { WorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' +import { createWorkspaceFileFromBuffer } from '@/lib/workspace-files/application/create-workspace-file' +import { deleteWorkspaceFileOperation } from '@/lib/workspace-files/application/delete-workspace-file' +import { createWorkspaceFileFolderOperation } from '@/lib/workspace-files/application/workspace-file-folders' import type { UserFile } from '@/executor/types' /** @@ -261,7 +260,7 @@ export async function decompressArchiveBufferToWorkspaceFiles( buffer: Buffer, opts: { workspaceId: string - userId: string + principal: Principal rootFolderSegments?: string[] skipNoiseEntries?: boolean secretProvenance?: WorkspaceFileSecretProvenance @@ -269,7 +268,7 @@ export async function decompressArchiveBufferToWorkspaceFiles( ): Promise { const { workspaceId, - userId, + principal, rootFolderSegments = [], skipNoiseEntries = false, secretProvenance = { status: 'unknown' }, @@ -357,32 +356,50 @@ export async function decompressArchiveBufferToWorkspaceFiles( const folderKey = folderSegments.join('/') let folderId = folderIdCache.get(folderKey) if (folderId === undefined) { - folderId = await ensureWorkspaceFileFolderPath({ - workspaceId, - userId, - pathSegments: folderSegments, - }) + if (folderSegments.length === 0) { + folderId = null + } else { + const result = await createWorkspaceFileFolderOperation.execute({ + principal, + input: { workspaceId, path: folderSegments.join('/') }, + }) + folderId = result.folder.id + } folderIdCache.set(folderKey, folderId) } const mimeType = getMimeTypeFromExtension(getFileExtension(leafName)) - const uploaded = await uploadWorkspaceFile( - workspaceId, - userId, - entryBuffer, - leafName, - mimeType, - { - folderId, - secretProvenance, - } - ) - extracted.push(uploaded) + const uploaded = ( + await createWorkspaceFileFromBuffer.execute({ + principal, + input: { + workspaceId, + content: entryBuffer, + name: leafName, + contentType: mimeType, + folderId, + exactName: true, + secretProvenance, + }, + }) + ).file + extracted.push({ + id: uploaded.id, + name: uploaded.name, + url: uploaded.url ?? uploaded.path, + size: uploaded.size, + type: uploaded.type, + key: uploaded.key, + context: 'workspace', + }) } } catch (error) { for (const file of extracted) { try { - await deleteWorkspaceFile(workspaceId, file.id) + await deleteWorkspaceFileOperation.execute({ + principal, + input: { fileId: file.id, assertedWorkspaceId: workspaceId }, + }) } catch { // Best-effort: a file whose cleanup fails is still soft-deletable by hand; // the original error is what the caller needs to see. diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts index 8ca14de9e32..afbc09c7cb4 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { folder as folderTable, workspaceFiles } from '@sim/db/schema' +import { folder as folderTable, workspaceFiles, workspace as workspaceTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage, getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' @@ -16,6 +16,7 @@ import { requireNonRootFolderPath, } from '@/lib/folders/paths' import { collectDescendantFolderIds } from '@/lib/folders/subtree' +import { MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS } from '@/lib/workspace-files/limits' import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' const logger = createLogger('WorkspaceFileFolders') @@ -72,6 +73,36 @@ export interface WorkspaceFileFolderRecord { updatedAt: Date } +export interface WorkspaceFileOperationContext { + workspaceId: string + workspaceOrganizationId: string | null + allowPersonalApiKeys: boolean + billedAccountUserId: string +} + +/** + * Loads the active workspace authorization context for folder and bulk-file operations. + * The workspace row is the canonical scope; callers must not authorize from a caller-supplied + * folder or file workspace id. + */ +export async function loadWorkspaceFileOperationContext( + workspaceId: string +): Promise { + const workspace = await getWorkspaceWithOwner(workspaceId) + if (!workspace) return null + const [settings] = await db + .select({ allowPersonalApiKeys: workspaceTable.allowPersonalApiKeys }) + .from(workspaceTable) + .where(eq(workspaceTable.id, workspaceId)) + .limit(1) + return { + workspaceId: workspace.id, + workspaceOrganizationId: workspace.organizationId, + allowPersonalApiKeys: settings?.allowPersonalApiKeys ?? false, + billedAccountUserId: workspace.billedAccountUserId, + } +} + interface RawWorkspaceFileFolder { id: string workspaceId: string @@ -89,6 +120,64 @@ export interface WorkspaceFileArchiveResult { files: number } +function assertBulkAffectedItemsWithinLimit(count: number): void { + if (count > MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS) { + throw new OrchestrationError( + 'validation', + `File operation affects more than ${MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS} items` + ) + } +} + +/** + * Verifies every requested active file/folder belongs to this workspace before a bulk mutation. + * This prevents the bulk archive primitive's workspace predicate from silently turning an + * out-of-scope id into a successful zero-row operation. + */ +export async function assertWorkspaceFileItemsBelongToWorkspace(params: { + workspaceId: string + fileIds?: string[] + folderIds?: string[] +}): Promise { + const fileIds = Array.from(new Set(params.fileIds ?? [])) + const folderIds = Array.from(new Set(params.folderIds ?? [])) + const [files, folders] = await Promise.all([ + fileIds.length === 0 + ? Promise.resolve([]) + : db + .select({ id: workspaceFiles.id }) + .from(workspaceFiles) + .where( + and( + inArray(workspaceFiles.id, fileIds), + eq(workspaceFiles.workspaceId, params.workspaceId), + eq(workspaceFiles.context, 'workspace'), + isNull(workspaceFiles.deletedAt) + ) + ), + folderIds.length === 0 + ? Promise.resolve([]) + : db + .select({ id: folderTable.id }) + .from(folderTable) + .where( + and( + inArray(folderTable.id, folderIds), + eq(folderTable.workspaceId, params.workspaceId), + isFileFolder, + isNull(folderTable.deletedAt) + ) + ), + ]) + const foundFiles = new Set(files.map((file) => file.id)) + const foundFolders = new Set(folders.map((folder) => folder.id)) + const missingFiles = fileIds.filter((id) => !foundFiles.has(id)) + const missingFolders = folderIds.filter((id) => !foundFolders.has(id)) + if (missingFiles.length > 0 || missingFolders.length > 0) { + throw new WorkspaceFileItemsNotFoundError(missingFiles, missingFolders) + } +} + export interface WorkspaceFileFolderRestoreResult { folder: WorkspaceFileFolderRecord restoredItems: WorkspaceFileArchiveResult @@ -777,9 +866,16 @@ export async function moveWorkspaceFileItems(params: { isNull(folderTable.deletedAt) ) ) + .limit(MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS + 1) + + assertBulkAffectedItemsWithinLimit(activeFolders.length) + + const affectedFolderIds = new Set() for (const folderId of folderIds) { const descendants = collectDescendantFolderIds(activeFolders, folderId) + affectedFolderIds.add(folderId) + for (const descendantId of descendants) affectedFolderIds.add(descendantId) if (targetFolderId && descendants.includes(targetFolderId)) { throw new OrchestrationError( 'validation', @@ -787,6 +883,24 @@ export async function moveWorkspaceFileItems(params: { ) } } + + assertBulkAffectedItemsWithinLimit(affectedFolderIds.size + fileIds.length) + if (affectedFolderIds.size > 0) { + const descendantFiles = await tx + .select({ id: workspaceFiles.id }) + .from(workspaceFiles) + .where( + and( + inArray(workspaceFiles.folderId, [...affectedFolderIds]), + eq(workspaceFiles.workspaceId, params.workspaceId), + eq(workspaceFiles.context, 'workspace'), + isNull(workspaceFiles.deletedAt) + ) + ) + .limit(MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS + 1) + const affectedFileIds = new Set([...fileIds, ...descendantFiles.map((file) => file.id)]) + assertBulkAffectedItemsWithinLimit(affectedFolderIds.size + affectedFileIds.size) + } } const movingFiles = @@ -943,7 +1057,24 @@ export async function archiveWorkspaceFileFolderRecursive( .where( and(eq(folderTable.workspaceId, workspaceId), isFileFolder, isNull(folderTable.deletedAt)) ) + .limit(MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS + 1) + assertBulkAffectedItemsWithinLimit(activeFolders.length) const folderIds = [folderId, ...collectDescendantFolderIds(activeFolders, folderId)] + assertBulkAffectedItemsWithinLimit(folderIds.length) + + const affectedFiles = await tx + .select({ id: workspaceFiles.id }) + .from(workspaceFiles) + .where( + and( + inArray(workspaceFiles.folderId, folderIds), + eq(workspaceFiles.workspaceId, workspaceId), + eq(workspaceFiles.context, 'workspace'), + isNull(workspaceFiles.deletedAt) + ) + ) + .limit(MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS + 1) + assertBulkAffectedItemsWithinLimit(folderIds.length + affectedFiles.length) const archivedFiles = await tx .update(workspaceFiles) @@ -1069,6 +1200,7 @@ export async function restoreWorkspaceFileFolder( ) .returning({ id: workspaceFiles.id }) stats.files += restoredFiles.length + assertBulkAffectedItemsWithinLimit(stats.files + stats.folders) const archivedChildren = await tx .select({ id: folderTable.id }) @@ -1081,6 +1213,8 @@ export async function restoreWorkspaceFileFolder( eq(folderTable.deletedAt, folderDeletedAt) ) ) + .limit(MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS + 1) + assertBulkAffectedItemsWithinLimit(stats.files + stats.folders + archivedChildren.length) for (const child of archivedChildren) { const [restoredChild] = await tx @@ -1098,6 +1232,7 @@ export async function restoreWorkspaceFileFolder( if (!restoredChild) continue stats.folders += 1 + assertBulkAffectedItemsWithinLimit(stats.files + stats.folders) await restoreFolderSubtree(child.id) } } @@ -1116,6 +1251,7 @@ export async function restoreWorkspaceFileFolder( .returning() stats.folders += 1 + assertBulkAffectedItemsWithinLimit(stats.files + stats.folders) await restoreFolderSubtree(folderId) return { restored: row, restoredItems: stats } @@ -1160,11 +1296,32 @@ export async function bulkArchiveWorkspaceFileItems(params: { isNull(folderTable.deletedAt) ) ) + .limit(MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS + 1) : [] + assertBulkAffectedItemsWithinLimit(activeFolders.length) const descendantFolderIds = explicitFolderIds.flatMap((folderId) => collectDescendantFolderIds(activeFolders, folderId) ) const allFolderIds = Array.from(new Set([...explicitFolderIds, ...descendantFolderIds])) + assertBulkAffectedItemsWithinLimit(allFolderIds.length + explicitFileIds.length) + + const descendantFiles = + allFolderIds.length > 0 + ? await tx + .select({ id: workspaceFiles.id }) + .from(workspaceFiles) + .where( + and( + inArray(workspaceFiles.folderId, allFolderIds), + eq(workspaceFiles.workspaceId, params.workspaceId), + eq(workspaceFiles.context, 'workspace'), + isNull(workspaceFiles.deletedAt) + ) + ) + .limit(MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS + 1) + : [] + const affectedFileIds = new Set([...explicitFileIds, ...descendantFiles.map((file) => file.id)]) + assertBulkAffectedItemsWithinLimit(allFolderIds.length + affectedFileIds.size) const archivedExplicitFiles = explicitFileIds.length > 0 diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts index f7fac1dd70b..bd5075549cd 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -5,7 +5,7 @@ import { randomBytes } from 'crypto' import { db } from '@sim/db' -import { workspace, workspaceFiles } from '@sim/db/schema' +import { uploadSession, workspace, workspaceFiles } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { describeError, @@ -143,6 +143,17 @@ export interface ActiveWorkspaceFileContext { billedAccountUserId: string } +export interface WorkspaceFileLifecycleContext extends ActiveWorkspaceFileContext { + deletedAt: Date | null +} + +export interface ActiveWorkspaceContext { + workspaceId: string + workspaceOrganizationId: string | null + allowPersonalApiKeys: boolean + billedAccountUserId: string +} + interface ListWorkspaceFilesOptions { scope?: WorkspaceFileScope folders?: WorkspaceFileFolderRecord[] @@ -559,6 +570,7 @@ export async function registerUploadedWorkspaceFile(params: { originalName: string contentType: string folderId?: string | null + uploadSessionId?: string }): Promise { const { workspaceId, userId, key, originalName, contentType } = params const normalizedOriginalName = normalizeWorkspaceFileItemName(originalName, 'File') @@ -600,6 +612,7 @@ export async function registerUploadedWorkspaceFile(params: { EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE ) } + await markUploadSessionFileRegistered(tx, params.uploadSessionId, workspaceId, found.id) return found }) if (existing) { @@ -658,6 +671,12 @@ export async function registerUploadedWorkspaceFile(params: { EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE ) } + await markUploadSessionFileRegistered( + tx, + params.uploadSessionId, + workspaceId, + raceWinner.id + ) return { kind: 'existing', file: raceWinner } as const } @@ -672,6 +691,7 @@ export async function registerUploadedWorkspaceFile(params: { inserted.contentUpdatedAt, EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE ) + await markUploadSessionFileRegistered(tx, params.uploadSessionId, workspaceId, inserted.id) return { kind: 'created', file: inserted, updatedUsage } as const }) @@ -704,6 +724,29 @@ export async function registerUploadedWorkspaceFile(params: { throw new FileConflictError(normalizedOriginalName) } +async function markUploadSessionFileRegistered( + tx: DbOrTx, + uploadSessionId: string | undefined, + workspaceId: string, + fileId: string +): Promise { + if (!uploadSessionId) return + const [marked] = await tx + .update(uploadSession) + .set({ completedFileId: fileId, updatedAt: new Date() }) + .where( + and( + eq(uploadSession.id, uploadSessionId), + eq(uploadSession.workspaceId, workspaceId), + eq(uploadSession.purpose, 'workspace_file'), + eq(uploadSession.status, 'finalizing'), + or(isNull(uploadSession.completedFileId), eq(uploadSession.completedFileId, fileId)) + ) + ) + .returning({ id: uploadSession.id }) + if (!marked) throw new Error('Workspace upload registration marker could not be persisted') +} + function assertActiveWorkspaceFileRegistration(file: typeof workspaceFiles.$inferSelect): void { if (file.deletedAt) { throw new OrchestrationError('conflict', 'Upload result was deleted') @@ -1333,7 +1376,7 @@ export async function resolveWorkspaceFileReference( ): Promise { const normalizedReference = normalizeWorkspaceFileReference(fileReference) if (normalizedReference.startsWith('wf_')) { - const file = await getWorkspaceFile(workspaceId, normalizedReference) + const file = await getWorkspaceFile(workspaceId, normalizedReference, { throwOnError: true }) if (file) return file } @@ -1352,7 +1395,8 @@ export async function resolveWorkspaceFileReference( * Database failures propagate so callers never confuse unavailable state with a missing file. */ export async function loadActiveWorkspaceFileContext( - fileId: string + fileId: string, + options?: { includeDeleted?: boolean } ): Promise { const [context] = await db .select({ @@ -1368,7 +1412,7 @@ export async function loadActiveWorkspaceFileContext( and( eq(workspaceFiles.id, fileId), eq(workspaceFiles.context, 'workspace'), - isNull(workspaceFiles.deletedAt), + ...(options?.includeDeleted ? [] : [isNull(workspaceFiles.deletedAt)]), isNull(workspace.archivedAt) ) ) @@ -1377,6 +1421,54 @@ export async function loadActiveWorkspaceFileContext( return context ?? null } +/** + * Load a workspace file for a lifecycle transition, including archived files. + * The workspace archive state is returned by the canonical workspace record and is enforced by + * the operation's manager primitive where the transition requires an active workspace. + */ +export async function loadWorkspaceFileLifecycleContext( + fileId: string +): Promise { + const [context] = await db + .select({ + fileId: workspaceFiles.id, + workspaceId: workspace.id, + workspaceOrganizationId: workspace.organizationId, + allowPersonalApiKeys: workspace.allowPersonalApiKeys, + billedAccountUserId: workspace.billedAccountUserId, + deletedAt: workspaceFiles.deletedAt, + }) + .from(workspaceFiles) + .innerJoin(workspace, eq(workspaceFiles.workspaceId, workspace.id)) + .where(and(eq(workspaceFiles.id, fileId), eq(workspaceFiles.context, 'workspace'))) + .limit(1) + + return context ?? null +} + +/** + * Load the canonical authorization context for an active workspace. + * + * The query deliberately throws database failures so callers cannot mistake an unavailable + * workspace for a missing one. Authentication and authorization remain the caller's concern. + */ +export async function loadActiveWorkspaceContext( + workspaceId: string +): Promise { + const [context] = await db + .select({ + workspaceId: workspace.id, + workspaceOrganizationId: workspace.organizationId, + allowPersonalApiKeys: workspace.allowPersonalApiKeys, + billedAccountUserId: workspace.billedAccountUserId, + }) + .from(workspace) + .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) + .limit(1) + + return context ?? null +} + /** * Get a specific workspace file. * @@ -1919,7 +2011,7 @@ export async function deleteWorkspaceFile(workspaceId: string, fileId: string): logger.info(`Successfully archived workspace file: ${archived.originalName}`) } catch (error) { logger.error(`Failed to delete workspace file ${fileId}:`, error) - throw new Error(`Failed to delete file: ${getErrorMessage(error, 'Unknown error')}`) + throw error } } diff --git a/apps/sim/lib/uploads/upload-session/application.ts b/apps/sim/lib/uploads/upload-session/application.ts new file mode 100644 index 00000000000..4f403a52cfd --- /dev/null +++ b/apps/sim/lib/uploads/upload-session/application.ts @@ -0,0 +1,350 @@ +import type { Principal } from '@sim/auth/principal' +import type { CreateInternalFileUploadBody } from '@/lib/api/contracts/upload-sessions' +import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { loadActiveFolderPathIndex, resolveFolderPathFromIndex } from '@/lib/folders/queries' +import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' +import { + abortUploadSession, + assertUploadSessionAuthBinding, + completeUploadSession, + createUploadPartUrls, + createUploadSession, + getOwnedUploadSession, + getPrincipalUploadSession, + type UploadSessionRecord, + type UploadSessionTransfer, +} from '@/lib/uploads/upload-session/service' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { authorizeWorkspaceFileOperation } from '@/lib/workspace-files/application/workspace-operation-context' +import { + finalizeUploadPurpose, + finalizeWorkspaceFileUpload, + loadCompletedUploadPurpose, + loadCompletedWorkspaceFileUpload, + type UploadActor, +} from '@/app/api/files/uploads/finalizers' +import { + createPurposeUploadSession, + reauthorizeUploadPurpose, + reauthorizeWorkspaceUploadPurpose, + resolveUploadAttributionUserId, +} from '@/app/api/files/uploads/purposes' + +export interface WorkspaceFileUploadCreateInput { + workspaceId: string + name: string + contentType: string + size: number + folderId?: string | null + localOrigin: string +} + +export interface UploadSessionControlInput { + uploadId: string + uploadToken: string + workspaceId?: string + localOrigin?: string + partNumbers?: number[] + actor?: UploadActor +} + +export interface InternalUploadSessionControlInput extends UploadSessionControlInput { + partNumbers?: number[] +} + +export interface UploadSessionCreateResult { + session: Awaited> +} + +/** Creates a workspace-file session after current principal authorization. */ +export async function createWorkspaceFileUploadSession( + principal: Principal, + input: WorkspaceFileUploadCreateInput +): Promise>> { + const userId = await resolveUploadAttributionUserId(principal, input.workspaceId) + return createUploadSession({ + purpose: 'workspace_file', + workspaceId: input.workspaceId, + userId, + principal, + fileName: input.name, + contentType: input.contentType, + fileSize: input.size, + metadata: { folderId: input.folderId ?? null }, + localOrigin: input.localOrigin, + }) +} + +/** Internal purpose-aware create use case; authentication remains at the route adapter. */ +export async function createInternalPurposeUploadSession( + principal: Principal, + body: CreateInternalFileUploadBody, + request: OrchestrationRequestContext +): Promise>> { + return createPurposeUploadSession(principal, body, requestOrigin(request)) +} + +/** Loads an internal session while binding workspace-file sessions to the principal. */ +export async function loadAuthorizedInternalUploadSession( + principal: Principal, + input: UploadSessionControlInput +): Promise { + if (input.workspaceId !== undefined) return loadAuthorizedWorkspaceUploadSession(principal, input) + const session = await getOwnedUploadSession({ + uploadId: input.uploadId, + uploadToken: input.uploadToken, + userId: principalUserId(principal), + }) + if (session.purpose === 'workspace_file') assertUploadSessionAuthBinding(session, principal) + return session +} + +export async function issueInternalUploadPartUrls( + principal: Principal, + input: InternalUploadSessionControlInput, + request: OrchestrationRequestContext +): Promise<{ parts: Awaited> }> { + const session = await loadAuthorizedInternalUploadSession(principal, input) + if (session.purpose === 'workspace_file') { + await reauthorizeWorkspaceUploadPurpose(principal, session, fileOperations.uploadParts) + } else { + await reauthorizeUploadPurpose(principalUserId(principal), session) + } + return { + parts: await createUploadPartUrls({ + session, + partNumbers: input.partNumbers ?? [], + localOrigin: requestOrigin(request), + }), + } +} + +export async function abortInternalUploadSession( + principal: Principal, + input: UploadSessionControlInput +): Promise { + const session = await loadAuthorizedInternalUploadSession(principal, input) + if (session.purpose === 'workspace_file') { + await reauthorizeWorkspaceUploadPurpose(principal, session, fileOperations.uploadCancel) + } else { + await reauthorizeUploadPurpose(principalUserId(principal), session) + } + return abortUploadSession(session) +} + +export async function completeInternalUploadSession( + principal: Principal, + input: UploadSessionControlInput, + request: OrchestrationRequestContext +): Promise<{ + session: UploadSessionRecord + value: import('@/app/api/files/uploads/finalizers').UploadPurposeResult + alreadyCompleted: boolean +}> { + const session = await loadAuthorizedInternalUploadSession(principal, input) + const authorize = async (claimed: UploadSessionRecord) => { + if (claimed.purpose === 'workspace_file') { + await reauthorizeWorkspaceUploadPurpose(principal, claimed, fileOperations.uploadComplete) + } else { + await reauthorizeUploadPurpose(principalUserId(principal), claimed) + } + } + await authorize(session) + const actor: UploadActor = { id: principalUserId(principal) } + return completeUploadSession({ + session, + loadCompleted: async (claimed) => { + await authorize(claimed) + return loadCompletedUploadPurpose(claimed) + }, + finalize: async (claimed) => { + await authorize(claimed) + const finalized = await finalizeUploadPurpose({ + session: claimed, + actor, + request, + principal, + authorizeBeforeRegistration: () => authorize(claimed), + }) + return { value: finalized.value, completedFileId: finalized.completedFileId } + }, + }) +} + +/** Loads and binds a workspace-file session to the fresh authenticated principal. */ +export async function loadAuthorizedWorkspaceUploadSession( + principal: Principal, + input: UploadSessionControlInput +): Promise { + return getPrincipalUploadSession({ + uploadId: input.uploadId, + uploadToken: input.uploadToken, + principal, + workspaceId: input.workspaceId, + }) +} + +/** Issues multipart URLs after current workspace authorization. */ +export async function issueWorkspaceUploadPartUrls( + principal: Principal, + input: UploadSessionControlInput +): Promise<{ parts: Awaited> }> { + const session = await loadAuthorizedWorkspaceUploadSession(principal, input) + await reauthorizeWorkspaceUploadPurpose(principal, session, fileOperations.uploadParts) + if (!input.localOrigin) throw new Error('Upload part URL issuance requires a local origin') + const parts = await createUploadPartUrls({ + session, + partNumbers: input.partNumbers ?? [], + localOrigin: input.localOrigin, + }) + return { parts } +} + +/** Aborts an upload after current workspace authorization. */ +export async function abortWorkspaceUploadSession( + principal: Principal, + input: UploadSessionControlInput +): Promise { + const session = await loadAuthorizedWorkspaceUploadSession(principal, input) + await reauthorizeWorkspaceUploadPurpose(principal, session, fileOperations.uploadCancel) + return abortUploadSession(session) +} + +/** + * Completes an upload and re-checks authorization immediately before durable + * workspace-file registration. Completed retries use the durable session/file + * result through the finalizer rather than registering a second file. + */ +export async function completeWorkspaceUploadSession( + principal: Principal, + input: UploadSessionControlInput, + request: OrchestrationRequestContext +): Promise<{ + session: UploadSessionRecord + value: WorkspaceFileRecord + alreadyCompleted: boolean +}> { + const session = await loadAuthorizedWorkspaceUploadSession(principal, input) + await reauthorizeWorkspaceUploadPurpose(principal, session, fileOperations.uploadComplete) + const actor: UploadActor = input.actor ?? { + id: await resolveUploadAttributionUserId(principal, session.workspaceId ?? ''), + } + return completeUploadSession({ + session, + loadCompleted: async (claimed) => { + await reauthorizeWorkspaceUploadPurpose(principal, claimed, fileOperations.uploadComplete) + return loadCompletedWorkspaceFileUpload(claimed) + }, + finalize: async (claimed) => { + await reauthorizeWorkspaceUploadPurpose(principal, claimed, fileOperations.uploadComplete) + const finalized = await finalizeWorkspaceFileUpload({ + session: claimed, + actor, + request, + source: 'api', + principal, + authorizeBeforeRegistration: async () => { + await reauthorizeWorkspaceUploadPurpose(principal, claimed, fileOperations.uploadComplete) + }, + }) + return { value: finalized.file, completedFileId: finalized.file.id } + }, + }) +} + +export interface CreateWorkspaceFileUploadOperationInput { + workspaceId: string + name: string + contentType: string + size: number + folderPath: string +} + +export const createWorkspaceFileUploadOperation = { + operation: fileOperations.uploadCreate, + async execute({ + principal, + input, + request, + }: { + principal: Principal + input: CreateWorkspaceFileUploadOperationInput + request?: OrchestrationRequestContext + }) { + if (!request) throw new Error('Workspace upload creation requires a request context') + await authorizeWorkspaceFileOperation(principal, fileOperations.uploadCreate, input.workspaceId) + const folderIndex = await loadActiveFolderPathIndex(input.workspaceId, 'file') + const folderId = resolveFolderPathFromIndex(folderIndex, input.folderPath) + if (folderId === undefined) throw new OrchestrationError('not_found', 'Folder not found') + return createWorkspaceFileUploadSession(principal, { + workspaceId: input.workspaceId, + name: input.name, + contentType: input.contentType, + size: input.size, + folderId, + localOrigin: requestOrigin(request), + }) + }, +} as const + +export const issueWorkspaceFileUploadPartsOperation = { + operation: fileOperations.uploadParts, + async execute({ + principal, + input, + request, + }: { + principal: Principal + input: UploadSessionControlInput + request?: OrchestrationRequestContext + }) { + if (!request) throw new Error('Upload part URL issuance requires a request context') + return issueWorkspaceUploadPartUrls(principal, { + ...input, + localOrigin: requestOrigin(request), + }) + }, +} as const + +export const completeWorkspaceFileUploadOperation = { + operation: fileOperations.uploadComplete, + async execute({ + principal, + input, + request, + }: { + principal: Principal + input: UploadSessionControlInput + request?: OrchestrationRequestContext + }) { + if (!request) throw new Error('Upload completion requires a request context') + return completeWorkspaceUploadSession(principal, input, request) + }, +} as const + +export const abortWorkspaceFileUploadOperation = { + operation: fileOperations.uploadCancel, + async execute({ principal, input }: { principal: Principal; input: UploadSessionControlInput }) { + return abortWorkspaceUploadSession(principal, input) + }, +} as const + +function principalUserId(principal: Principal): string { + if (principal.kind === 'session' || principal.kind === 'personal_api_key') { + return principal.userId + } + throw new Error('Workspace upload attribution must be resolved from the current workspace owner') +} + +export function requestOrigin(request: OrchestrationRequestContext & { nextUrl?: URL }): string { + if (request.nextUrl instanceof URL) return request.nextUrl.origin + const origin = request.headers.get('origin') + if (origin) return origin + const host = request.headers.get('x-forwarded-host') ?? request.headers.get('host') + const protocol = request.headers.get('x-forwarded-proto') ?? 'https' + if (!host) throw new Error('Upload signing requires a request origin') + return `${protocol}://${host}` +} + +export type { UploadSessionTransfer } diff --git a/apps/sim/lib/uploads/upload-session/service.test.ts b/apps/sim/lib/uploads/upload-session/service.test.ts index 1e7b16256c6..ee0691c6eb8 100644 --- a/apps/sim/lib/uploads/upload-session/service.test.ts +++ b/apps/sim/lib/uploads/upload-session/service.test.ts @@ -3,7 +3,7 @@ */ import { sha256Hex } from '@sim/security/hash' import { dbChainMockFns, queueTableRows, resetDbChainMock, schemaMock } from '@sim/testing' -import { inArray } from 'drizzle-orm' +import { eq, inArray, isNull } from 'drizzle-orm' import { beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -110,6 +110,32 @@ describe('upload sessions', () => { expect(inserted.tokenHash).toBe(sha256Hex(created.uploadToken)) expect(inserted.tokenHash).not.toBe(created.uploadToken) expect(inserted.finalKey).toBe(FINAL_KEY) + expect(inserted.metadata.authBinding).toEqual({ + version: 1, + workspaceId: WORKSPACE_ID, + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + }) + }) + + it('rejects workspace control access without the matching immutable credential binding', async () => { + const row = uploadRow({ + metadata: { + authBinding: { + version: 1, + workspaceId: WORKSPACE_ID, + principal: { kind: 'workspace_api_key', workspaceId: WORKSPACE_ID, keyId: 'key-1' }, + }, + }, + }) + queueTableRows(schemaMock.uploadSession, [row]) + + await expect( + getOwnedUploadSession({ + uploadId: row.id, + uploadToken: 'upload-secret', + principal: { kind: 'workspace_api_key', workspaceId: WORKSPACE_ID, keyId: 'key-2' }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) }) it('initiates multipart storage directly at the final key', async () => { @@ -248,6 +274,52 @@ describe('upload sessions', () => { ).resolves.toMatchObject({ value: 'recovered', alreadyCompleted: true }) }) + it('loads a durable finalizing result without re-running the finalizer', async () => { + const session = sessionRecord({ + status: 'finalizing', + expiresAt: new Date(Date.now() - 1), + providerObjectVersion: 'version-1', + completedFileId: 'file-1', + }) + dbChainMockFns.returning + .mockResolvedValueOnce([ + uploadRow({ + ...rowGeometry(session), + status: 'finalizing', + completedFileId: 'file-1', + }), + ]) + .mockResolvedValueOnce([ + uploadRow({ + ...rowGeometry(session), + status: 'completed', + completedFileId: 'file-1', + completedAt: new Date(), + }), + ]) + const finalize = vi.fn() + const loadCompleted = vi.fn().mockResolvedValue('recovered-file') + + await expect( + completeUploadSession({ session, finalize, loadCompleted }) + ).resolves.toMatchObject({ value: 'recovered-file', alreadyCompleted: true }) + expect(loadCompleted).toHaveBeenCalledOnce() + expect(finalize).not.toHaveBeenCalled() + expect(mockHeadObject).not.toHaveBeenCalled() + }) + + it('loads an already-completed durable result without re-running the finalizer', async () => { + const session = sessionRecord({ status: 'completed', completedFileId: 'file-1' }) + const finalize = vi.fn() + const loadCompleted = vi.fn().mockResolvedValue('completed-file') + + await expect( + completeUploadSession({ session, finalize, loadCompleted }) + ).resolves.toMatchObject({ value: 'completed-file', alreadyCompleted: true }) + expect(loadCompleted).toHaveBeenCalledOnce() + expect(finalize).not.toHaveBeenCalled() + }) + it('deletes a matching completed provider object without aborting its consumed upload id', async () => { const session = sessionRecord({ method: 'multipart', @@ -295,17 +367,47 @@ describe('upload sessions', () => { expect(mockDeleteObjectVersion).not.toHaveBeenCalled() }) - it('refuses to abort once domain finalization may have created a resource', async () => { - const session = sessionRecord({ status: 'finalizing' }) + it('refuses to abort once domain finalization has registered a resource', async () => { + const session = sessionRecord({ status: 'finalizing', completedFileId: 'file-1' }) await expect(abortUploadSession(session)).rejects.toThrow( - 'Finalizing upload sessions cannot be aborted' + 'Finalizing upload sessions with a registered file cannot be aborted' ) expect(mockAbortProviderUpload).not.toHaveBeenCalled() expect(mockDeleteObjectVersion).not.toHaveBeenCalled() }) - it('cleans expired upload state without selecting sessions that may be finalizing', async () => { + it('aborts a finalizing session whose durable registration never committed', async () => { + const session = sessionRecord({ + status: 'finalizing', + providerObjectVersion: 'version-1', + completedFileId: null, + }) + mockHeadObject.mockResolvedValue(providerObject(session, 'version-1')) + dbChainMockFns.returning + .mockResolvedValueOnce([ + uploadRow({ + ...rowGeometry(session), + status: 'aborting', + providerObjectVersion: 'version-1', + }), + ]) + .mockResolvedValueOnce([ + uploadRow({ + ...rowGeometry(session), + status: 'aborted', + providerObjectVersion: 'version-1', + completedAt: new Date(), + }), + ]) + + await expect(abortUploadSession(session)).resolves.toMatchObject({ status: 'aborted' }) + expect(mockDeleteObjectVersion).toHaveBeenCalledWith( + expect.objectContaining({ key: FINAL_KEY, version: 'version-1' }) + ) + }) + + it('cleans expired upload state including unregistered finalizing sessions', async () => { const expired = uploadRow({ expiresAt: new Date(Date.now() - 1) }) queueTableRows(schemaMock.uploadSession, [expired]) queueTableRows(schemaMock.uploadSession, []) @@ -326,7 +428,8 @@ describe('upload sessions', () => { .mocked(inArray) .mock.calls.find(([, values]) => values.includes('uploading'))?.[1] expect(candidateStatuses).toEqual(['uploading', 'completing', 'aborting']) - expect(candidateStatuses).not.toContain('finalizing') + expect(vi.mocked(eq)).toHaveBeenCalledWith(schemaMock.uploadSession.status, 'finalizing') + expect(vi.mocked(isNull)).toHaveBeenCalledWith(schemaMock.uploadSession.completedFileId) }) it('deletes a late PUT object before purging an aborted session', async () => { @@ -355,6 +458,11 @@ async function createWorkspaceUpload(fileSize: number) { id: 'upload-1', workspaceId: WORKSPACE_ID, userId: 'user-1', + principal: { + kind: 'session', + userId: 'user-1', + sessionId: 'session-1', + }, purpose: 'workspace_file', fileName: 'file.bin', contentType: 'application/octet-stream', diff --git a/apps/sim/lib/uploads/upload-session/service.ts b/apps/sim/lib/uploads/upload-session/service.ts index 325c48f58ec..10433588331 100644 --- a/apps/sim/lib/uploads/upload-session/service.ts +++ b/apps/sim/lib/uploads/upload-session/service.ts @@ -1,3 +1,4 @@ +import type { Principal } from '@sim/auth/principal' import { db, dbFor } from '@sim/db' import { uploadSession } from '@sim/db/schema' import { safeCompare } from '@sim/security/compare' @@ -90,6 +91,23 @@ export interface UploadSessionRecord { updatedAt: Date } +/** + * The credential that was authorized to create a workspace-file upload. + * + * This is deliberately kept in the existing JSON metadata column. It is + * server-authored and immutable for the lifetime of the session; the upload + * token only proves possession of the byte-plane capability and never grants + * workspace access by itself. + */ +export interface UploadSessionAuthBinding { + version: 1 + workspaceId: string + principal: + | { kind: 'session'; userId: string; sessionId: string } + | { kind: 'personal_api_key'; userId: string; keyId: string } + | { kind: 'workspace_api_key'; workspaceId: string; keyId: string } +} + export interface CreatedUploadSession extends UploadSessionRecord { transfer: UploadSessionTransfer } @@ -112,6 +130,7 @@ interface CreateUploadSessionBaseParams { fileSize: number metadata?: Record localOrigin?: string + principal?: Principal } export type CreateUploadSessionParams = CreateUploadSessionBaseParams & @@ -137,6 +156,14 @@ export async function createUploadSession( const id = params.id ?? generateId() const uploadToken = generateSecureToken(32) const workspaceId = params.purpose === 'profile_picture' ? null : params.workspaceId + const metadata = { ...(params.metadata ?? {}) } + if (params.purpose === 'workspace_file') { + if (!workspaceId) throw new Error('Workspace-file upload is missing workspaceId') + if (!params.principal) { + throw new Error('Workspace-file upload requires an authenticated principal') + } + metadata.authBinding = createUploadSessionAuthBinding(params.principal, workspaceId) + } const { storageContext, finalKey } = resolveUploadStorage(params, id) const method: UploadTransferMethod = params.fileSize <= UPLOAD_SESSION_PUT_MAX_BYTES ? 'put' : 'multipart' @@ -206,7 +233,7 @@ export async function createUploadSession( fileSize: params.fileSize, partSize, partCount, - metadata: params.metadata ?? {}, + metadata, createdAt, expiresAt, updatedAt: createdAt, @@ -265,6 +292,7 @@ export async function getOwnedUploadSession(params: { knowledgeBaseId?: string workflowId?: string executionId?: string + principal?: Principal }): Promise { const [row] = await db .select() @@ -287,9 +315,93 @@ export async function getOwnedUploadSession(params: { if (params.executionId !== undefined && session.executionId !== params.executionId) { throw uploadNotFound() } + if (params.principal && session.purpose === 'workspace_file') { + assertUploadSessionAuthBinding(session, params.principal) + } + return session +} + +/** + * Loads a session using the signed token and verifies the immutable principal + * binding for workspace-file control-plane requests. + */ +export async function getPrincipalUploadSession(params: { + uploadId: string + uploadToken: string + principal: Principal + workspaceId?: string +}): Promise { + const session = await getOwnedUploadSession({ + uploadId: params.uploadId, + uploadToken: params.uploadToken, + workspaceId: params.workspaceId, + purpose: 'workspace_file', + principal: params.principal, + }) return session } +export function createUploadSessionAuthBinding( + principal: Principal, + workspaceId: string +): UploadSessionAuthBinding { + switch (principal.kind) { + case 'session': + return { + version: 1, + workspaceId, + principal: { + kind: principal.kind, + userId: principal.userId, + sessionId: principal.sessionId, + }, + } + case 'personal_api_key': + return { + version: 1, + workspaceId, + principal: { kind: principal.kind, userId: principal.userId, keyId: principal.keyId }, + } + case 'workspace_api_key': + if (principal.workspaceId !== workspaceId) { + throw new UploadSessionError('forbidden', 'Workspace API key cannot access this workspace') + } + return { + version: 1, + workspaceId, + principal: { kind: principal.kind, workspaceId, keyId: principal.keyId }, + } + case 'delegated': + throw new UploadSessionError('forbidden', 'Delegated principals cannot create uploads') + } +} + +export function assertUploadSessionAuthBinding( + session: UploadSessionRecord, + principal: Principal +): void { + if (session.purpose !== 'workspace_file') return + const candidate = session.metadata.authBinding + if (!isUploadSessionAuthBinding(candidate) || candidate.workspaceId !== session.workspaceId) { + throw uploadNotFound() + } + const bound = candidate.principal + const matches = + bound.kind === principal.kind && + (bound.kind === 'session' + ? principal.kind === 'session' && + bound.userId === principal.userId && + bound.sessionId === principal.sessionId + : bound.kind === 'personal_api_key' + ? 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) + if (!matches) throw uploadNotFound() +} + export async function verifyUploadSessionToken(uploadToken: string): Promise { const tokenHash = sha256Hex(uploadToken) const [row] = await db @@ -345,8 +457,16 @@ export async function createUploadPartUrls(params: { export async function completeUploadSession(params: { session: UploadSessionRecord finalize: (session: UploadSessionRecord) => Promise<{ value: T; completedFileId?: string }> + loadCompleted?: (session: UploadSessionRecord) => Promise }): Promise<{ session: UploadSessionRecord; value: T; alreadyCompleted: boolean }> { if (params.session.status === 'completed') { + if (params.loadCompleted && params.session.completedFileId) { + return { + session: params.session, + value: await params.loadCompleted(params.session), + alreadyCompleted: true, + } + } const finalized = await params.finalize(params.session) return { session: params.session, value: finalized.value, alreadyCompleted: true } } @@ -373,6 +493,12 @@ export async function completeUploadSession(params: { let alreadyCompleted = false try { + if (recoveringFinalization && claimed.completedFileId && params.loadCompleted) { + const value = await params.loadCompleted(claimed) + const completed = await markUploadSessionCompleted(claimed, leaseId, claimed.completedFileId) + return { session: completed, value, alreadyCompleted: true } + } + let finalObject = await headProviderObject({ provider: claimed.storageProvider, key: claimed.finalKey, @@ -439,25 +565,13 @@ export async function completeUploadSession(params: { claimed.uploadToken ) const finalized = await params.finalize(finalizing) - const completedAt = new Date() - const [completedRow] = await db - .update(uploadSession) - .set({ - status: 'completed', - completedFileId: finalized.completedFileId ?? null, - completedAt, - processingLeaseId: null, - processingLeaseExpiresAt: null, - error: null, - updatedAt: completedAt, - }) - .where(and(eq(uploadSession.id, claimed.id), eq(uploadSession.processingLeaseId, leaseId))) - .returning() + const completed = await markUploadSessionCompleted( + claimed, + leaseId, + finalized.completedFileId ?? null + ) return { - session: sessionFromRow( - requireRow(completedRow, 'Upload completion lease was lost'), - claimed.uploadToken - ), + session: completed, value: finalized.value, alreadyCompleted, } @@ -476,6 +590,31 @@ export async function completeUploadSession(params: { } } +async function markUploadSessionCompleted( + session: UploadSessionRecord, + leaseId: string, + completedFileId: string | null +): Promise { + const completedAt = new Date() + const [completedRow] = await db + .update(uploadSession) + .set({ + status: 'completed', + completedFileId, + completedAt, + processingLeaseId: null, + processingLeaseExpiresAt: null, + error: null, + updatedAt: completedAt, + }) + .where(and(eq(uploadSession.id, session.id), eq(uploadSession.processingLeaseId, leaseId))) + .returning() + return sessionFromRow( + requireRow(completedRow, 'Upload completion lease was lost'), + session.uploadToken + ) +} + export async function abortUploadSession( session: UploadSessionRecord ): Promise { @@ -483,13 +622,17 @@ export async function abortUploadSession( if (session.status === 'completed') { throw new UploadSessionError('conflict', 'Completed upload sessions cannot be aborted') } - if (session.status === 'finalizing') { - throw new UploadSessionError('conflict', 'Finalizing upload sessions cannot be aborted') + if (session.status === 'finalizing' && session.completedFileId) { + throw new UploadSessionError( + 'conflict', + 'Finalizing upload sessions with a registered file cannot be aborted' + ) } if ( session.status !== 'uploading' && session.status !== 'completing' && - session.status !== 'aborting' + session.status !== 'aborting' && + session.status !== 'finalizing' ) { throw new UploadSessionError('conflict', `Upload session is ${session.status}`) } @@ -498,7 +641,7 @@ export async function abortUploadSession( ...(await claimSession( session.id, leaseId, - ['uploading', 'completing', 'aborting'], + ['uploading', 'completing', 'aborting', 'finalizing'], 'aborting' )), uploadToken: session.uploadToken, @@ -545,7 +688,10 @@ export async function cleanupExpiredUploadSessions(): Promise<{ .from(uploadSession) .where( and( - inArray(uploadSession.status, ['uploading', 'completing', 'aborting']), + or( + inArray(uploadSession.status, ['uploading', 'completing', 'aborting']), + and(eq(uploadSession.status, 'finalizing'), isNull(uploadSession.completedFileId)) + ), lt(uploadSession.expiresAt, now), or( isNull(uploadSession.processingLeaseId), @@ -565,7 +711,7 @@ export async function cleanupExpiredUploadSessions(): Promise<{ const claimed = await claimSession( candidate.id, leaseId, - ['uploading', 'completing', 'aborting'], + ['uploading', 'completing', 'aborting', 'finalizing'], 'aborting', cleanupDb ) @@ -979,6 +1125,25 @@ function isStorageContext(value: string): value is StorageContext { ].includes(value) } +function isUploadSessionAuthBinding(value: unknown): value is UploadSessionAuthBinding { + if (!value || typeof value !== 'object') return false + const binding = value as Record + if (binding.version !== 1 || typeof binding.workspaceId !== 'string') return false + if (!binding.principal || typeof binding.principal !== 'object') return false + const principal = binding.principal as Record + if (principal.kind === 'session') { + return typeof principal.userId === 'string' && typeof principal.sessionId === 'string' + } + if (principal.kind === 'personal_api_key') { + return typeof principal.userId === 'string' && typeof principal.keyId === 'string' + } + return ( + principal.kind === 'workspace_api_key' && + typeof principal.workspaceId === 'string' && + typeof principal.keyId === 'string' + ) +} + function uploadNotFound(): UploadSessionError { return new UploadSessionError('not_found', 'Upload session not found') } diff --git a/apps/sim/lib/workspace-files/application/archive-workspace-file-items.test.ts b/apps/sim/lib/workspace-files/application/archive-workspace-file-items.test.ts new file mode 100644 index 00000000000..6c76afbd839 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/archive-workspace-file-items.test.ts @@ -0,0 +1,92 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockAuthorize, mockArchive, mockAudit, mockNotify } = vi.hoisted(() => ({ + mockAuthorize: vi.fn(), + mockArchive: vi.fn(), + mockAudit: vi.fn(), + mockNotify: vi.fn(), +})) + +vi.mock('@/lib/workspace-files/application/workspace-operation-context', () => ({ + authorizeWorkspaceFileOperation: mockAuthorize, +})) +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + assertWorkspaceFileItemsBelongToWorkspace: mockAuthorize, + bulkArchiveWorkspaceFileItems: mockArchive, +})) +vi.mock('@sim/audit', () => ({ + AuditAction: { FILE_DELETED: 'file.deleted', FOLDER_DELETED: 'folder.deleted' }, + AuditResourceType: { FILE: 'file', FOLDER: 'folder' }, + recordAudit: mockAudit, +})) +vi.mock('@/lib/realtime/notify', () => ({ notifyWorkspaceFilesChanged: mockNotify })) + +import { archiveWorkspaceFileItemsOperation } from '@/lib/workspace-files/application/archive-workspace-file-items' +import { fileOperations } from '@/lib/workspace-files/application/operations' + +describe('archiveWorkspaceFileItemsOperation', () => { + beforeEach(() => { + vi.clearAllMocks() + mockAuthorize.mockResolvedValue({ + context: { + workspaceId: 'ws-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'owner-1', + }, + attribution: { attributedUserId: 'user-1', actor: { kind: 'session', userId: 'user-1' } }, + }) + mockArchive.mockResolvedValue({ files: 1, folders: 0 }) + }) + + it('preserves atomic bulk archive results and emits side effects once', async () => { + const result = await archiveWorkspaceFileItemsOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: 'ws-1', fileIds: ['file-1'] }, + }) + + expect(result).toEqual({ deletedItems: { files: 1, folders: 0 } }) + expect(mockAuthorize).toHaveBeenCalledWith( + expect.anything(), + fileOperations.delete, + 'ws-1', + 'file-1' + ) + expect(mockArchive).toHaveBeenCalledWith({ + workspaceId: 'ws-1', + fileIds: ['file-1'], + folderIds: [], + }) + expect(mockAudit).toHaveBeenCalledOnce() + expect(mockNotify).toHaveBeenCalledOnce() + }) + + it('classifies a single missing file without notifying', async () => { + mockArchive.mockResolvedValue({ files: 0, folders: 0 }) + await expect( + archiveWorkspaceFileItemsOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: 'ws-1', fileIds: ['missing'] }, + }) + ).rejects.toThrow('File not found') + expect(mockAudit).not.toHaveBeenCalled() + expect(mockNotify).not.toHaveBeenCalled() + }) + + it('rejects oversized selections before authorization', async () => { + await expect( + archiveWorkspaceFileItemsOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + workspaceId: 'ws-1', + fileIds: Array.from({ length: 1_001 }, (_, index) => `file-${index}`), + }, + }) + ).rejects.toThrow('accept at most 1000') + expect(mockAuthorize).not.toHaveBeenCalled() + expect(mockArchive).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workspace-files/application/archive-workspace-file-items.ts b/apps/sim/lib/workspace-files/application/archive-workspace-file-items.ts new file mode 100644 index 00000000000..8ecb336b6e9 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/archive-workspace-file-items.ts @@ -0,0 +1,112 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { type Principal, resolvePrincipalAuditAttribution } from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import { + OrchestrationError, + type OrchestrationRequestContext, +} from '@/lib/core/orchestration/types' +import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' +import { + assertWorkspaceFileItemsBelongToWorkspace, + bulkArchiveWorkspaceFileItems, +} from '@/lib/uploads/contexts/workspace' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { authorizeWorkspaceFileOperation } from '@/lib/workspace-files/application/workspace-operation-context' +import { MAX_WORKSPACE_FILE_BULK_REQUEST_IDS } from '@/lib/workspace-files/limits' + +const logger = createLogger('ArchiveWorkspaceFileItems') + +export interface ArchiveWorkspaceFileItemsInput { + workspaceId: string + fileIds?: string[] + folderIds?: string[] +} + +export interface ArchiveWorkspaceFileItemsResult { + deletedItems: { files: number; folders: number } +} + +async function executeArchiveWorkspaceFileItems(args: { + principal: Principal + input: ArchiveWorkspaceFileItemsInput + request?: OrchestrationRequestContext +}): Promise { + const fileIds = [...new Set(args.input.fileIds ?? [])] + const folderIds = [...new Set(args.input.folderIds ?? [])] + if (fileIds.length === 0 && folderIds.length === 0) { + throw new OrchestrationError('validation', 'At least one file or folder must be selected') + } + if ( + fileIds.length > MAX_WORKSPACE_FILE_BULK_REQUEST_IDS || + folderIds.length > MAX_WORKSPACE_FILE_BULK_REQUEST_IDS + ) { + throw new OrchestrationError( + 'validation', + `Bulk file operations accept at most ${MAX_WORKSPACE_FILE_BULK_REQUEST_IDS} file and folder IDs` + ) + } + + const { context } = await authorizeWorkspaceFileOperation( + args.principal, + fileOperations.delete, + args.input.workspaceId, + fileIds[0] + ) + const auditAttribution = resolvePrincipalAuditAttribution(args.principal) + await assertWorkspaceFileItemsBelongToWorkspace({ + workspaceId: context.workspaceId, + fileIds, + folderIds, + }) + const deletedItems = await bulkArchiveWorkspaceFileItems({ + workspaceId: context.workspaceId, + fileIds, + folderIds, + }) + + if (fileIds.length === 1 && folderIds.length === 0 && deletedItems.files === 0) { + throw new OrchestrationError('not_found', 'File not found') + } + if (folderIds.length === 1 && fileIds.length === 0 && deletedItems.folders === 0) { + throw new OrchestrationError('not_found', 'Folder not found') + } + + if (fileIds.length > 0) { + recordAudit({ + workspaceId: context.workspaceId, + actorId: auditAttribution.actorId, + actorName: auditAttribution.actorName, + action: AuditAction.FILE_DELETED, + resourceType: AuditResourceType.FILE, + description: `Deleted ${fileIds.length} file${fileIds.length === 1 ? '' : 's'}`, + metadata: { operation: fileOperations.delete.id, actor: auditAttribution.actor, fileIds }, + request: args.request, + }) + } + if (folderIds.length > 0) { + recordAudit({ + workspaceId: context.workspaceId, + actorId: auditAttribution.actorId, + actorName: auditAttribution.actorName, + action: AuditAction.FOLDER_DELETED, + resourceType: AuditResourceType.FOLDER, + resourceId: folderIds.length === 1 ? folderIds[0] : undefined, + description: `Deleted ${folderIds.length} file folder${folderIds.length === 1 ? '' : 's'}`, + metadata: { + operation: fileOperations.delete.id, + actor: auditAttribution.actor, + folderIds, + affected: deletedItems, + }, + request: args.request, + }) + } + await notifyWorkspaceFilesChanged(context.workspaceId) + logger.info('Archived workspace file items', { workspaceId: context.workspaceId, deletedItems }) + return { deletedItems } +} + +export const archiveWorkspaceFileItemsOperation = { + operation: fileOperations.delete, + execute: executeArchiveWorkspaceFileItems, +} as const diff --git a/apps/sim/lib/workspace-files/application/compiled-check-workspace-file.ts b/apps/sim/lib/workspace-files/application/compiled-check-workspace-file.ts new file mode 100644 index 00000000000..0b155cb7859 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/compiled-check-workspace-file.ts @@ -0,0 +1,114 @@ +import type { Principal } from '@sim/auth/principal' +import { getE2BDocFormat } from '@/lib/copilot/tools/server/files/doc-compile' +import { runE2BCompiledCheck } from '@/lib/copilot/tools/server/files/doc-recalc' +import { isDocSandboxEnabled } from '@/lib/core/config/env-flags' +import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' +import { BINARY_DOC_TASKS, MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants' +import { runSandboxTask, SandboxUserCodeError } from '@/lib/execution/sandbox/run-task' +import { validateMermaidSource } from '@/lib/mermaid/validate' +import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' +import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' + +export class CompiledCheckUnsupportedError extends Error { + constructor() { + super('Compiled check only supports .docx, .pptx, .pdf, .xlsx, and .mmd files') + this.name = 'CompiledCheckUnsupportedError' + } +} + +export class CompiledCheckTooLargeError extends Error { + constructor() { + super('File source exceeds maximum size') + this.name = 'CompiledCheckTooLargeError' + } +} + +export interface CompiledCheckWorkspaceFileInput { + fileId: string + assertedWorkspaceId?: string +} + +export type CompiledCheckWorkspaceFileResult = + | { ok: true } + | { ok: false; error: string; errorName: string } + +function normalizeCompiledCheckResult(result: { + ok: boolean + error?: string + errorName?: string +}): CompiledCheckWorkspaceFileResult { + if (result.ok) return { ok: true } + return { + ok: false, + error: result.error ?? 'Compiled check failed', + errorName: result.errorName ?? 'CompiledCheckError', + } +} + +async function executeCompiledCheckWorkspaceFile({ + principal, + input, +}: { + principal: Principal + input: CompiledCheckWorkspaceFileInput + request?: OrchestrationRequestContext +}): Promise { + const { file } = await readWorkspaceFileMetadata.execute({ principal, input }) + const ext = file.name.split('.').pop()?.toLowerCase() ?? '' + const e2bFmt = isDocSandboxEnabled ? await getE2BDocFormat(file.name) : null + const taskId = BINARY_DOC_TASKS[ext] + const isMermaidFile = ext === 'mmd' || ext === 'mermaid' + if (!e2bFmt && !taskId && !isMermaidFile) throw new CompiledCheckUnsupportedError() + + if (file.size > MAX_DOCUMENT_PREVIEW_CODE_BYTES) { + throw new CompiledCheckTooLargeError() + } + + const { content } = await readWorkspaceFileContent.execute({ + principal, + input: { ...input, maxBytes: MAX_DOCUMENT_PREVIEW_CODE_BYTES }, + }) + + const code = content.toString('utf-8') + if (Buffer.byteLength(code, 'utf-8') > MAX_DOCUMENT_PREVIEW_CODE_BYTES) { + throw new CompiledCheckTooLargeError() + } + if (isMermaidFile) return normalizeCompiledCheckResult(await validateMermaidSource(code)) + if (e2bFmt) { + return normalizeCompiledCheckResult( + await runE2BCompiledCheck({ + source: code, + fileName: file.name, + workspaceId: file.workspaceId, + ext, + principal, + }) + ) + } + + try { + if (!taskId) throw new CompiledCheckUnsupportedError() + const ownerId = + principal.kind === 'session' || principal.kind === 'personal_api_key' + ? principal.userId + : principal.kind === 'delegated' + ? principal.subjectUserId + : file.uploadedBy + await runSandboxTask( + taskId, + { code, workspaceId: file.workspaceId }, + { ownerKey: `user:${ownerId}` } + ) + return { ok: true } + } catch (error) { + if (error instanceof SandboxUserCodeError) { + return { ok: false, error: error.message, errorName: error.name } + } + throw error + } +} + +export const compiledCheckWorkspaceFile = { + operation: readWorkspaceFileContent.operation, + execute: executeCompiledCheckWorkspaceFile, +} as const diff --git a/apps/sim/lib/workspace-files/application/create-workspace-file.ts b/apps/sim/lib/workspace-files/application/create-workspace-file.ts new file mode 100644 index 00000000000..8e58db1f770 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/create-workspace-file.ts @@ -0,0 +1,175 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { + type Principal, + resolvePrincipalAttribution, + resolvePrincipalAuditAttribution, +} from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import { getPostgresErrorCode } from '@sim/utils/errors' +import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + FileConflictError, + loadActiveWorkspaceContext, + uploadWorkspaceFile, + type WorkspaceFileRecord, +} from '@/lib/uploads/contexts/workspace' +import type { WorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' +import { EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' +import { authorizeWorkspaceOperation } from '@/lib/workspace-files/application/authorization' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { MAX_WORKSPACE_FILE_CONTENT_BYTES } from '@/lib/workspace-files/orchestration' + +const logger = createLogger('CreateWorkspaceFile') + +export interface CreateWorkspaceFileInput { + workspaceId: string + name: string + contentType: string + content: string + encoding: 'utf-8' | 'base64' + folderId?: string | null + folderPath?: string + exactName: boolean + secretProvenance?: WorkspaceFileSecretProvenance +} + +export interface CreateWorkspaceFileResult { + file: WorkspaceFileRecord +} + +export interface CreateWorkspaceFileBufferInput + extends Omit { + content: Buffer +} + +interface CreateWorkspaceFileArguments { + principal: Principal + input: CreateWorkspaceFileInput + request?: OrchestrationRequestContext +} + +interface CreateWorkspaceFileBufferArguments { + principal: Principal + input: CreateWorkspaceFileBufferInput + request?: OrchestrationRequestContext +} + +async function requireCreateWorkspaceFileAccess(principal: Principal, workspaceId: string) { + const workspace = await loadActiveWorkspaceContext(workspaceId) + if (!workspace) throw new OrchestrationError('not_found', 'Workspace not found') + await authorizeWorkspaceOperation(principal, fileOperations.create, workspace) + return workspace +} + +async function executeCreateWorkspaceFile({ + principal, + input, + request, +}: CreateWorkspaceFileArguments): Promise { + const workspace = await requireCreateWorkspaceFileAccess(principal, input.workspaceId) + + const content = Buffer.from(input.content, input.encoding === 'base64' ? 'base64' : 'utf-8') + if (content.length > MAX_WORKSPACE_FILE_CONTENT_BYTES) { + throw new OrchestrationError( + 'payload_too_large', + `File size exceeds ${MAX_WORKSPACE_FILE_CONTENT_BYTES / 1024 / 1024}MB limit` + ) + } + + return createAuthorizedWorkspaceFile({ principal, input, content, request, workspace }) +} + +async function executeCreateWorkspaceFileBuffer({ + principal, + input, + request, +}: CreateWorkspaceFileBufferArguments): Promise { + const workspace = await requireCreateWorkspaceFileAccess(principal, input.workspaceId) + return createAuthorizedWorkspaceFile({ + principal, + input, + content: input.content, + request, + workspace, + }) +} + +async function createAuthorizedWorkspaceFile({ + principal, + input, + content, + request, + workspace, +}: { + principal: Principal + input: Omit + content: Buffer + request?: OrchestrationRequestContext + workspace: Awaited> +}): Promise { + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: workspace.billedAccountUserId, + }) + const auditAttribution = resolvePrincipalAuditAttribution(principal) + + let file: WorkspaceFileRecord + try { + file = await uploadWorkspaceFile( + workspace.workspaceId, + attribution.attributedUserId, + content, + input.name, + input.contentType, + { + folderId: input.folderId, + folderPath: input.folderPath, + exactName: input.exactName, + secretProvenance: input.secretProvenance ?? EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE, + } + ) + } catch (error) { + if (error instanceof FileConflictError || getPostgresErrorCode(error) === '23505') { + throw new OrchestrationError('conflict', 'File already exists') + } + throw error + } + + logger.info('Created workspace file', { + workspaceId: workspace.workspaceId, + fileId: file.id, + folderId: file.folderId, + size: file.size, + principalKind: principal.kind, + }) + recordAudit({ + workspaceId: workspace.workspaceId, + actorId: auditAttribution.actorId, + actorName: auditAttribution.actorName, + action: AuditAction.FILE_UPLOADED, + resourceType: AuditResourceType.FILE, + resourceId: file.id, + resourceName: file.name, + description: `Uploaded file "${file.name}"`, + metadata: { + operation: fileOperations.create.id, + actor: auditAttribution.actor, + fileSize: file.size, + fileType: file.type, + }, + request, + }) + + return { file } +} + +export const createWorkspaceFile = { + operation: fileOperations.create, + admit: requireCreateWorkspaceFileAccess, + execute: executeCreateWorkspaceFile, +} as const + +export const createWorkspaceFileFromBuffer = { + operation: fileOperations.create, + execute: executeCreateWorkspaceFileBuffer, +} as const diff --git a/apps/sim/lib/workspace-files/application/csv-preview-workspace-file.ts b/apps/sim/lib/workspace-files/application/csv-preview-workspace-file.ts new file mode 100644 index 00000000000..da862465443 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/csv-preview-workspace-file.ts @@ -0,0 +1,36 @@ +import type { Principal } from '@sim/auth/principal' +import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { getCsvPreviewSlice } from '@/lib/file-parsers/csv-preview-slice' +import { readWorkspaceFileContentRecord } from '@/lib/workspace-files/application/read-workspace-file-record' + +export interface CsvPreviewWorkspaceFileInput { + fileId: string + assertedWorkspaceId?: string + key: string + signal?: AbortSignal +} + +async function executeCsvPreviewWorkspaceFile({ + principal, + input, + request, +}: { + principal: Principal + input: CsvPreviewWorkspaceFileInput + request?: OrchestrationRequestContext +}) { + const { file } = await readWorkspaceFileContentRecord.execute({ principal, input, request }) + if (file.key !== input.key) throw new OrchestrationError('not_found', 'File not found') + const slice = await getCsvPreviewSlice({ + key: file.key, + context: 'workspace', + signal: input.signal, + }) + return { success: true as const, ...slice } +} + +export const csvPreviewWorkspaceFile = { + operation: readWorkspaceFileContentRecord.operation, + execute: executeCsvPreviewWorkspaceFile, +} as const diff --git a/apps/sim/lib/workspace-files/application/delegated-principal.ts b/apps/sim/lib/workspace-files/application/delegated-principal.ts new file mode 100644 index 00000000000..7cb5040c86c --- /dev/null +++ b/apps/sim/lib/workspace-files/application/delegated-principal.ts @@ -0,0 +1,39 @@ +import type { DelegatedPrincipal } from '@sim/auth/principal' +import { WORKSPACE_FILES_DELEGATION_AUDIENCE } from '@/lib/workspace-files/application/authorization' + +const WORKSPACE_FILE_DELEGATION_TTL_MS = 5 * 60 * 1000 + +export interface WorkspaceFileDelegationInput { + serviceId: DelegatedPrincipal['serviceId'] + subjectUserId: string + workspaceId: string + delegationId: string + fileId?: string + chatId?: string + executionId?: string +} + +/** Binds a trusted service execution to the shared workspace-file principal shape. */ +export function createWorkspaceFileDelegatedPrincipal( + input: WorkspaceFileDelegationInput +): DelegatedPrincipal { + if (!input.subjectUserId || !input.workspaceId || !input.delegationId) { + throw new Error('Workspace file delegation requires subject, workspace, and delegation IDs') + } + const issuedAt = new Date() + return { + kind: 'delegated', + serviceId: input.serviceId, + subjectUserId: input.subjectUserId, + workspaceId: input.workspaceId, + delegationId: input.delegationId, + audience: WORKSPACE_FILES_DELEGATION_AUDIENCE, + issuedAt, + expiresAt: new Date(issuedAt.getTime() + WORKSPACE_FILE_DELEGATION_TTL_MS), + resourceScope: { + ...(input.fileId ? { fileId: input.fileId } : {}), + ...(input.chatId ? { chatId: input.chatId } : {}), + ...(input.executionId ? { executionId: input.executionId } : {}), + }, + } +} diff --git a/apps/sim/lib/workspace-files/application/delete-workspace-file.test.ts b/apps/sim/lib/workspace-files/application/delete-workspace-file.test.ts new file mode 100644 index 00000000000..274ae732ad3 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/delete-workspace-file.test.ts @@ -0,0 +1,81 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadAuthorized: vi.fn(), + deleteStored: vi.fn(), + recordAudit: vi.fn(), + notify: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { FILE_DELETED: 'FILE_DELETED' }, + AuditResourceType: { FILE: 'FILE' }, + recordAudit: mocks.recordAudit, +})) +vi.mock('@/lib/realtime/notify', () => ({ notifyWorkspaceFilesChanged: mocks.notify })) +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + deleteWorkspaceFile: mocks.deleteStored, +})) +vi.mock('@/lib/workspace-files/application/load-authorized-workspace-file', () => ({ + loadAuthorizedWorkspaceFile: mocks.loadAuthorized, +})) + +import { deleteWorkspaceFileOperation } from '@/lib/workspace-files/application/delete-workspace-file' + +const context = { + fileId: 'file-1', + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner', +} + +describe('deleteWorkspaceFileOperation', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadAuthorized.mockResolvedValue(context) + mocks.deleteStored.mockResolvedValue(undefined) + mocks.notify.mockResolvedValue(undefined) + }) + + it('authorizes, archives, audits, and notifies once', async () => { + const result = await deleteWorkspaceFileOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-1' }, + }) + + expect(result).toEqual({ id: 'file-1', workspaceId: 'workspace-1', deleted: true }) + expect(mocks.loadAuthorized).toHaveBeenCalledWith({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + operation: expect.objectContaining({ id: 'files.delete' }), + fileId: 'file-1', + assertedWorkspaceId: 'workspace-1', + }) + expect(mocks.deleteStored).toHaveBeenCalledWith('workspace-1', 'file-1') + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-1', + actorId: 'user-1', + metadata: expect.objectContaining({ operation: 'files.delete' }), + }) + ) + expect(mocks.notify).toHaveBeenCalledWith('workspace-1') + }) + + it('does not emit side effects when storage fails', async () => { + const failure = new Error('storage unavailable') + mocks.deleteStored.mockRejectedValueOnce(failure) + + await expect( + deleteWorkspaceFileOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { fileId: 'file-1' }, + }) + ).rejects.toBe(failure) + expect(mocks.recordAudit).not.toHaveBeenCalled() + expect(mocks.notify).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workspace-files/application/delete-workspace-file.ts b/apps/sim/lib/workspace-files/application/delete-workspace-file.ts new file mode 100644 index 00000000000..62fd7ca8b2f --- /dev/null +++ b/apps/sim/lib/workspace-files/application/delete-workspace-file.ts @@ -0,0 +1,67 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { type Principal, resolvePrincipalAuditAttribution } from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' +import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' +import { deleteWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { loadAuthorizedWorkspaceFile } from '@/lib/workspace-files/application/load-authorized-workspace-file' +import { fileOperations } from '@/lib/workspace-files/application/operations' + +const logger = createLogger('DeleteWorkspaceFile') + +export interface DeleteWorkspaceFileInput { + fileId: string + assertedWorkspaceId?: string +} + +export interface DeleteWorkspaceFileResult { + id: string + workspaceId: string + deleted: true +} + +interface DeleteWorkspaceFileArguments { + principal: Principal + input: DeleteWorkspaceFileInput + request?: OrchestrationRequestContext +} + +async function executeDeleteWorkspaceFile({ + principal, + input, + request, +}: DeleteWorkspaceFileArguments): Promise { + const canonical = await loadAuthorizedWorkspaceFile({ + principal, + operation: fileOperations.delete, + fileId: input.fileId, + assertedWorkspaceId: input.assertedWorkspaceId, + }) + + const auditAttribution = resolvePrincipalAuditAttribution(principal) + await deleteWorkspaceFile(canonical.workspaceId, canonical.fileId) + + recordAudit({ + workspaceId: canonical.workspaceId, + actorId: auditAttribution.actorId, + actorName: auditAttribution.actorName, + action: AuditAction.FILE_DELETED, + resourceType: AuditResourceType.FILE, + resourceId: canonical.fileId, + description: `Deleted workspace file ${canonical.fileId}`, + metadata: { operation: fileOperations.delete.id, actor: auditAttribution.actor }, + request, + }) + await notifyWorkspaceFilesChanged(canonical.workspaceId) + logger.info('Deleted workspace file', { + workspaceId: canonical.workspaceId, + fileId: canonical.fileId, + principalKind: principal.kind, + }) + return { id: canonical.fileId, workspaceId: canonical.workspaceId, deleted: true } +} + +export const deleteWorkspaceFileOperation = { + operation: fileOperations.delete, + execute: executeDeleteWorkspaceFile, +} as const diff --git a/apps/sim/lib/workspace-files/application/download-workspace-file-items.test.ts b/apps/sim/lib/workspace-files/application/download-workspace-file-items.test.ts new file mode 100644 index 00000000000..388a0326869 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/download-workspace-file-items.test.ts @@ -0,0 +1,152 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockAuthorize, + mockListFiles, + mockListFolders, + mockFetchServable, + mockRecordAudit, + mockIsGenerated, + mockIsRenderable, + mockIsDocNotReady, +} = vi.hoisted(() => ({ + mockAuthorize: vi.fn(), + mockListFiles: vi.fn(), + mockListFolders: vi.fn(), + mockFetchServable: vi.fn(), + mockRecordAudit: vi.fn(), + mockIsGenerated: vi.fn(), + mockIsRenderable: vi.fn(), + mockIsDocNotReady: vi.fn(), +})) + +vi.mock('@/lib/workspace-files/application/workspace-operation-context', () => ({ + authorizeWorkspaceFileOperation: mockAuthorize, +})) +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + buildWorkspaceFileFolderPathMap: (folders: Array<{ id: string; path?: string; name: string }>) => + new Map(folders.map((folder) => [folder.id, folder.path ?? folder.name])), + fetchServableWorkspaceFileBuffer: mockFetchServable, + listWorkspaceFileFolders: mockListFolders, + listWorkspaceFiles: mockListFiles, +})) +vi.mock('@/lib/uploads/utils/file-utils', () => ({ + formatFileSize: (bytes: number) => `${bytes} bytes`, + isGeneratedDocumentSourceType: mockIsGenerated, + isRenderableDocumentName: mockIsRenderable, + MAX_RENDERED_DOCUMENT_BYTES: 50 * 1024 * 1024, +})) +vi.mock('@/lib/uploads/utils/servable-file-response', () => ({ + docNotReadyMessage: (names: string[]) => `Pending: ${names.join(', ')}`, + isDocNotReadyError: mockIsDocNotReady, +})) +vi.mock('@sim/audit', () => ({ + AuditAction: { FILE_DOWNLOADED: 'file.downloaded' }, + AuditResourceType: { FILE: 'file' }, + recordAudit: mockRecordAudit, +})) + +import { downloadWorkspaceFileItems } from '@/lib/workspace-files/application/download-workspace-file-items' + +const principal = { kind: 'session' as const, userId: 'u1', sessionId: 's1' } +const workspace = { + workspaceId: 'ws-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner', +} + +function file(id: string, name: string, folderId: string | null = null, size = 10) { + return { id, name, folderId, size, type: 'application/octet-stream', key: `key-${id}` } +} + +describe('downloadWorkspaceFileItems', () => { + beforeEach(() => { + vi.clearAllMocks() + mockAuthorize.mockResolvedValue({ + context: workspace, + attribution: { + attributedUserId: 'u1', + actor: { kind: 'session', userId: 'u1' }, + }, + }) + mockListFiles.mockResolvedValue([file('f1', 'clip.mp4')]) + mockListFolders.mockResolvedValue([]) + mockIsGenerated.mockReturnValue(false) + mockIsRenderable.mockReturnValue(false) + mockIsDocNotReady.mockReturnValue(false) + }) + + it('authorizes the workspace once and returns the bounded selection', async () => { + const result = await downloadWorkspaceFileItems.execute({ + principal, + input: { workspaceId: 'ws-1', fileIds: ['f1'], folderIds: [] }, + }) + + expect(mockAuthorize).toHaveBeenCalledWith( + principal, + expect.objectContaining({ id: 'files.download' }), + 'ws-1' + ) + expect(result.filesToZip).toHaveLength(1) + expect(mockRecordAudit).toHaveBeenCalledWith( + expect.objectContaining({ actorId: 'u1', workspaceId: 'ws-1' }) + ) + }) + + it('expands selected folders and renders generated documents before returning', async () => { + mockListFolders.mockResolvedValue([ + { id: 'folder-1', name: 'Reports', path: 'Reports', parentId: null }, + { id: 'folder-2', name: 'Drafts', path: 'Reports/Drafts', parentId: 'folder-1' }, + ]) + mockListFiles.mockResolvedValue([file('f1', 'report.docx', 'folder-2')]) + mockIsGenerated.mockReturnValue(true) + mockFetchServable.mockResolvedValue({ buffer: Buffer.from('rendered') }) + + const result = await downloadWorkspaceFileItems.execute({ + principal, + input: { workspaceId: 'ws-1', fileIds: [], folderIds: ['folder-1'] }, + }) + + expect(result.filesToZip[0].id).toBe('f1') + expect(result.renderedDocuments.get('f1')).toEqual(Buffer.from('rendered')) + expect(mockFetchServable).toHaveBeenCalledWith(expect.objectContaining({ id: 'f1' }), { + maxBytes: 50 * 1024 * 1024, + }) + }) + + it('returns typed validation and conflict failures without recording audit', async () => { + await expect( + downloadWorkspaceFileItems.execute({ + principal, + input: { workspaceId: 'ws-1', fileIds: [], folderIds: [] }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + expect(mockAuthorize).not.toHaveBeenCalled() + + mockListFiles.mockResolvedValue([file('f1', 'pending.docx')]) + mockIsGenerated.mockReturnValue(true) + mockIsDocNotReady.mockReturnValue(true) + mockFetchServable.mockRejectedValue(new Error('pending')) + await expect( + downloadWorkspaceFileItems.execute({ + principal, + input: { workspaceId: 'ws-1', fileIds: ['f1'], folderIds: [] }, + }) + ).rejects.toMatchObject({ code: 'conflict' }) + expect(mockRecordAudit).not.toHaveBeenCalled() + }) + + it('rejects unknown selected IDs rather than exposing another workspace selection', async () => { + mockListFiles.mockResolvedValue([]) + await expect( + downloadWorkspaceFileItems.execute({ + principal, + input: { workspaceId: 'ws-1', fileIds: ['other-workspace-file'], folderIds: [] }, + }) + ).rejects.toEqual(expect.objectContaining({ code: 'not_found' })) + }) +}) diff --git a/apps/sim/lib/workspace-files/application/download-workspace-file-items.ts b/apps/sim/lib/workspace-files/application/download-workspace-file-items.ts new file mode 100644 index 00000000000..fabba422874 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/download-workspace-file-items.ts @@ -0,0 +1,183 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { type Principal, resolvePrincipalAuditAttribution } from '@sim/auth/principal' +import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' +import { + buildWorkspaceFileFolderPathMap, + fetchServableWorkspaceFileBuffer, + listWorkspaceFileFolders, + listWorkspaceFiles, + type WorkspaceFileRecord, +} from '@/lib/uploads/contexts/workspace' +import { + formatFileSize, + isGeneratedDocumentSourceType, + isRenderableDocumentName, + MAX_RENDERED_DOCUMENT_BYTES, +} from '@/lib/uploads/utils/file-utils' +import { docNotReadyMessage, isDocNotReadyError } from '@/lib/uploads/utils/servable-file-response' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { authorizeWorkspaceFileOperation } from '@/lib/workspace-files/application/workspace-operation-context' + +export const MAX_ZIP_DOWNLOAD_FILES = 100 +export const MAX_ZIP_DOWNLOAD_BYTES = 250 * 1024 * 1024 +const MAX_REQUESTED_FILE_IDS = 1_000 +const MAX_REQUESTED_FOLDER_IDS = 1_000 + +export interface DownloadWorkspaceFileItemsInput { + workspaceId: string + fileIds: string[] + folderIds: string[] +} + +export interface DownloadWorkspaceFileItemsResult { + filesToZip: WorkspaceFileRecord[] + folderPaths: Map + renderedDocuments: Map + declaredBytes: number +} + +function needsRendering(file: WorkspaceFileRecord): boolean { + return file.type ? isGeneratedDocumentSourceType(file.type) : isRenderableDocumentName(file.name) +} + +function collectDescendantFolderIds( + selectedFolderIds: string[], + folders: Array<{ id: string; parentId: string | null }> +): Set { + const folderIds = new Set(selectedFolderIds) + let changed = true + while (changed) { + changed = false + for (const folder of folders) { + if (folder.parentId && folderIds.has(folder.parentId) && !folderIds.has(folder.id)) { + folderIds.add(folder.id) + changed = true + } + } + } + return folderIds +} + +function validationError(message: string): never { + throw new OrchestrationError('validation', message) +} + +async function executeDownloadWorkspaceFileItems({ + principal, + input, + request, +}: { + principal: Principal + input: DownloadWorkspaceFileItemsInput + request?: OrchestrationRequestContext +}): Promise { + if (input.fileIds.length > MAX_REQUESTED_FILE_IDS) { + validationError(`Too many file IDs selected. Select ${MAX_REQUESTED_FILE_IDS} or fewer files.`) + } + if (input.folderIds.length > MAX_REQUESTED_FOLDER_IDS) { + validationError( + `Too many folder IDs selected. Select ${MAX_REQUESTED_FOLDER_IDS} or fewer folders.` + ) + } + if (input.fileIds.length === 0 && input.folderIds.length === 0) { + validationError('No files selected for download') + } + + const { context } = await authorizeWorkspaceFileOperation( + principal, + fileOperations.download, + input.workspaceId + ) + const auditAttribution = resolvePrincipalAuditAttribution(principal) + const [files, folders] = await Promise.all([ + listWorkspaceFiles(context.workspaceId, { hydrateFolderPaths: false, throwOnError: true }), + listWorkspaceFileFolders(context.workspaceId), + ]) + const folderPaths = buildWorkspaceFileFolderPathMap(folders) + const knownFileIds = new Set(files.map((file) => file.id)) + const knownFolderIds = new Set(folders.map((folder) => folder.id)) + if ( + input.fileIds.some((fileId) => !knownFileIds.has(fileId)) || + input.folderIds.some((folderId) => !knownFolderIds.has(folderId)) + ) { + throw new OrchestrationError('not_found', 'File selection not found') + } + const selectedFolderIds = collectDescendantFolderIds(input.folderIds, folders) + const requestedFileIds = new Set(input.fileIds) + const filesToZip = files.filter( + (file) => + requestedFileIds.has(file.id) || + (file.folderId != null && selectedFolderIds.has(file.folderId)) + ) + + if (filesToZip.length === 0) validationError('No files selected for download') + if (filesToZip.length > MAX_ZIP_DOWNLOAD_FILES) { + validationError( + `Too many files selected for download. Select ${MAX_ZIP_DOWNLOAD_FILES} or fewer files.` + ) + } + + const declaredBytes = filesToZip.reduce((sum, file) => sum + file.size, 0) + if (declaredBytes > MAX_ZIP_DOWNLOAD_BYTES) { + validationError( + `Selected files total ${formatFileSize(declaredBytes)}, which exceeds the ${formatFileSize(MAX_ZIP_DOWNLOAD_BYTES)} download limit.` + ) + } + + const reservedForStreamed = filesToZip + .filter((file) => !needsRendering(file)) + .reduce((sum, file) => sum + file.size, 0) + const renderedDocuments = new Map() + const pendingNames: string[] = [] + let renderedBytes = 0 + + for (const file of filesToZip) { + if (!needsRendering(file)) continue + const remaining = Math.max(0, MAX_ZIP_DOWNLOAD_BYTES - reservedForStreamed - renderedBytes) + const allowance = Math.min(remaining, MAX_RENDERED_DOCUMENT_BYTES) + try { + const { buffer } = await fetchServableWorkspaceFileBuffer(file, { maxBytes: allowance }) + renderedBytes += buffer.length + renderedDocuments.set(file.id, buffer) + } catch (error) { + if (error instanceof PayloadSizeLimitError) { + validationError( + allowance === MAX_RENDERED_DOCUMENT_BYTES + ? `"${file.name}" renders to more than ${formatFileSize(MAX_RENDERED_DOCUMENT_BYTES)} and is too large to include in a zip; download it on its own instead.` + : `The selected files exceed the ${formatFileSize(MAX_ZIP_DOWNLOAD_BYTES)} download limit once documents are rendered. Select fewer files.` + ) + } + if (!isDocNotReadyError(error)) throw error + pendingNames.push(file.name) + } + } + + if (pendingNames.length > 0) { + throw new OrchestrationError('conflict', docNotReadyMessage(pendingNames)) + } + + recordAudit({ + workspaceId: context.workspaceId, + actorId: auditAttribution.actorId, + actorName: auditAttribution.actorName, + action: AuditAction.FILE_DOWNLOADED, + resourceType: AuditResourceType.FILE, + description: `Downloaded ${filesToZip.length} file${filesToZip.length === 1 ? '' : 's'} as zip`, + metadata: { + operation: fileOperations.download.id, + actor: auditAttribution.actor, + fileCount: filesToZip.length, + totalBytes: declaredBytes, + }, + request, + }) + + return { filesToZip, folderPaths, renderedDocuments, declaredBytes } +} + +export const downloadWorkspaceFileItems = { + operation: fileOperations.download, + execute: executeDownloadWorkspaceFileItems, +} as const diff --git a/apps/sim/lib/workspace-files/application/download-workspace-file.ts b/apps/sim/lib/workspace-files/application/download-workspace-file.ts new file mode 100644 index 00000000000..f9236d24c16 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/download-workspace-file.ts @@ -0,0 +1,92 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { type Principal, resolvePrincipalAuditAttribution } from '@sim/auth/principal' +import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' +import { nodeReadableToWebStream } from '@/lib/core/utils/node-stream' +import { downloadFileStream } from '@/lib/uploads/core/storage-service' +import { downloadWorkspaceFileRecord } from '@/lib/workspace-files/application/read-workspace-file-record' + +export interface DownloadWorkspaceFileInput { + fileId: string + assertedWorkspaceId?: string +} + +export interface DownloadWorkspaceFileResult { + file: Awaited>['file'] +} + +export interface DownloadWorkspaceFileStreamResult extends DownloadWorkspaceFileResult { + stream: ReadableStream +} + +function recordDownloadAudit( + file: DownloadWorkspaceFileResult['file'], + principal: Principal, + request?: OrchestrationRequestContext +) { + const auditAttribution = resolvePrincipalAuditAttribution(principal) + + recordAudit({ + workspaceId: file.workspaceId, + actorId: auditAttribution.actorId, + actorName: auditAttribution.actorName, + action: AuditAction.FILE_DOWNLOADED, + resourceType: AuditResourceType.FILE, + resourceId: file.id, + resourceName: file.name, + description: `Downloaded file "${file.name}"`, + metadata: { + fileId: file.id, + fileName: file.name, + bytes: file.size, + actor: auditAttribution.actor, + }, + request, + }) +} + +async function executeDownloadWorkspaceFile({ + principal, + input, + request, +}: { + principal: Principal + input: DownloadWorkspaceFileInput + request?: OrchestrationRequestContext +}): Promise { + const result = await downloadWorkspaceFileRecord.execute({ principal, input, request }) + recordDownloadAudit(result.file, principal, request) + return { file: result.file } +} + +export const downloadWorkspaceFile = { + operation: downloadWorkspaceFileRecord.operation, + execute: executeDownloadWorkspaceFile, +} as const + +async function executeDownloadWorkspaceFileStream({ + principal, + input, + request, +}: { + principal: Principal + input: DownloadWorkspaceFileInput + request?: OrchestrationRequestContext +}): Promise { + const result = await downloadWorkspaceFileRecord.execute({ + principal, + input, + request, + }) + const stream = await downloadFileStream({ + key: result.file.key, + context: result.file.storageContext ?? 'workspace', + }) + recordDownloadAudit(result.file, principal, request) + return { file: result.file, stream: nodeReadableToWebStream(stream) } +} + +/** Authorized and audited binary download without materializing the file in memory. */ +export const downloadWorkspaceFileStream = { + operation: downloadWorkspaceFileRecord.operation, + execute: executeDownloadWorkspaceFileStream, +} as const diff --git a/apps/sim/lib/workspace-files/application/list-workspace-files.ts b/apps/sim/lib/workspace-files/application/list-workspace-files.ts new file mode 100644 index 00000000000..966e43338b9 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/list-workspace-files.ts @@ -0,0 +1,91 @@ +import type { Principal } from '@sim/auth/principal' +import type { CursorKey } from '@/lib/api/list-query' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { ROOT_FOLDER_PATH } from '@/lib/folders/paths' +import { loadActiveFolderPathIndex } from '@/lib/folders/queries' +import { getWorkspaceShares } from '@/lib/public-shares/share-manager' +import { + listWorkspaceFiles, + loadActiveWorkspaceContext, + queryWorkspaceFiles, +} from '@/lib/uploads/contexts/workspace' +import { authorizeWorkspaceOperation } from '@/lib/workspace-files/application/authorization' +import { fileOperations } from '@/lib/workspace-files/application/operations' + +export interface ListAllWorkspaceFilesInput { + workspaceId: string + scope: 'active' | 'archived' | 'all' +} + +export interface QueryWorkspaceFilePageInput { + workspaceId: string + folderPath?: string + search?: string + sortBy: 'name' | 'size' | 'uploadedAt' | 'updatedAt' + sortOrder: 'asc' | 'desc' + limit: number + after?: CursorKey[] + cursorSort: string +} + +async function requireListWorkspaceFileAccess(principal: Principal, workspaceId: string) { + const workspace = await loadActiveWorkspaceContext(workspaceId) + if (!workspace) throw new OrchestrationError('not_found', 'Workspace not found') + await authorizeWorkspaceOperation(principal, fileOperations.list, workspace) + return workspace +} + +async function executeListAllWorkspaceFiles({ + principal, + input, +}: { + principal: Principal + input: ListAllWorkspaceFilesInput +}) { + const workspace = await requireListWorkspaceFileAccess(principal, input.workspaceId) + const files = await listWorkspaceFiles(workspace.workspaceId, { scope: input.scope }) + const shares = await getWorkspaceShares('file', workspace.workspaceId) + return { + files: files.map((file) => ({ ...file, share: shares.get(file.id) ?? null })), + } +} + +async function executeQueryWorkspaceFilePage({ + principal, + input, +}: { + principal: Principal + input: QueryWorkspaceFilePageInput +}) { + const workspace = await requireListWorkspaceFileAccess(principal, input.workspaceId) + const folderIndex = await loadActiveFolderPathIndex(workspace.workspaceId, 'file') + const folderId = + input.folderPath === undefined + ? undefined + : input.folderPath === ROOT_FOLDER_PATH + ? null + : folderIndex.idByPath.get(input.folderPath) + if (input.folderPath !== undefined && folderId === undefined) { + throw new OrchestrationError('not_found', 'Folder not found') + } + + const { files, nextKeys } = await queryWorkspaceFiles(workspace.workspaceId, { + folderId, + search: input.search, + sortBy: input.sortBy, + sortOrder: input.sortOrder, + limit: input.limit, + after: input.after, + }) + return { files, nextKeys, cursorSort: input.cursorSort } +} + +export const listAllWorkspaceFiles = { + operation: fileOperations.list, + execute: executeListAllWorkspaceFiles, +} as const + +export const queryWorkspaceFilePage = { + operation: fileOperations.list, + execute: executeQueryWorkspaceFilePage, +} as const diff --git a/apps/sim/lib/workspace-files/application/load-authorized-workspace-file.test.ts b/apps/sim/lib/workspace-files/application/load-authorized-workspace-file.test.ts new file mode 100644 index 00000000000..6d26662de9b --- /dev/null +++ b/apps/sim/lib/workspace-files/application/load-authorized-workspace-file.test.ts @@ -0,0 +1,92 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadContext: vi.fn(), + authorize: vi.fn(), +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + loadActiveWorkspaceFileContext: mocks.loadContext, +})) + +vi.mock('@/lib/workspace-files/application/authorization', () => ({ + authorizeWorkspaceOperation: mocks.authorize, +})) + +import { loadAuthorizedWorkspaceFile } from '@/lib/workspace-files/application/load-authorized-workspace-file' +import { fileOperations } from '@/lib/workspace-files/application/operations' + +const canonical = { + fileId: 'file-1', + workspaceId: 'workspace-1', + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +describe('loadAuthorizedWorkspaceFile', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadContext.mockResolvedValue(canonical) + mocks.authorize.mockResolvedValue(undefined) + }) + + it('loads canonically and authorizes the requested operation', async () => { + const principal = { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' } + + await expect( + loadAuthorizedWorkspaceFile({ + principal, + operation: fileOperations.readMetadata, + fileId: 'file-1', + assertedWorkspaceId: 'workspace-1', + }) + ).resolves.toBe(canonical) + + expect(mocks.authorize).toHaveBeenCalledWith(principal, fileOperations.readMetadata, { + workspaceId: 'workspace-1', + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, + fileId: 'file-1', + }) + }) + + it('conceals missing and asserted-workspace mismatches before authorization', async () => { + const principal = { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' } + mocks.loadContext.mockResolvedValueOnce(null) + + await expect( + loadAuthorizedWorkspaceFile({ + principal, + operation: fileOperations.readMetadata, + fileId: 'file-1', + }) + ).rejects.toMatchObject({ code: 'not_found' }) + + await expect( + loadAuthorizedWorkspaceFile({ + principal, + operation: fileOperations.readMetadata, + fileId: 'file-1', + assertedWorkspaceId: 'workspace-2', + }) + ).rejects.toMatchObject({ code: 'not_found' }) + expect(mocks.authorize).not.toHaveBeenCalled() + }) + + it('propagates canonical-load infrastructure failures', async () => { + const failure = new Error('database unavailable') + mocks.loadContext.mockRejectedValueOnce(failure) + + await expect( + loadAuthorizedWorkspaceFile({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + operation: fileOperations.readMetadata, + fileId: 'file-1', + }) + ).rejects.toBe(failure) + }) +}) diff --git a/apps/sim/lib/workspace-files/application/load-authorized-workspace-file.ts b/apps/sim/lib/workspace-files/application/load-authorized-workspace-file.ts new file mode 100644 index 00000000000..f7b49020646 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/load-authorized-workspace-file.ts @@ -0,0 +1,41 @@ +import type { Principal } from '@sim/auth/principal' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + type ActiveWorkspaceFileContext, + loadActiveWorkspaceFileContext, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { authorizeWorkspaceOperation } from '@/lib/workspace-files/application/authorization' +import type { WorkspaceOperation } from '@/lib/workspace-files/application/operations' + +interface LoadAuthorizedWorkspaceFileArgs { + principal: Principal + operation: WorkspaceOperation + fileId: string + assertedWorkspaceId?: string + includeDeleted?: boolean +} + +export async function loadAuthorizedWorkspaceFile({ + principal, + operation, + fileId, + assertedWorkspaceId, + includeDeleted, +}: LoadAuthorizedWorkspaceFileArgs): Promise { + const canonical = await loadActiveWorkspaceFileContext(fileId, { includeDeleted }) + if ( + !canonical || + (assertedWorkspaceId !== undefined && assertedWorkspaceId !== canonical.workspaceId) + ) { + throw new OrchestrationError('not_found', 'File not found') + } + + await authorizeWorkspaceOperation(principal, operation, { + workspaceId: canonical.workspaceId, + workspaceOrganizationId: canonical.workspaceOrganizationId, + allowPersonalApiKeys: canonical.allowPersonalApiKeys, + fileId: canonical.fileId, + }) + + return canonical +} diff --git a/apps/sim/lib/workspace-files/application/move-workspace-file-items.test.ts b/apps/sim/lib/workspace-files/application/move-workspace-file-items.test.ts new file mode 100644 index 00000000000..80b6c22428b --- /dev/null +++ b/apps/sim/lib/workspace-files/application/move-workspace-file-items.test.ts @@ -0,0 +1,98 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockAuthorize, mockMove, mockAudit, mockNotify } = vi.hoisted(() => ({ + mockAuthorize: vi.fn(), + mockMove: vi.fn(), + mockAudit: vi.fn(), + mockNotify: vi.fn(), +})) + +vi.mock('@/lib/workspace-files/application/workspace-operation-context', () => ({ + authorizeWorkspaceFileOperation: mockAuthorize, +})) +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + assertWorkspaceFileItemsBelongToWorkspace: mockAuthorize, + moveWorkspaceFileItems: mockMove, +})) +vi.mock('@sim/audit', () => ({ + AuditAction: { FILE_MOVED: 'file.moved', FOLDER_MOVED: 'folder.moved' }, + AuditResourceType: { FILE: 'file', FOLDER: 'folder' }, + recordAudit: mockAudit, +})) +vi.mock('@/lib/realtime/notify', () => ({ notifyWorkspaceFilesChanged: mockNotify })) + +import { moveWorkspaceFileItemsOperation } from '@/lib/workspace-files/application/move-workspace-file-items' +import { fileOperations } from '@/lib/workspace-files/application/operations' + +describe('moveWorkspaceFileItemsOperation', () => { + beforeEach(() => { + vi.clearAllMocks() + mockAuthorize.mockResolvedValue({ + context: { + workspaceId: 'ws-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'owner-1', + }, + attribution: { attributedUserId: 'user-1', actor: { kind: 'session', userId: 'user-1' } }, + }) + mockMove.mockResolvedValue({ movedFiles: 2, movedFolders: 1 }) + }) + + it('uses the atomic manager primitive and records each semantic category once', async () => { + const result = await moveWorkspaceFileItemsOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + workspaceId: 'ws-1', + fileIds: ['file-1', 'file-2'], + folderIds: ['folder-1'], + targetFolderId: null, + }, + }) + + expect(result).toEqual({ movedItems: { files: 2, folders: 1 } }) + expect(mockAuthorize).toHaveBeenCalledWith( + expect.anything(), + fileOperations.move, + 'ws-1', + 'file-1' + ) + expect(mockMove).toHaveBeenCalledWith({ + workspaceId: 'ws-1', + fileIds: ['file-1', 'file-2'], + folderIds: ['folder-1'], + targetFolderId: null, + targetFolderPath: undefined, + }) + expect(mockAudit).toHaveBeenCalledTimes(2) + expect(mockNotify).toHaveBeenCalledOnce() + }) + + it('fails before touching storage when the selection is empty', async () => { + await expect( + moveWorkspaceFileItemsOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: 'ws-1' }, + }) + ).rejects.toThrow('At least one file or folder must be selected') + expect(mockAuthorize).not.toHaveBeenCalled() + expect(mockMove).not.toHaveBeenCalled() + }) + + it('rejects oversized selections before authorization', async () => { + await expect( + moveWorkspaceFileItemsOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + workspaceId: 'ws-1', + folderIds: Array.from({ length: 1_001 }, (_, index) => `folder-${index}`), + }, + }) + ).rejects.toThrow('accept at most 1000') + expect(mockAuthorize).not.toHaveBeenCalled() + expect(mockMove).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workspace-files/application/move-workspace-file-items.ts b/apps/sim/lib/workspace-files/application/move-workspace-file-items.ts new file mode 100644 index 00000000000..74a2d6f35dc --- /dev/null +++ b/apps/sim/lib/workspace-files/application/move-workspace-file-items.ts @@ -0,0 +1,117 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { type Principal, resolvePrincipalAuditAttribution } from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import { + OrchestrationError, + type OrchestrationRequestContext, +} from '@/lib/core/orchestration/types' +import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' +import { + assertWorkspaceFileItemsBelongToWorkspace, + moveWorkspaceFileItems, +} from '@/lib/uploads/contexts/workspace' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { authorizeWorkspaceFileOperation } from '@/lib/workspace-files/application/workspace-operation-context' +import { MAX_WORKSPACE_FILE_BULK_REQUEST_IDS } from '@/lib/workspace-files/limits' + +const logger = createLogger('MoveWorkspaceFileItems') + +export interface MoveWorkspaceFileItemsInput { + workspaceId: string + fileIds?: string[] + folderIds?: string[] + targetFolderId?: string | null + targetFolderPath?: string +} + +export interface MoveWorkspaceFileItemsResult { + movedItems: { files: number; folders: number } +} + +async function executeMoveWorkspaceFileItems(args: { + principal: Principal + input: MoveWorkspaceFileItemsInput + request?: OrchestrationRequestContext +}): Promise { + const fileIds = [...new Set(args.input.fileIds ?? [])] + const folderIds = [...new Set(args.input.folderIds ?? [])] + if (fileIds.length === 0 && folderIds.length === 0) { + throw new OrchestrationError('validation', 'At least one file or folder must be selected') + } + if ( + fileIds.length > MAX_WORKSPACE_FILE_BULK_REQUEST_IDS || + folderIds.length > MAX_WORKSPACE_FILE_BULK_REQUEST_IDS + ) { + throw new OrchestrationError( + 'validation', + `Bulk file operations accept at most ${MAX_WORKSPACE_FILE_BULK_REQUEST_IDS} file and folder IDs` + ) + } + + const { context } = await authorizeWorkspaceFileOperation( + args.principal, + fileOperations.move, + args.input.workspaceId, + fileIds[0] + ) + const auditAttribution = resolvePrincipalAuditAttribution(args.principal) + await assertWorkspaceFileItemsBelongToWorkspace({ + workspaceId: context.workspaceId, + fileIds, + folderIds, + }) + const moved = await moveWorkspaceFileItems({ + workspaceId: context.workspaceId, + fileIds, + folderIds, + targetFolderId: args.input.targetFolderId, + targetFolderPath: args.input.targetFolderPath, + }) + const movedItems = { files: moved.movedFiles, folders: moved.movedFolders } + + if (fileIds.length > 0) { + recordAudit({ + workspaceId: context.workspaceId, + actorId: auditAttribution.actorId, + actorName: auditAttribution.actorName, + action: AuditAction.FILE_MOVED, + resourceType: AuditResourceType.FILE, + description: `Moved ${fileIds.length} file${fileIds.length === 1 ? '' : 's'}`, + metadata: { + operation: fileOperations.move.id, + actor: auditAttribution.actor, + fileIds, + targetFolderId: args.input.targetFolderId, + targetFolderPath: args.input.targetFolderPath, + }, + request: args.request, + }) + } + if (folderIds.length > 0) { + recordAudit({ + workspaceId: context.workspaceId, + actorId: auditAttribution.actorId, + actorName: auditAttribution.actorName, + action: AuditAction.FOLDER_MOVED, + resourceType: AuditResourceType.FOLDER, + resourceId: folderIds.length === 1 ? folderIds[0] : undefined, + description: `Moved ${folderIds.length} file folder${folderIds.length === 1 ? '' : 's'}`, + metadata: { + operation: fileOperations.move.id, + actor: auditAttribution.actor, + folderIds, + targetFolderId: args.input.targetFolderId, + targetFolderPath: args.input.targetFolderPath, + }, + request: args.request, + }) + } + await notifyWorkspaceFilesChanged(context.workspaceId) + logger.info('Moved workspace file items', { workspaceId: context.workspaceId, movedItems }) + return { movedItems } +} + +export const moveWorkspaceFileItemsOperation = { + operation: fileOperations.move, + execute: executeMoveWorkspaceFileItems, +} as const diff --git a/apps/sim/lib/workspace-files/application/operations.test.ts b/apps/sim/lib/workspace-files/application/operations.test.ts new file mode 100644 index 00000000000..cd115a058df --- /dev/null +++ b/apps/sim/lib/workspace-files/application/operations.test.ts @@ -0,0 +1,29 @@ +/** + * @vitest-environment node + */ + +import { permissionSatisfies } from '@sim/platform-authz/workspace' +import { describe, expect, it } from 'vitest' +import { fileOperations } from '@/lib/workspace-files/application/operations' + +describe('file operation registry', () => { + it('keeps every workspace-key operation at or below the fixed write ceiling', () => { + for (const operation of Object.values(fileOperations)) { + if (operation.workspaceApiKey === 'allow') { + expect( + permissionSatisfies('write', operation.minimumRole), + `${operation.id} exceeds the workspace API-key write ceiling` + ).toBe(true) + } + } + }) + + it('uses unique stable operation IDs', () => { + const ids = Object.values(fileOperations).map((operation) => operation.id) + expect(new Set(ids).size).toBe(ids.length) + }) + + it('keeps external sharing policy changes human-delegated', () => { + expect(fileOperations.updateShare.workspaceApiKey).toBe('deny') + }) +}) diff --git a/apps/sim/lib/workspace-files/application/operations.ts b/apps/sim/lib/workspace-files/application/operations.ts index 7a6dfffb2d4..7d86191397d 100644 --- a/apps/sim/lib/workspace-files/application/operations.ts +++ b/apps/sim/lib/workspace-files/application/operations.ts @@ -18,11 +18,116 @@ function defineWorkspaceOperation => { + const canonical = await loadAuthorizedWorkspaceFile({ + principal, + operation, + fileId: input.fileId, + assertedWorkspaceId: input.assertedWorkspaceId, + includeDeleted: input.includeDeleted, + }) + const file = await getWorkspaceFile(canonical.workspaceId, canonical.fileId, { + includeDeleted: input.includeDeleted, + throwOnError: true, + }) + if (!file) throw new OrchestrationError('not_found', 'File not found') + return { + file, + content: await fetchWorkspaceFileBuffer(file, { maxBytes: input.maxBytes }), + } + } +} + +export const readWorkspaceFileContent = { + operation: fileOperations.readContent, + execute: executeReadWorkspaceFileContent(fileOperations.readContent), +} as const diff --git a/apps/sim/lib/workspace-files/application/read-workspace-file-metadata.test.ts b/apps/sim/lib/workspace-files/application/read-workspace-file-metadata.test.ts new file mode 100644 index 00000000000..6cb73ce7f9f --- /dev/null +++ b/apps/sim/lib/workspace-files/application/read-workspace-file-metadata.test.ts @@ -0,0 +1,84 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadAuthorized: vi.fn(), + getWorkspaceFile: vi.fn(), +})) + +vi.mock('@/lib/workspace-files/application/load-authorized-workspace-file', () => ({ + loadAuthorizedWorkspaceFile: mocks.loadAuthorized, +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + getWorkspaceFile: mocks.getWorkspaceFile, +})) + +import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' + +const canonical = { + fileId: 'file-1', + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner-1', +} + +const file = { + id: 'file-1', + workspaceId: 'workspace-1', + name: 'data.csv', + key: 'workspace/ws/data.csv', + path: '/api/files/serve/data.csv', + size: 42, + type: 'text/csv', + uploadedBy: 'user-1', + folderId: null, + uploadedAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-02T00:00:00Z'), +} + +describe('readWorkspaceFileMetadata', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadAuthorized.mockResolvedValue(canonical) + mocks.getWorkspaceFile.mockResolvedValue(file) + }) + + it('returns the canonical active file without side effects', async () => { + const principal = { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' } + + await expect( + readWorkspaceFileMetadata.execute({ + principal, + input: { + fileId: 'file-1', + assertedWorkspaceId: 'workspace-1', + }, + }) + ).resolves.toEqual({ file }) + + expect(mocks.loadAuthorized).toHaveBeenCalledWith({ + principal, + operation: readWorkspaceFileMetadata.operation, + fileId: 'file-1', + assertedWorkspaceId: 'workspace-1', + }) + expect(mocks.getWorkspaceFile).toHaveBeenCalledWith('workspace-1', 'file-1', { + throwOnError: true, + }) + }) + + it('fails fast if the authorized file disappears before projection', async () => { + mocks.getWorkspaceFile.mockResolvedValueOnce(null) + + await expect( + readWorkspaceFileMetadata.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { fileId: 'file-1' }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + }) +}) diff --git a/apps/sim/lib/workspace-files/application/read-workspace-file-metadata.ts b/apps/sim/lib/workspace-files/application/read-workspace-file-metadata.ts new file mode 100644 index 00000000000..89b5aa89aea --- /dev/null +++ b/apps/sim/lib/workspace-files/application/read-workspace-file-metadata.ts @@ -0,0 +1,49 @@ +import type { Principal } from '@sim/auth/principal' +import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + getWorkspaceFile, + type WorkspaceFileRecord, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { loadAuthorizedWorkspaceFile } from '@/lib/workspace-files/application/load-authorized-workspace-file' +import { fileOperations } from '@/lib/workspace-files/application/operations' + +export interface ReadWorkspaceFileMetadataInput { + fileId: string + assertedWorkspaceId?: string + includeDeleted?: boolean +} + +export interface ReadWorkspaceFileMetadataResult { + file: WorkspaceFileRecord +} + +interface ReadWorkspaceFileMetadataArguments { + principal: Principal + input: ReadWorkspaceFileMetadataInput + request?: OrchestrationRequestContext +} + +async function executeReadWorkspaceFileMetadata({ + principal, + input, +}: ReadWorkspaceFileMetadataArguments): Promise { + const canonical = await loadAuthorizedWorkspaceFile({ + principal, + operation: fileOperations.readMetadata, + fileId: input.fileId, + assertedWorkspaceId: input.assertedWorkspaceId, + includeDeleted: input.includeDeleted, + }) + const file = await getWorkspaceFile(canonical.workspaceId, canonical.fileId, { + includeDeleted: input.includeDeleted, + throwOnError: true, + }) + if (!file) throw new OrchestrationError('not_found', 'File not found') + return { file } +} + +export const readWorkspaceFileMetadata = { + operation: fileOperations.readMetadata, + execute: executeReadWorkspaceFileMetadata, +} as const diff --git a/apps/sim/lib/workspace-files/application/read-workspace-file-record.ts b/apps/sim/lib/workspace-files/application/read-workspace-file-record.ts new file mode 100644 index 00000000000..be5c901b5fb --- /dev/null +++ b/apps/sim/lib/workspace-files/application/read-workspace-file-record.ts @@ -0,0 +1,54 @@ +import type { Principal } from '@sim/auth/principal' +import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + getWorkspaceFile, + type WorkspaceFileRecord, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { loadAuthorizedWorkspaceFile } from '@/lib/workspace-files/application/load-authorized-workspace-file' +import { + fileOperations, + type WorkspaceOperation, +} from '@/lib/workspace-files/application/operations' + +export interface ReadWorkspaceFileRecordInput { + fileId: string + assertedWorkspaceId?: string +} + +export interface ReadWorkspaceFileRecordResult { + file: WorkspaceFileRecord +} + +function executeReadWorkspaceFileRecord(operation: WorkspaceOperation) { + return async ({ + principal, + input, + }: { + principal: Principal + input: ReadWorkspaceFileRecordInput + request?: OrchestrationRequestContext + }): Promise => { + const canonical = await loadAuthorizedWorkspaceFile({ + principal, + operation, + fileId: input.fileId, + assertedWorkspaceId: input.assertedWorkspaceId, + }) + const file = await getWorkspaceFile(canonical.workspaceId, canonical.fileId, { + throwOnError: true, + }) + if (!file) throw new OrchestrationError('not_found', 'File not found') + return { file } + } +} + +export const readWorkspaceFileContentRecord = { + operation: fileOperations.readContent, + execute: executeReadWorkspaceFileRecord(fileOperations.readContent), +} as const + +export const downloadWorkspaceFileRecord = { + operation: fileOperations.download, + execute: executeReadWorkspaceFileRecord(fileOperations.download), +} as const diff --git a/apps/sim/lib/workspace-files/application/read-workspace-inline-file.test.ts b/apps/sim/lib/workspace-files/application/read-workspace-inline-file.test.ts new file mode 100644 index 00000000000..5b2038bcb8b --- /dev/null +++ b/apps/sim/lib/workspace-files/application/read-workspace-inline-file.test.ts @@ -0,0 +1,90 @@ +/** + * @vitest-environment node + */ +import { Readable } from 'node:stream' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockLoadAuthorized, mockGetWorkspaceFile, mockGetMetadataByKey, mockDownloadFileStream } = + vi.hoisted(() => ({ + mockLoadAuthorized: vi.fn(), + mockGetWorkspaceFile: vi.fn(), + mockGetMetadataByKey: vi.fn(), + mockDownloadFileStream: vi.fn(), + })) + +vi.mock('@/lib/workspace-files/application/load-authorized-workspace-file', () => ({ + loadAuthorizedWorkspaceFile: mockLoadAuthorized, +})) +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + getWorkspaceFile: mockGetWorkspaceFile, +})) +vi.mock('@/lib/uploads/server/metadata', () => ({ getFileMetadataByKey: mockGetMetadataByKey })) +vi.mock('@/lib/uploads/core/storage-service', () => ({ + downloadFileStream: mockDownloadFileStream, +})) + +import { readWorkspaceInlineFile } from '@/lib/workspace-files/application/read-workspace-inline-file' + +const principal = { kind: 'session' as const, userId: 'u1', sessionId: 's1' } +const file = { + id: 'f1', + workspaceId: 'ws-1', + key: 'workspace/ws-1/photo.png', + name: 'photo.png', + type: 'image/png', +} + +describe('readWorkspaceInlineFile', () => { + beforeEach(() => { + vi.clearAllMocks() + mockLoadAuthorized.mockResolvedValue({ fileId: 'f1', workspaceId: 'ws-1' }) + mockGetWorkspaceFile.mockResolvedValue(file) + mockDownloadFileStream.mockResolvedValue(Readable.from(Buffer.from('png'))) + }) + + it('authorizes a file-id reference against the asserted workspace before reading bytes', async () => { + const result = await readWorkspaceInlineFile.execute({ + principal, + input: { workspaceId: 'ws-1', fileId: 'f1' }, + }) + + expect(mockLoadAuthorized).toHaveBeenCalledWith({ + principal, + operation: expect.objectContaining({ id: 'files.read_content' }), + fileId: 'f1', + assertedWorkspaceId: 'ws-1', + }) + expect(mockDownloadFileStream).toHaveBeenCalledWith({ + key: file.key, + context: 'workspace', + }) + expect(Buffer.from(await new Response(result.stream).arrayBuffer())).toEqual(Buffer.from('png')) + }) + + it('resolves a storage-key reference to its canonical file id before authorizing', async () => { + mockGetMetadataByKey.mockResolvedValue({ id: 'f1', workspaceId: 'ws-1' }) + + await readWorkspaceInlineFile.execute({ + principal, + input: { workspaceId: 'ws-1', key: file.key }, + }) + + expect(mockGetMetadataByKey).toHaveBeenCalledWith(file.key, 'workspace') + expect(mockLoadAuthorized).toHaveBeenCalledWith( + expect.objectContaining({ fileId: 'f1', assertedWorkspaceId: 'ws-1' }) + ) + }) + + it('conceals a key belonging to another workspace before authorization', async () => { + mockGetMetadataByKey.mockResolvedValue({ id: 'other', workspaceId: 'ws-other' }) + + await expect( + readWorkspaceInlineFile.execute({ + principal, + input: { workspaceId: 'ws-1', key: 'workspace/ws-other/photo.png' }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + expect(mockLoadAuthorized).not.toHaveBeenCalled() + expect(mockDownloadFileStream).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workspace-files/application/read-workspace-inline-file.ts b/apps/sim/lib/workspace-files/application/read-workspace-inline-file.ts new file mode 100644 index 00000000000..28d7b6effd4 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/read-workspace-inline-file.ts @@ -0,0 +1,59 @@ +import type { Principal } from '@sim/auth/principal' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { nodeReadableToWebStream } from '@/lib/core/utils/node-stream' +import { + getWorkspaceFile, + type WorkspaceFileRecord, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { downloadFileStream } from '@/lib/uploads/core/storage-service' +import { getFileMetadataByKey } from '@/lib/uploads/server/metadata' +import { loadAuthorizedWorkspaceFile } from '@/lib/workspace-files/application/load-authorized-workspace-file' +import { fileOperations } from '@/lib/workspace-files/application/operations' + +export interface ReadWorkspaceInlineFileInput { + workspaceId: string + key?: string + fileId?: string +} + +export interface ReadWorkspaceInlineFileResult { + file: WorkspaceFileRecord + stream: ReadableStream +} + +async function executeReadWorkspaceInlineFile({ + principal, + input, +}: { + principal: Principal + input: ReadWorkspaceInlineFileInput +}): Promise { + let fileId = input.fileId + if (!fileId && input.key) { + const metadata = await getFileMetadataByKey(input.key, 'workspace') + if (!metadata || metadata.workspaceId !== input.workspaceId) { + throw new OrchestrationError('not_found', 'Not found') + } + fileId = metadata.id + } + if (!fileId) throw new OrchestrationError('validation', 'Provide exactly one file reference') + + const canonical = await loadAuthorizedWorkspaceFile({ + principal, + operation: fileOperations.readContent, + fileId, + assertedWorkspaceId: input.workspaceId, + }) + const file = await getWorkspaceFile(canonical.workspaceId, canonical.fileId, { + throwOnError: true, + }) + if (!file) throw new OrchestrationError('not_found', 'Not found') + + const stream = await downloadFileStream({ key: file.key, context: 'workspace' }) + return { file, stream: nodeReadableToWebStream(stream) } +} + +export const readWorkspaceInlineFile = { + operation: fileOperations.readContent, + execute: executeReadWorkspaceInlineFile, +} as const diff --git a/apps/sim/lib/workspace-files/application/rename-workspace-file.test.ts b/apps/sim/lib/workspace-files/application/rename-workspace-file.test.ts index a03401e836e..0960b3545db 100644 --- a/apps/sim/lib/workspace-files/application/rename-workspace-file.test.ts +++ b/apps/sim/lib/workspace-files/application/rename-workspace-file.test.ts @@ -68,7 +68,7 @@ describe('renameWorkspaceFile application service', () => { }) expect(result).toEqual({ file: mappedFile }) - expect(mocks.loadContext).toHaveBeenCalledWith('file-1') + expect(mocks.loadContext).toHaveBeenCalledWith('file-1', { includeDeleted: undefined }) expect(mocks.authorize).toHaveBeenCalledWith( expect.objectContaining({ kind: 'session', userId: 'user-1' }), expect.objectContaining({ id: 'files.rename' }), @@ -96,7 +96,7 @@ describe('renameWorkspaceFile application service', () => { expect(mocks.recordAudit).not.toHaveBeenCalled() }) - it('attributes a workspace-key audit to the billing owner', async () => { + it('keeps workspace-key audit attribution non-human', async () => { await renameWorkspaceFile.execute({ principal: { kind: 'workspace_api_key', workspaceId: 'workspace-1', keyId: 'key-1' }, input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-1', name: 'new.csv' }, @@ -104,7 +104,8 @@ describe('renameWorkspaceFile application service', () => { expect(mocks.recordAudit).toHaveBeenCalledWith( expect.objectContaining({ - actorId: 'billing-owner-1', + actorId: null, + actorName: 'Workspace API key', metadata: expect.objectContaining({ actor: { kind: 'workspace_api_key', diff --git a/apps/sim/lib/workspace-files/application/rename-workspace-file.ts b/apps/sim/lib/workspace-files/application/rename-workspace-file.ts index 3ce99418a3b..ad103fcb127 100644 --- a/apps/sim/lib/workspace-files/application/rename-workspace-file.ts +++ b/apps/sim/lib/workspace-files/application/rename-workspace-file.ts @@ -1,15 +1,13 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { type Principal, resolvePrincipalAttribution } from '@sim/auth/principal' +import { type Principal, resolvePrincipalAuditAttribution } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' -import { OrchestrationError } from '@/lib/core/orchestration/types' import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' import { - loadActiveWorkspaceFileContext, renameWorkspaceFile as renameStoredWorkspaceFile, type WorkspaceFileRecord, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { authorizeWorkspaceOperation } from '@/lib/workspace-files/application/authorization' +import { loadAuthorizedWorkspaceFile } from '@/lib/workspace-files/application/load-authorized-workspace-file' import { fileOperations } from '@/lib/workspace-files/application/operations' const logger = createLogger('RenameWorkspaceFile') @@ -35,24 +33,14 @@ async function executeRenameWorkspaceFile({ input, request, }: RenameWorkspaceFileArguments): Promise { - const canonical = await loadActiveWorkspaceFileContext(input.fileId) - if ( - !canonical || - (input.assertedWorkspaceId !== undefined && input.assertedWorkspaceId !== canonical.workspaceId) - ) { - throw new OrchestrationError('not_found', 'File not found') - } - - await authorizeWorkspaceOperation(principal, fileOperations.rename, { - workspaceId: canonical.workspaceId, - workspaceOrganizationId: canonical.workspaceOrganizationId, - allowPersonalApiKeys: canonical.allowPersonalApiKeys, - fileId: canonical.fileId, + const canonical = await loadAuthorizedWorkspaceFile({ + principal, + operation: fileOperations.rename, + fileId: input.fileId, + assertedWorkspaceId: input.assertedWorkspaceId, }) - const attribution = resolvePrincipalAttribution(principal, { - workspaceBillingOwnerUserId: canonical.billedAccountUserId, - }) + const auditAttribution = resolvePrincipalAuditAttribution(principal) const file = await renameStoredWorkspaceFile(canonical.workspaceId, canonical.fileId, input.name) const workspaceId = canonical.workspaceId @@ -64,7 +52,8 @@ async function executeRenameWorkspaceFile({ }) recordAudit({ workspaceId, - actorId: attribution.attributedUserId, + actorId: auditAttribution.actorId, + actorName: auditAttribution.actorName, action: AuditAction.FILE_UPDATED, resourceType: AuditResourceType.FILE, resourceId: file.id, @@ -72,7 +61,7 @@ async function executeRenameWorkspaceFile({ description: `Renamed file to "${file.name}"`, metadata: { operation: fileOperations.rename.id, - actor: attribution.actor, + actor: auditAttribution.actor, }, request, }) 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 new file mode 100644 index 00000000000..eb77bad0b46 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.ts @@ -0,0 +1,69 @@ +import type { Principal } from '@sim/auth/principal' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + loadActiveWorkspaceContext, + resolveWorkspaceFileReference as resolveStoredWorkspaceFileReference, + type WorkspaceFileRecord, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { authorizeWorkspaceOperation } from '@/lib/workspace-files/application/authorization' +import { loadAuthorizedWorkspaceFile } from '@/lib/workspace-files/application/load-authorized-workspace-file' +import { + fileOperations, + type WorkspaceOperation, +} from '@/lib/workspace-files/application/operations' +import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' + +export interface ResolveWorkspaceFileReferenceInput { + principal: Principal + operation: WorkspaceOperation + workspaceId: string + reference: string +} + +/** Resolve one workspace-file reference under an explicit semantic operation policy. */ +export async function resolveWorkspaceFileReference({ + principal, + operation, + workspaceId, + reference, +}: ResolveWorkspaceFileReferenceInput): Promise { + const workspace = await loadActiveWorkspaceContext(workspaceId) + if (!workspace) throw new OrchestrationError('not_found', 'Workspace not found') + await authorizeWorkspaceOperation(principal, operation, workspace) + + const file = await resolveStoredWorkspaceFileReference(workspaceId, reference) + if (!file) throw new OrchestrationError('not_found', 'File not found') + + await loadAuthorizedWorkspaceFile({ + principal, + operation, + fileId: file.id, + assertedWorkspaceId: workspaceId, + }) + + return file +} + +export interface ReadWorkspaceFileReferenceInput + extends Omit { + maxBytes: number +} + +/** Resolve one trusted workspace-file reference and read it under the shared file policy. */ +export async function readWorkspaceFileReference({ + principal, + workspaceId, + reference, + maxBytes, +}: ReadWorkspaceFileReferenceInput): Promise<{ file: WorkspaceFileRecord; content: Buffer }> { + const file = await resolveWorkspaceFileReference({ + principal, + operation: fileOperations.readContent, + workspaceId, + reference, + }) + return readWorkspaceFileContent.execute({ + principal, + input: { fileId: file.id, assertedWorkspaceId: workspaceId, maxBytes }, + }) +} diff --git a/apps/sim/lib/workspace-files/application/restore-workspace-file.test.ts b/apps/sim/lib/workspace-files/application/restore-workspace-file.test.ts new file mode 100644 index 00000000000..0014313a219 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/restore-workspace-file.test.ts @@ -0,0 +1,81 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadLifecycle: vi.fn(), + restoreStored: vi.fn(), + recordAudit: vi.fn(), + notify: vi.fn(), + authorize: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { FILE_RESTORED: 'FILE_RESTORED' }, + AuditResourceType: { FILE: 'FILE' }, + recordAudit: mocks.recordAudit, +})) +vi.mock('@/lib/realtime/notify', () => ({ notifyWorkspaceFilesChanged: mocks.notify })) +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + loadWorkspaceFileLifecycleContext: mocks.loadLifecycle, + restoreWorkspaceFile: mocks.restoreStored, +})) +vi.mock('@/lib/workspace-files/application/authorization', () => ({ + authorizeWorkspaceOperation: mocks.authorize, +})) + +import { restoreWorkspaceFileOperation } from '@/lib/workspace-files/application/restore-workspace-file' + +const context = { + fileId: 'file-1', + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner', + deletedAt: new Date('2026-01-01T00:00:00Z'), +} + +describe('restoreWorkspaceFileOperation', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadLifecycle.mockResolvedValue(context) + mocks.authorize.mockResolvedValue(undefined) + mocks.restoreStored.mockResolvedValue(undefined) + mocks.notify.mockResolvedValue(undefined) + }) + + it('authorizes, restores, audits, and notifies once', async () => { + const result = await restoreWorkspaceFileOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-1' }, + }) + + expect(result).toEqual({ restored: true }) + expect(mocks.authorize).toHaveBeenCalledWith( + expect.objectContaining({ kind: 'session' }), + expect.objectContaining({ id: 'files.restore' }), + expect.objectContaining({ workspaceId: 'workspace-1', fileId: 'file-1' }) + ) + expect(mocks.restoreStored).toHaveBeenCalledWith('workspace-1', 'file-1') + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-1', + actorId: 'user-1', + metadata: expect.objectContaining({ operation: 'files.restore' }), + }) + ) + expect(mocks.notify).toHaveBeenCalledWith('workspace-1') + }) + + it('conceals an asserted-workspace mismatch before authorization', async () => { + await expect( + restoreWorkspaceFileOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-2' }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + expect(mocks.authorize).not.toHaveBeenCalled() + expect(mocks.restoreStored).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workspace-files/application/restore-workspace-file.ts b/apps/sim/lib/workspace-files/application/restore-workspace-file.ts new file mode 100644 index 00000000000..af6acc75443 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/restore-workspace-file.ts @@ -0,0 +1,78 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { type Principal, resolvePrincipalAuditAttribution } from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' +import { + loadWorkspaceFileLifecycleContext, + restoreWorkspaceFile, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { authorizeWorkspaceOperation } from '@/lib/workspace-files/application/authorization' +import { fileOperations } from '@/lib/workspace-files/application/operations' + +const logger = createLogger('RestoreWorkspaceFile') + +export interface RestoreWorkspaceFileInput { + fileId: string + assertedWorkspaceId?: string +} + +export interface RestoreWorkspaceFileResult { + restored: true +} + +interface RestoreWorkspaceFileArguments { + principal: Principal + input: RestoreWorkspaceFileInput + request?: OrchestrationRequestContext +} + +async function executeRestoreWorkspaceFile({ + principal, + input, + request, +}: RestoreWorkspaceFileArguments): Promise { + const canonical = await loadWorkspaceFileLifecycleContext(input.fileId) + if ( + !canonical || + (input.assertedWorkspaceId !== undefined && input.assertedWorkspaceId !== canonical.workspaceId) + ) { + throw new OrchestrationError('not_found', 'File not found') + } + + await authorizeWorkspaceOperation(principal, fileOperations.restore, { + workspaceId: canonical.workspaceId, + workspaceOrganizationId: canonical.workspaceOrganizationId, + allowPersonalApiKeys: canonical.allowPersonalApiKeys, + fileId: canonical.fileId, + }) + + const auditAttribution = resolvePrincipalAuditAttribution(principal) + await restoreWorkspaceFile(canonical.workspaceId, canonical.fileId) + + recordAudit({ + workspaceId: canonical.workspaceId, + actorId: auditAttribution.actorId, + actorName: auditAttribution.actorName, + action: AuditAction.FILE_RESTORED, + resourceType: AuditResourceType.FILE, + resourceId: canonical.fileId, + resourceName: canonical.fileId, + description: `Restored workspace file ${canonical.fileId}`, + metadata: { operation: fileOperations.restore.id, actor: auditAttribution.actor }, + request, + }) + await notifyWorkspaceFilesChanged(canonical.workspaceId) + logger.info('Restored workspace file', { + workspaceId: canonical.workspaceId, + fileId: canonical.fileId, + principalKind: principal.kind, + }) + return { restored: true } +} + +export const restoreWorkspaceFileOperation = { + operation: fileOperations.restore, + execute: executeRestoreWorkspaceFile, +} as const diff --git a/apps/sim/lib/workspace-files/application/share-workspace-file.ts b/apps/sim/lib/workspace-files/application/share-workspace-file.ts new file mode 100644 index 00000000000..aa0c6c4e1ec --- /dev/null +++ b/apps/sim/lib/workspace-files/application/share-workspace-file.ts @@ -0,0 +1,171 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { type Principal, resolvePrincipalAuditAttribution } from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import type { ShareAuthType, ShareRecord } from '@/lib/api/contracts/public-shares' +import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + getShareForResource, + ShareValidationError, + upsertFileShare, +} from '@/lib/public-shares/share-manager' +import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { loadAuthorizedWorkspaceFile } from '@/lib/workspace-files/application/load-authorized-workspace-file' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { + PublicFileSharingNotAllowedError, + validatePublicFileSharing, +} from '@/ee/access-control/utils/permission-check' + +const logger = createLogger('WorkspaceFileShare') + +export interface GetWorkspaceFileShareInput { + fileId: string + assertedWorkspaceId?: string +} + +export interface GetWorkspaceFileShareResult { + share: ShareRecord | null +} + +export interface UpdateWorkspaceFileShareInput { + fileId: string + assertedWorkspaceId?: string + isActive: boolean + authType?: ShareAuthType + password?: string + allowedEmails?: string[] + token?: string + noOpIfInactive?: boolean +} + +export interface UpdateWorkspaceFileShareResult { + share: ShareRecord +} + +export class WorkspaceFileShareNoopError extends Error { + constructor() { + super('Workspace file is not currently shared') + this.name = 'WorkspaceFileShareNoopError' + } +} + +function requireWorkspaceFileShareUserId(principal: Principal): string { + switch (principal.kind) { + case 'session': + case 'personal_api_key': + return principal.userId + case 'delegated': + return principal.subjectUserId + case 'workspace_api_key': + throw new OrchestrationError( + 'forbidden', + 'Workspace API keys cannot change public file sharing' + ) + } +} + +async function executeGetWorkspaceFileShare({ + principal, + input, +}: { + principal: Principal + input: GetWorkspaceFileShareInput +}): Promise { + const canonical = await loadAuthorizedWorkspaceFile({ + principal, + operation: fileOperations.readShare, + fileId: input.fileId, + assertedWorkspaceId: input.assertedWorkspaceId, + }) + const share = await getShareForResource('file', canonical.fileId) + return { share } +} + +async function executeUpdateWorkspaceFileShare({ + principal, + input, + request, +}: { + principal: Principal + input: UpdateWorkspaceFileShareInput + request?: OrchestrationRequestContext +}): Promise { + const canonical = await loadAuthorizedWorkspaceFile({ + principal, + operation: fileOperations.updateShare, + fileId: input.fileId, + assertedWorkspaceId: input.assertedWorkspaceId, + }) + const file = await getWorkspaceFile(canonical.workspaceId, canonical.fileId, { + throwOnError: true, + }) + if (!file) throw new OrchestrationError('not_found', 'File not found') + + const existingShare = await getShareForResource('file', canonical.fileId) + if (input.noOpIfInactive && !input.isActive && !existingShare?.isActive) { + throw new WorkspaceFileShareNoopError() + } + + if (input.isActive) { + const effectiveAuthType = input.authType ?? existingShare?.authType ?? 'public' + try { + const subjectUserId = requireWorkspaceFileShareUserId(principal) + await validatePublicFileSharing(subjectUserId, canonical.workspaceId, effectiveAuthType) + } catch (error) { + if (error instanceof PublicFileSharingNotAllowedError) + throw new OrchestrationError('forbidden', error.message) + throw error + } + } + + let share: ShareRecord + try { + share = await upsertFileShare({ + workspaceId: canonical.workspaceId, + fileId: canonical.fileId, + userId: requireWorkspaceFileShareUserId(principal), + isActive: input.isActive, + authType: input.authType, + password: input.password, + allowedEmails: input.allowedEmails, + token: input.token, + }) + } catch (error) { + if (error instanceof ShareValidationError) { + throw new OrchestrationError('validation', error.message) + } + throw error + } + if (!share) throw new Error('Updating workspace file share returned no share') + + const auditAttribution = resolvePrincipalAuditAttribution(principal) + recordAudit({ + workspaceId: canonical.workspaceId, + actorId: auditAttribution.actorId, + actorName: auditAttribution.actorName, + action: input.isActive ? AuditAction.FILE_SHARED : AuditAction.FILE_SHARE_DISABLED, + resourceType: AuditResourceType.FILE, + resourceId: canonical.fileId, + resourceName: file.name, + description: `${input.isActive ? 'Enabled' : 'Disabled'} public share for "${file.name}"`, + metadata: { operation: fileOperations.updateShare.id, actor: auditAttribution.actor }, + request, + }) + logger.info(`${input.isActive ? 'Enabled' : 'Disabled'} share for workspace file`, { + workspaceId: canonical.workspaceId, + fileId: canonical.fileId, + principalKind: principal.kind, + }) + return { share } +} + +export const getWorkspaceFileShare = { + operation: fileOperations.readShare, + execute: executeGetWorkspaceFileShare, +} as const + +export const updateWorkspaceFileShare = { + operation: fileOperations.updateShare, + execute: executeUpdateWorkspaceFileShare, +} as const diff --git a/apps/sim/lib/workspace-files/application/style-workspace-file.ts b/apps/sim/lib/workspace-files/application/style-workspace-file.ts new file mode 100644 index 00000000000..73b3d99a803 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/style-workspace-file.ts @@ -0,0 +1,57 @@ +import type { Principal } from '@sim/auth/principal' +import { extractDocumentStyle } from '@/lib/copilot/vfs/document-style' +import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' +import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' +import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' + +const MAX_STYLE_FILE_BYTES = 100 * 1024 * 1024 + +export class StyleExtractionUnsupportedError extends Error { + constructor(message: string) { + super(message) + this.name = 'StyleExtractionUnsupportedError' + } +} + +export interface StyleWorkspaceFileInput { + fileId: string + assertedWorkspaceId?: string +} + +async function executeStyleWorkspaceFile({ + principal, + input, +}: { + principal: Principal + input: StyleWorkspaceFileInput + request?: OrchestrationRequestContext +}) { + const { file } = await readWorkspaceFileMetadata.execute({ principal, input }) + const rawExt = file.name.split('.').pop()?.toLowerCase() + if (rawExt !== 'docx' && rawExt !== 'pptx' && rawExt !== 'pdf') { + throw new StyleExtractionUnsupportedError( + 'Style extraction supports .docx, .pptx, and .pdf files' + ) + } + if (file.size > MAX_STYLE_FILE_BYTES) { + throw new StyleExtractionUnsupportedError( + 'File is too large for style extraction (limit: 100 MB)' + ) + } + const { content } = await readWorkspaceFileContent.execute({ + principal, + input: { ...input, maxBytes: MAX_STYLE_FILE_BYTES }, + }) + const summary = await extractDocumentStyle(content, rawExt) + if (!summary) { + throw new StyleExtractionUnsupportedError( + 'Could not extract style — file may be encrypted, corrupt, image-only, or contain no parseable style information' + ) + } + return summary +} + +export const styleWorkspaceFile = { + operation: readWorkspaceFileContent.operation, + execute: executeStyleWorkspaceFile, +} as const diff --git a/apps/sim/lib/workspace-files/application/update-workspace-file-content.ts b/apps/sim/lib/workspace-files/application/update-workspace-file-content.ts new file mode 100644 index 00000000000..2344573109c --- /dev/null +++ b/apps/sim/lib/workspace-files/application/update-workspace-file-content.ts @@ -0,0 +1,192 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { + type Principal, + resolvePrincipalAttribution, + resolvePrincipalAuditAttribution, +} from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + ContentVersionConflictError, + updateWorkspaceFileContent as updateStoredWorkspaceFileContent, + type WorkspaceFileRecord, +} from '@/lib/uploads/contexts/workspace' +import { + EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE, + type WorkspaceFileSecretProvenance, +} from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' +import { loadAuthorizedWorkspaceFile } from '@/lib/workspace-files/application/load-authorized-workspace-file' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { MAX_WORKSPACE_FILE_CONTENT_BYTES } from '@/lib/workspace-files/orchestration' + +const logger = createLogger('UpdateWorkspaceFileContent') + +export interface UpdateWorkspaceFileContentInput { + fileId: string + assertedWorkspaceId?: string + content: string + encoding: 'utf-8' | 'base64' + contentType?: string + provenanceMode?: 'replace_empty' | 'preserve' + secretProvenance?: WorkspaceFileSecretProvenance + syncLiveDoc?: boolean + expectedUpdatedAt?: Date +} + +export interface UpdateWorkspaceFileContentResult { + file: WorkspaceFileRecord +} + +export interface UpdateWorkspaceFileContentBufferInput + extends Omit { + content: Buffer +} + +interface UpdateWorkspaceFileContentArguments { + principal: Principal + input: UpdateWorkspaceFileContentInput + request?: OrchestrationRequestContext +} + +interface UpdateWorkspaceFileContentBufferArguments { + principal: Principal + input: UpdateWorkspaceFileContentBufferInput + request?: OrchestrationRequestContext +} + +async function admitUpdateWorkspaceFileContent( + principal: Principal, + fileId: string +): Promise { + await loadAuthorizedWorkspaceFile({ + principal, + operation: fileOperations.updateContent, + fileId, + }) +} + +async function executeUpdateWorkspaceFileContent({ + principal, + input, + request, +}: UpdateWorkspaceFileContentArguments): Promise { + const canonical = await loadAuthorizedWorkspaceFile({ + principal, + operation: fileOperations.updateContent, + fileId: input.fileId, + assertedWorkspaceId: input.assertedWorkspaceId, + }) + const content = Buffer.from(input.content, input.encoding === 'base64' ? 'base64' : 'utf-8') + if (content.length > MAX_WORKSPACE_FILE_CONTENT_BYTES) { + throw new OrchestrationError( + 'payload_too_large', + `File size exceeds ${MAX_WORKSPACE_FILE_CONTENT_BYTES / 1024 / 1024}MB limit` + ) + } + + return updateAuthorizedWorkspaceFileContent({ principal, input, content, request, canonical }) +} + +async function executeUpdateWorkspaceFileContentBuffer({ + principal, + input, + request, +}: UpdateWorkspaceFileContentBufferArguments): Promise { + const canonical = await loadAuthorizedWorkspaceFile({ + principal, + operation: fileOperations.updateContent, + fileId: input.fileId, + assertedWorkspaceId: input.assertedWorkspaceId, + }) + return updateAuthorizedWorkspaceFileContent({ + principal, + input, + content: input.content, + request, + canonical, + }) +} + +async function updateAuthorizedWorkspaceFileContent({ + principal, + input, + content, + request, + canonical, +}: { + principal: Principal + input: Omit + content: Buffer + request?: OrchestrationRequestContext + canonical: Awaited> +}): Promise { + const attribution = resolvePrincipalAttribution(principal, { + workspaceBillingOwnerUserId: canonical.billedAccountUserId, + }) + const auditAttribution = resolvePrincipalAuditAttribution(principal) + + let file: WorkspaceFileRecord + try { + file = await updateStoredWorkspaceFileContent( + canonical.workspaceId, + canonical.fileId, + attribution.attributedUserId, + content, + input.contentType, + { + ...(input.expectedUpdatedAt ? { expectedUpdatedAt: input.expectedUpdatedAt } : {}), + syncLiveDoc: input.syncLiveDoc, + secretProvenancePolicy: { + ...(input.provenanceMode === 'preserve' + ? { mode: 'preserve' as const } + : { + mode: 'replace' as const, + provenance: input.secretProvenance ?? EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE, + }), + }, + } + ) + } catch (error) { + if (error instanceof ContentVersionConflictError) { + throw new OrchestrationError('conflict', error.message) + } + throw error + } + + logger.info('Updated workspace file content', { + workspaceId: canonical.workspaceId, + fileId: canonical.fileId, + size: content.length, + principalKind: principal.kind, + }) + recordAudit({ + workspaceId: canonical.workspaceId, + actorId: auditAttribution.actorId, + actorName: auditAttribution.actorName, + action: AuditAction.FILE_UPDATED, + resourceType: AuditResourceType.FILE, + resourceId: file.id, + resourceName: file.name, + description: `Updated content of file "${file.name}"`, + metadata: { + operation: fileOperations.updateContent.id, + actor: auditAttribution.actor, + contentSize: content.length, + }, + request, + }) + + return { file } +} + +export const updateWorkspaceFileContent = { + operation: fileOperations.updateContent, + admit: admitUpdateWorkspaceFileContent, + execute: executeUpdateWorkspaceFileContent, +} as const + +export const updateWorkspaceFileContentFromBuffer = { + operation: fileOperations.updateContent, + execute: executeUpdateWorkspaceFileContentBuffer, +} as const diff --git a/apps/sim/lib/workspace-files/application/update-workspace-file-dimensions.test.ts b/apps/sim/lib/workspace-files/application/update-workspace-file-dimensions.test.ts new file mode 100644 index 00000000000..cdb76174a0f --- /dev/null +++ b/apps/sim/lib/workspace-files/application/update-workspace-file-dimensions.test.ts @@ -0,0 +1,57 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + loadAuthorized: vi.fn(), + updateDimensions: vi.fn(), +})) + +vi.mock('@/lib/workspace-files/application/load-authorized-workspace-file', () => ({ + loadAuthorizedWorkspaceFile: mocks.loadAuthorized, +})) +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + updateWorkspaceFileDimensions: mocks.updateDimensions, +})) + +import { updateWorkspaceFileDimensionsOperation } from '@/lib/workspace-files/application/update-workspace-file-dimensions' + +describe('updateWorkspaceFileDimensionsOperation', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadAuthorized.mockResolvedValue({ + fileId: 'file-1', + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner', + }) + mocks.updateDimensions.mockResolvedValue(false) + }) + + it('preserves a stale-key write as a successful false result', async () => { + const result = await updateWorkspaceFileDimensionsOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + fileId: 'file-1', + assertedWorkspaceId: 'workspace-1', + key: 'workspace/workspace-1/current-key', + width: 800, + height: 600, + }, + }) + + expect(result).toEqual({ success: false }) + expect(mocks.loadAuthorized).toHaveBeenCalledWith( + expect.objectContaining({ + operation: expect.objectContaining({ id: 'files.update_metadata' }), + }) + ) + expect(mocks.updateDimensions).toHaveBeenCalledWith('workspace-1', 'file-1', { + key: 'workspace/workspace-1/current-key', + width: 800, + height: 600, + }) + }) +}) diff --git a/apps/sim/lib/workspace-files/application/update-workspace-file-dimensions.ts b/apps/sim/lib/workspace-files/application/update-workspace-file-dimensions.ts new file mode 100644 index 00000000000..5cc117049bc --- /dev/null +++ b/apps/sim/lib/workspace-files/application/update-workspace-file-dimensions.ts @@ -0,0 +1,44 @@ +import type { Principal } from '@sim/auth/principal' +import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' +import { updateWorkspaceFileDimensions } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { loadAuthorizedWorkspaceFile } from '@/lib/workspace-files/application/load-authorized-workspace-file' +import { fileOperations } from '@/lib/workspace-files/application/operations' + +export interface UpdateWorkspaceFileDimensionsInput { + fileId: string + assertedWorkspaceId?: string + key: string + width: number + height: number +} + +export interface UpdateWorkspaceFileDimensionsResult { + success: boolean +} + +async function executeUpdateWorkspaceFileDimensions({ + principal, + input, +}: { + principal: Principal + input: UpdateWorkspaceFileDimensionsInput + request?: OrchestrationRequestContext +}): Promise { + const canonical = await loadAuthorizedWorkspaceFile({ + principal, + operation: fileOperations.updateMetadata, + fileId: input.fileId, + assertedWorkspaceId: input.assertedWorkspaceId, + }) + const success = await updateWorkspaceFileDimensions(canonical.workspaceId, canonical.fileId, { + key: input.key, + width: input.width, + height: input.height, + }) + return { success } +} + +export const updateWorkspaceFileDimensionsOperation = { + operation: fileOperations.updateMetadata, + execute: executeUpdateWorkspaceFileDimensions, +} as const diff --git a/apps/sim/lib/workspace-files/application/workspace-file-folders.test.ts b/apps/sim/lib/workspace-files/application/workspace-file-folders.test.ts new file mode 100644 index 00000000000..3c1a84a8e8e --- /dev/null +++ b/apps/sim/lib/workspace-files/application/workspace-file-folders.test.ts @@ -0,0 +1,97 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockAuthorize, mockCreate, mockRelocate, mockAudit, mockNotify } = vi.hoisted(() => ({ + mockAuthorize: vi.fn(), + mockCreate: vi.fn(), + mockRelocate: vi.fn(), + mockAudit: vi.fn(), + mockNotify: vi.fn(), +})) + +vi.mock('@/lib/workspace-files/application/workspace-operation-context', () => ({ + authorizeWorkspaceFileOperation: mockAuthorize, +})) +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + assertWorkspaceFileItemsBelongToWorkspace: mockAuthorize, + createWorkspaceFileFolderAtPath: mockCreate, + relocateWorkspaceFileFolderByPath: mockRelocate, +})) +vi.mock('@sim/audit', () => ({ + AuditAction: { FOLDER_CREATED: 'folder.created', FOLDER_MOVED: 'folder.moved' }, + AuditResourceType: { FOLDER: 'folder' }, + recordAudit: mockAudit, +})) +vi.mock('@/lib/realtime/notify', () => ({ notifyWorkspaceFilesChanged: mockNotify })) + +import { + createWorkspaceFileFolderOperation, + updateWorkspaceFileFolderOperation, +} from '@/lib/workspace-files/application/workspace-file-folders' + +const folder = { + id: 'folder-1', + workspaceId: 'ws-1', + userId: 'owner-1', + name: 'Reports', + parentId: null, + sortOrder: 0, + deletedAt: null, + createdAt: new Date('2026-01-01T00:00:00Z'), + updatedAt: new Date('2026-01-01T00:00:00Z'), +} + +describe('workspace file folder operations', () => { + beforeEach(() => { + vi.clearAllMocks() + mockAuthorize.mockResolvedValue({ + context: { + workspaceId: 'ws-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'owner-1', + }, + attribution: { attributedUserId: 'user-1', actor: { kind: 'session', userId: 'user-1' } }, + }) + }) + + it('creates a canonical path folder through the manager primitive', async () => { + mockCreate.mockResolvedValue({ folder, path: '/Reports' }) + const result = await createWorkspaceFileFolderOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: 'ws-1', path: '/Reports' }, + }) + + expect(result.folder.path).toBe('/Reports') + expect(mockCreate).toHaveBeenCalledWith({ + workspaceId: 'ws-1', + userId: 'user-1', + path: '/Reports', + }) + expect(mockAudit).toHaveBeenCalledOnce() + expect(mockNotify).toHaveBeenCalledOnce() + }) + + it('relocates a canonical path folder without invoking legacy orchestration', async () => { + mockRelocate.mockResolvedValue({ folder, path: '/Archive/Reports' }) + const result = await updateWorkspaceFileFolderOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + workspaceId: 'ws-1', + path: '/Reports', + destinationPath: '/Archive/Reports', + }, + }) + + expect(result.folder.path).toBe('/Archive/Reports') + expect(mockRelocate).toHaveBeenCalledWith({ + workspaceId: 'ws-1', + path: '/Reports', + destinationPath: '/Archive/Reports', + }) + expect(mockAudit).toHaveBeenCalledOnce() + expect(mockNotify).toHaveBeenCalledOnce() + }) +}) diff --git a/apps/sim/lib/workspace-files/application/workspace-file-folders.ts b/apps/sim/lib/workspace-files/application/workspace-file-folders.ts new file mode 100644 index 00000000000..f0e95234245 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/workspace-file-folders.ts @@ -0,0 +1,329 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { + type Principal, + resolvePrincipalAttribution, + resolvePrincipalAuditAttribution, +} from '@sim/auth/principal' +import { createLogger } from '@sim/logger' +import { + OrchestrationError, + type OrchestrationRequestContext, +} from '@/lib/core/orchestration/types' +import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' +import { + assertWorkspaceFileItemsBelongToWorkspace, + bulkArchiveWorkspaceFileItems, + createWorkspaceFileFolder, + createWorkspaceFileFolderAtPath, + deleteWorkspaceFileFolderByPath, + listWorkspaceFileFolders, + relocateWorkspaceFileFolderByPath, + restoreWorkspaceFileFolder, + updateWorkspaceFileFolder, + type WorkspaceFileArchiveResult, + type WorkspaceFileFolderRecord, +} from '@/lib/uploads/contexts/workspace' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { authorizeWorkspaceFileOperation } from '@/lib/workspace-files/application/workspace-operation-context' + +const logger = createLogger('WorkspaceFileFolders') + +export interface ListWorkspaceFileFoldersInput { + workspaceId: string + scope?: 'active' | 'archived' | 'all' + parentPath?: string + search?: string + sortBy?: 'name' | 'createdAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' +} + +export interface ListWorkspaceFileFoldersResult { + folders: WorkspaceFileFolderRecord[] +} + +export interface CreateWorkspaceFileFolderInput { + workspaceId: string + name?: string + parentId?: string | null + path?: string +} + +export interface CreateWorkspaceFileFolderResult { + folder: WorkspaceFileFolderRecord +} + +export interface UpdateWorkspaceFileFolderInput { + workspaceId: string + folderId?: string + name?: string + parentId?: string | null + sortOrder?: number + path?: string + destinationPath?: string +} + +export interface UpdateWorkspaceFileFolderResult { + folder: WorkspaceFileFolderRecord +} + +export interface DeleteWorkspaceFileFolderInput { + workspaceId: string + folderId?: string + path?: string + recursive?: boolean +} + +export interface DeleteWorkspaceFileFolderResult { + deletedItems: WorkspaceFileArchiveResult + path?: string +} + +export interface RestoreWorkspaceFileFolderInput { + workspaceId: string + folderId: string +} + +export interface RestoreWorkspaceFileFolderResult { + folder: WorkspaceFileFolderRecord + restoredItems: WorkspaceFileArchiveResult +} + +async function executeListWorkspaceFileFolders(args: { + principal: Principal + input: ListWorkspaceFileFoldersInput + request?: OrchestrationRequestContext +}): Promise { + await authorizeWorkspaceFileOperation( + args.principal, + fileOperations.listFolders, + args.input.workspaceId + ) + let folders = await listWorkspaceFileFolders(args.input.workspaceId, { scope: args.input.scope }) + if (args.input.parentPath !== undefined) { + const parentPath = args.input.parentPath === '/' ? '' : args.input.parentPath.replace(/^\//, '') + folders = folders.filter((folder) => { + const parent = folder.path.includes('/') + ? folder.path.slice(0, folder.path.lastIndexOf('/')) + : '' + return parent === parentPath + }) + } + if (args.input.search) { + const search = args.input.search.toLowerCase() + folders = folders.filter((folder) => folder.name.toLowerCase().includes(search)) + } + const sortBy = args.input.sortBy ?? 'name' + const sortOrder = args.input.sortOrder ?? 'asc' + folders.sort((left, right) => { + const leftValue = left[sortBy] + const rightValue = right[sortBy] + const comparison = leftValue < rightValue ? -1 : leftValue > rightValue ? 1 : 0 + return sortOrder === 'asc' ? comparison : -comparison + }) + return { folders } +} + +async function executeCreateWorkspaceFileFolder(args: { + principal: Principal + input: CreateWorkspaceFileFolderInput + request?: OrchestrationRequestContext +}): Promise { + const { context } = await authorizeWorkspaceFileOperation( + args.principal, + fileOperations.createFolder, + args.input.workspaceId + ) + const attribution = resolvePrincipalAttribution(args.principal, { + workspaceBillingOwnerUserId: context.billedAccountUserId, + }) + const auditAttribution = resolvePrincipalAuditAttribution(args.principal) + const result = + args.input.path !== undefined + ? await createWorkspaceFileFolderAtPath({ + workspaceId: context.workspaceId, + userId: attribution.attributedUserId, + path: args.input.path, + }) + : { + folder: await createWorkspaceFileFolder({ + workspaceId: context.workspaceId, + userId: attribution.attributedUserId, + name: args.input.name ?? '', + parentId: args.input.parentId, + }), + } + const folder = 'path' in result ? { ...result.folder, path: result.path } : result.folder + recordAudit({ + workspaceId: context.workspaceId, + actorId: auditAttribution.actorId, + actorName: auditAttribution.actorName, + action: AuditAction.FOLDER_CREATED, + resourceType: AuditResourceType.FOLDER, + resourceId: folder.id, + resourceName: folder.name, + description: `Created file folder "${folder.name}"`, + metadata: { operation: fileOperations.createFolder.id, actor: auditAttribution.actor }, + request: args.request, + }) + await notifyWorkspaceFilesChanged(context.workspaceId) + return { folder } +} + +async function executeUpdateWorkspaceFileFolder(args: { + principal: Principal + input: UpdateWorkspaceFileFolderInput + request?: OrchestrationRequestContext +}): Promise { + const { context } = await authorizeWorkspaceFileOperation( + args.principal, + fileOperations.updateFolder, + args.input.workspaceId + ) + const auditAttribution = resolvePrincipalAuditAttribution(args.principal) + let folder: WorkspaceFileFolderRecord + if (args.input.path !== undefined || args.input.destinationPath !== undefined) { + if (!args.input.path || !args.input.destinationPath) { + throw new OrchestrationError('validation', 'path and destinationPath are required') + } + const result = await relocateWorkspaceFileFolderByPath({ + workspaceId: context.workspaceId, + path: args.input.path, + destinationPath: args.input.destinationPath, + }) + folder = { ...result.folder, path: result.path } + } else { + if (!args.input.folderId) throw new OrchestrationError('validation', 'Folder ID is required') + folder = await updateWorkspaceFileFolder({ + workspaceId: context.workspaceId, + folderId: args.input.folderId, + name: args.input.name, + parentId: args.input.parentId, + sortOrder: args.input.sortOrder, + }) + } + recordAudit({ + workspaceId: context.workspaceId, + actorId: auditAttribution.actorId, + actorName: auditAttribution.actorName, + action: args.input.path !== undefined ? AuditAction.FOLDER_MOVED : AuditAction.FOLDER_UPDATED, + resourceType: AuditResourceType.FOLDER, + resourceId: folder.id, + resourceName: folder.name, + description: `Updated file folder "${folder.name}"`, + metadata: { operation: fileOperations.updateFolder.id, actor: auditAttribution.actor }, + request: args.request, + }) + await notifyWorkspaceFilesChanged(context.workspaceId) + logger.info('Updated workspace file folder', { + workspaceId: context.workspaceId, + folderId: folder.id, + }) + return { folder } +} + +async function executeDeleteWorkspaceFileFolder(args: { + principal: Principal + input: DeleteWorkspaceFileFolderInput + request?: OrchestrationRequestContext +}): Promise { + const { context } = await authorizeWorkspaceFileOperation( + args.principal, + fileOperations.deleteFolder, + args.input.workspaceId + ) + const auditAttribution = resolvePrincipalAuditAttribution(args.principal) + let deletedItems: WorkspaceFileArchiveResult + if (args.input.path !== undefined) { + deletedItems = await deleteWorkspaceFileFolderByPath({ + workspaceId: context.workspaceId, + path: args.input.path, + recursive: args.input.recursive ?? false, + }) + } else { + if (!args.input.folderId) throw new OrchestrationError('validation', 'Folder ID is required') + await assertWorkspaceFileItemsBelongToWorkspace({ + workspaceId: context.workspaceId, + folderIds: [args.input.folderId], + }) + deletedItems = await bulkArchiveWorkspaceFileItems({ + workspaceId: context.workspaceId, + folderIds: [args.input.folderId], + }) + } + recordAudit({ + workspaceId: context.workspaceId, + actorId: auditAttribution.actorId, + actorName: auditAttribution.actorName, + action: AuditAction.FOLDER_DELETED, + resourceType: AuditResourceType.FOLDER, + resourceId: args.input.folderId, + description: 'Deleted file folder', + metadata: { + operation: fileOperations.deleteFolder.id, + actor: auditAttribution.actor, + path: args.input.path, + deletedItems, + }, + request: args.request, + }) + await notifyWorkspaceFilesChanged(context.workspaceId) + return { deletedItems, path: args.input.path } +} + +async function executeRestoreWorkspaceFileFolder(args: { + principal: Principal + input: RestoreWorkspaceFileFolderInput + request?: OrchestrationRequestContext +}): Promise { + const { context } = await authorizeWorkspaceFileOperation( + args.principal, + fileOperations.restoreFolder, + args.input.workspaceId, + args.input.folderId + ) + const auditAttribution = resolvePrincipalAuditAttribution(args.principal) + const result = await restoreWorkspaceFileFolder(context.workspaceId, args.input.folderId) + recordAudit({ + workspaceId: context.workspaceId, + actorId: auditAttribution.actorId, + actorName: auditAttribution.actorName, + action: AuditAction.FOLDER_RESTORED, + resourceType: AuditResourceType.FOLDER, + resourceId: args.input.folderId, + resourceName: result.folder.name, + description: `Restored file folder "${result.folder.name}"`, + metadata: { + operation: fileOperations.restoreFolder.id, + actor: auditAttribution.actor, + restoredItems: result.restoredItems, + }, + request: args.request, + }) + await notifyWorkspaceFilesChanged(context.workspaceId) + return result +} + +export const listWorkspaceFileFoldersOperation = { + operation: fileOperations.listFolders, + execute: executeListWorkspaceFileFolders, +} as const + +export const createWorkspaceFileFolderOperation = { + operation: fileOperations.createFolder, + execute: executeCreateWorkspaceFileFolder, +} as const + +export const updateWorkspaceFileFolderOperation = { + operation: fileOperations.updateFolder, + execute: executeUpdateWorkspaceFileFolder, +} as const + +export const deleteWorkspaceFileFolderOperation = { + operation: fileOperations.deleteFolder, + execute: executeDeleteWorkspaceFileFolder, +} as const + +export const restoreWorkspaceFileFolderOperation = { + operation: fileOperations.restoreFolder, + execute: executeRestoreWorkspaceFileFolder, +} as const diff --git a/apps/sim/lib/workspace-files/application/workspace-operation-context.ts b/apps/sim/lib/workspace-files/application/workspace-operation-context.ts new file mode 100644 index 00000000000..a08b4d3d4d2 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/workspace-operation-context.ts @@ -0,0 +1,31 @@ +import type { Principal } from '@sim/auth/principal' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + loadWorkspaceFileOperationContext, + type WorkspaceFileOperationContext, +} from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' +import { authorizeWorkspaceOperation } from '@/lib/workspace-files/application/authorization' +import type { WorkspaceOperation } from '@/lib/workspace-files/application/operations' + +export interface AuthorizedWorkspaceOperationContext { + context: WorkspaceFileOperationContext +} + +export async function authorizeWorkspaceFileOperation( + principal: Principal, + operation: WorkspaceOperation, + workspaceId: string, + fileId?: string +): Promise { + const context = await loadWorkspaceFileOperationContext(workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + + await authorizeWorkspaceOperation(principal, operation, { + workspaceId: context.workspaceId, + workspaceOrganizationId: context.workspaceOrganizationId, + allowPersonalApiKeys: context.allowPersonalApiKeys, + fileId, + }) + + return { context } +} diff --git a/apps/sim/lib/workspace-files/application/write-workspace-file-by-path.ts b/apps/sim/lib/workspace-files/application/write-workspace-file-by-path.ts new file mode 100644 index 00000000000..5d9c5bc9521 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/write-workspace-file-by-path.ts @@ -0,0 +1,224 @@ +import type { Principal } from '@sim/auth/principal' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { ensureWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' +import { loadActiveWorkspaceContext } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import type { WorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' +import { + createWorkspaceFile, + createWorkspaceFileFromBuffer, +} from '@/lib/workspace-files/application/create-workspace-file' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { resolveWorkspaceFileReference } from '@/lib/workspace-files/application/resolve-workspace-file-reference' +import { + updateWorkspaceFileContent, + updateWorkspaceFileContentFromBuffer, +} from '@/lib/workspace-files/application/update-workspace-file-content' +import { parseWorkspaceFileCreatePath } from '@/lib/workspace-files/workspace-file-path' + +export interface WriteWorkspaceFileByPathInput { + workspaceId: string + path: string + content: string + encoding: 'utf-8' | 'base64' + contentType: string + mode: 'create' | 'overwrite' + exactName?: boolean + syncLiveDoc?: boolean + secretProvenance?: WorkspaceFileSecretProvenance +} + +export interface WriteWorkspaceFileBufferByPathInput + extends Omit { + content: Buffer +} + +export interface WriteWorkspaceFileByPathResult { + id: string + name: string + size: number + contentType: string + downloadUrl?: string + vfsPath: string + mode: WriteWorkspaceFileByPathInput['mode'] +} + +function toResult( + file: { + id: string + name: string + size: number + type: string + url?: string + folderPath?: string | null + }, + mode: WriteWorkspaceFileByPathInput['mode'] +): WriteWorkspaceFileByPathResult { + const folderPath = file.folderPath ?? '' + const encodedFolderPath = folderPath + ? folderPath + .split('/') + .filter(Boolean) + .map((segment) => encodeURIComponent(segment)) + .join('/') + : '' + return { + id: file.id, + name: file.name, + size: file.size, + contentType: file.type, + downloadUrl: file.url, + vfsPath: `files/${encodedFolderPath ? `${encodedFolderPath}/` : ''}${encodeURIComponent(file.name)}`, + mode, + } +} + +async function executeCreate({ + principal, + input, +}: { + principal: Principal + input: WriteWorkspaceFileByPathInput +}): Promise { + const parsed = parseWorkspaceFileCreatePath(input.path) + await createWorkspaceFile.admit(principal, input.workspaceId) + + const folderUserId = await resolveFolderAttributionUserId(principal, input.workspaceId) + + const folderId = await ensureWorkspaceFileFolderPath({ + workspaceId: input.workspaceId, + userId: folderUserId, + pathSegments: parsed.folderSegments, + }) + const result = await createWorkspaceFile.execute({ + principal, + input: { + workspaceId: input.workspaceId, + name: parsed.fileName, + contentType: input.contentType, + content: input.content, + encoding: input.encoding, + folderId, + exactName: input.exactName ?? true, + secretProvenance: input.secretProvenance, + }, + }) + return toResult(result.file, 'create') +} + +async function executeOverwrite({ + principal, + input, +}: { + principal: Principal + input: WriteWorkspaceFileByPathInput +}): Promise { + const existing = await resolveWorkspaceFileReference({ + principal, + operation: fileOperations.updateContent, + workspaceId: input.workspaceId, + reference: input.path, + }) + const result = await updateWorkspaceFileContent.execute({ + principal, + input: { + fileId: existing.id, + assertedWorkspaceId: input.workspaceId, + content: input.content, + encoding: input.encoding, + contentType: input.contentType, + provenanceMode: 'replace_empty', + syncLiveDoc: input.syncLiveDoc, + secretProvenance: input.secretProvenance, + }, + }) + return toResult(result.file, 'overwrite') +} + +async function executeCreateBuffer({ + principal, + input, +}: { + principal: Principal + input: WriteWorkspaceFileBufferByPathInput +}): Promise { + const parsed = parseWorkspaceFileCreatePath(input.path) + await createWorkspaceFile.admit(principal, input.workspaceId) + const folderUserId = await resolveFolderAttributionUserId(principal, input.workspaceId) + const folderId = await ensureWorkspaceFileFolderPath({ + workspaceId: input.workspaceId, + userId: folderUserId, + pathSegments: parsed.folderSegments, + }) + const result = await createWorkspaceFileFromBuffer.execute({ + principal, + input: { + workspaceId: input.workspaceId, + name: parsed.fileName, + contentType: input.contentType, + content: input.content, + folderId, + exactName: input.exactName ?? true, + secretProvenance: input.secretProvenance, + }, + }) + return toResult(result.file, 'create') +} + +async function executeOverwriteBuffer({ + principal, + input, +}: { + principal: Principal + input: WriteWorkspaceFileBufferByPathInput +}): Promise { + const existing = await resolveWorkspaceFileReference({ + principal, + operation: fileOperations.updateContent, + workspaceId: input.workspaceId, + reference: input.path, + }) + const result = await updateWorkspaceFileContentFromBuffer.execute({ + principal, + input: { + fileId: existing.id, + assertedWorkspaceId: input.workspaceId, + content: input.content, + contentType: input.contentType, + provenanceMode: 'replace_empty', + syncLiveDoc: input.syncLiveDoc, + secretProvenance: input.secretProvenance, + }, + }) + return toResult(result.file, 'overwrite') +} + +async function resolveFolderAttributionUserId( + principal: Principal, + workspaceId: string +): Promise { + if (principal.kind === 'session' || principal.kind === 'personal_api_key') return principal.userId + if (principal.kind === 'delegated') return principal.subjectUserId + const workspace = await loadActiveWorkspaceContext(workspaceId) + if (!workspace) throw new OrchestrationError('not_found', 'Workspace not found') + return workspace.billedAccountUserId +} + +export const createWorkspaceFileByPath = { + operation: fileOperations.create, + execute: executeCreate, +} as const + +export const updateWorkspaceFileContentByPath = { + operation: fileOperations.updateContent, + execute: executeOverwrite, +} as const + +export const createWorkspaceFileBufferByPath = { + operation: fileOperations.create, + execute: executeCreateBuffer, +} as const + +export const updateWorkspaceFileContentBufferByPath = { + operation: fileOperations.updateContent, + execute: executeOverwriteBuffer, +} as const diff --git a/apps/sim/lib/workspace-files/limits.ts b/apps/sim/lib/workspace-files/limits.ts new file mode 100644 index 00000000000..5f1f72ff630 --- /dev/null +++ b/apps/sim/lib/workspace-files/limits.ts @@ -0,0 +1,2 @@ +export const MAX_WORKSPACE_FILE_BULK_REQUEST_IDS = 1_000 +export const MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS = 5_000 diff --git a/apps/sim/lib/workspace-files/workspace-file-path.ts b/apps/sim/lib/workspace-files/workspace-file-path.ts new file mode 100644 index 00000000000..f50763cc9fb --- /dev/null +++ b/apps/sim/lib/workspace-files/workspace-file-path.ts @@ -0,0 +1,29 @@ +import { canonicalWorkspaceFilePath, decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' +import { normalizeWorkspaceFileItemName } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' + +export function parseWorkspaceFileCreatePath(path: string): { + folderSegments: string[] + fileName: string + vfsPath: string +} { + const trimmed = path.trim().replace(/^\/+/, '') + if (!trimmed.startsWith('files/')) { + throw new Error('Workspace file paths must start with "files/"') + } + + const decoded = decodeVfsPathSegments(trimmed.slice('files/'.length)) + if (decoded.length === 0) { + throw new Error('Workspace file path must include a file name') + } + + const fileName = normalizeWorkspaceFileItemName(decoded.at(-1) ?? '', 'File') + const folderSegments = decoded + .slice(0, -1) + .map((segment) => normalizeWorkspaceFileItemName(segment, 'Folder')) + + return { + folderSegments, + fileName, + vfsPath: canonicalWorkspaceFilePath({ folderPath: folderSegments.join('/'), name: fileName }), + } +} diff --git a/packages/auth/src/principal.ts b/packages/auth/src/principal.ts index 4118941b3ec..18a138fa9a9 100644 --- a/packages/auth/src/principal.ts +++ b/packages/auth/src/principal.ts @@ -54,6 +54,20 @@ export interface PrincipalAttribution { attributedUserId: string } +/** + * The audit actor for an authenticated operation. + * + * `actorId` is only populated when the principal represents a real user. A + * workspace API key is deliberately actor-less in the audit table: its key and + * workspace identity remain available in `actor`, while `actorName` keeps the + * row readable without pretending the billing owner performed the action. + */ +export interface PrincipalAuditAttribution { + actor: PrincipalActor + actorId: string | null + actorName?: string +} + export interface PrincipalAttributionContext { workspaceBillingOwnerUserId?: string } @@ -80,6 +94,21 @@ export function toPrincipalActor(principal: Principal): PrincipalActor { } } +export function resolvePrincipalAuditAttribution(principal: Principal): PrincipalAuditAttribution { + const actor = toPrincipalActor(principal) + + switch (actor.kind) { + case 'session': + return { actor, actorId: actor.userId } + case 'personal_api_key': + return { actor, actorId: actor.userId } + case 'delegated': + return { actor, actorId: actor.subjectUserId } + case 'workspace_api_key': + return { actor, actorId: null, actorName: 'Workspace API key' } + } +} + export function resolvePrincipalAttribution( principal: Principal, context: PrincipalAttributionContext = {} diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index e1e2c7b5304..e27939a569e 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -150,9 +150,9 @@ const PUBLIC_API_ROUTE_HANDLER_IMPORT_PATTERN = /\bimport\s*\{[^}]*\bwithPublicApiRouteHandler\b[^}]*\}\s*from\s*['"]@\/app\/api\/public-api-route-handler['"]/ const PUBLIC_API_ROUTE_HANDLER_USAGE_PATTERN = /\bwithPublicApiRouteHandler\s*\(/ const DECLARATIVE_ROUTE_BUILDER_IMPORT_PATTERN = - /\bimport\s*\{[^}]*(?:\bdefineInternalJsonRoute\b|\bdefineV2JsonRoute\b)[^}]*\}\s*from\s*['"]@\/lib\/api\/server\/routes['"]/ + /\bimport\s*\{[^}]*(?:\bdefineInternalJsonRoute\b|\bdefineV2JsonRoute\b|\bdefineInternalBinaryRoute\b|\bdefineV2BinaryRoute\b)[^}]*\}\s*from\s*['"]@\/lib\/api\/server\/routes['"]/ const DECLARATIVE_ROUTE_BUILDER_USAGE_PATTERN = - /\b(?:defineInternalJsonRoute|defineV2JsonRoute)\s*\(/ + /\b(?:defineInternalJsonRoute|defineV2JsonRoute|defineInternalBinaryRoute|defineV2BinaryRoute)\s*\(/ const SERVER_VALIDATION_IMPORT_PATTERN = /\bfrom\s+['"]@\/lib\/api\/server(?:\/validation)?['"]/ const SCHEMA_PARSE_PATTERN = /\b\w+Schema\.(?:safeParse|parse)\(/ const CONTRACT_SERVER_HELPER_PATTERN = /\bparseToolRequest\(/ From b061854fc3b96b4448212b6bfb7ffaf37645976f Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 7 Aug 2026 22:30:52 -0700 Subject: [PATCH 4/9] improvement(files): extract shared operation foundation --- .../skills/migrate-file-operation/SKILL.md | 8 +- .claude/commands/migrate-file-operation.md | 8 +- .cursor/commands/migrate-file-operation.md | 8 +- apps/sim/app/api/files/uploads/purposes.ts | 8 +- .../app/api/function/execute/route.test.ts | 39 ++++++ apps/sim/app/api/function/execute/route.ts | 48 ++++--- .../api/v2/files/[fileId]/content/route.ts | 8 +- .../api/v2/files/[fileId]/metadata/route.ts | 8 +- apps/sim/app/api/v2/files/[fileId]/route.ts | 2 +- .../app/api/v2/files/[fileId]/share/route.ts | 8 +- .../sim/app/api/v2/files/bulk-delete/route.ts | 8 +- apps/sim/app/api/v2/files/folders/route.ts | 8 +- apps/sim/app/api/v2/files/move/route.ts | 8 +- apps/sim/app/api/v2/files/route.ts | 8 +- apps/sim/app/api/v2/files/uploads/route.ts | 8 +- .../files/[fileId]/compiled-check/route.ts | 2 +- .../[id]/files/[fileId]/content/route.ts | 2 +- .../[id]/files/[fileId]/csv-preview/route.ts | 5 +- .../[id]/files/[fileId]/dimensions/route.ts | 2 +- .../[id]/files/[fileId]/download/route.ts | 2 +- .../[id]/files/[fileId]/restore/route.ts | 2 +- .../workspaces/[id]/files/[fileId]/route.ts | 2 +- .../[id]/files/[fileId]/share/route.ts | 2 +- .../[id]/files/[fileId]/style/route.ts | 2 +- .../[id]/files/bulk-archive/route.ts | 2 +- .../files/folders/[folderId]/restore/route.ts | 2 +- .../[id]/files/folders/[folderId]/route.ts | 2 +- .../workspaces/[id]/files/folders/route.ts | 2 +- .../api/workspaces/[id]/files/move/route.ts | 2 +- .../app/api/workspaces/[id]/files/route.ts | 2 +- apps/sim/lib/api/server/routes/definition.ts | 10 +- apps/sim/lib/api/server/routes/index.ts | 12 +- .../server/routes/internal-binary-route.ts | 9 +- .../api/server/routes/internal-json-route.ts | 59 ++++----- apps/sim/lib/api/server/routes/types.ts | 17 +-- .../lib/api/server/routes/v2-binary-route.ts | 12 +- .../lib/api/server/routes/v2-json-route.ts | 30 ++--- apps/sim/lib/core/application/index.ts | 14 +++ apps/sim/lib/core/application/operation.ts | 15 +++ .../application/workspace-authorization.ts | 100 +++++++++++++++ .../core/application/workspace-operation.ts | 19 +++ .../upload-session/application.test.ts | 117 ++++++++++++++++++ .../lib/uploads/upload-session/application.ts | 2 +- .../uploads/upload-session/service.test.ts | 54 ++++++++ .../sim/lib/uploads/upload-session/service.ts | 20 +++ apps/sim/lib/workspace-files/api/index.ts | 6 + .../api/route-policies.test.ts} | 6 +- .../lib/workspace-files/api/route-policies.ts | 40 ++++++ .../application/authorization.test.ts | 12 +- .../application/authorization.ts | 102 ++++----------- .../application/create-workspace-file.ts | 4 +- .../application/list-workspace-files.ts | 4 +- .../load-authorized-workspace-file.test.ts | 2 +- .../load-authorized-workspace-file.ts | 6 +- .../workspace-files/application/operations.ts | 19 +-- .../read-workspace-file-content.ts | 6 +- .../application/read-workspace-file-record.ts | 6 +- .../application/rename-workspace-file.test.ts | 2 +- .../resolve-workspace-file-reference.ts | 10 +- .../restore-workspace-file.test.ts | 2 +- .../application/restore-workspace-file.ts | 4 +- .../workspace-operation-context.ts | 6 +- 62 files changed, 627 insertions(+), 318 deletions(-) create mode 100644 apps/sim/lib/core/application/index.ts create mode 100644 apps/sim/lib/core/application/operation.ts create mode 100644 apps/sim/lib/core/application/workspace-authorization.ts create mode 100644 apps/sim/lib/core/application/workspace-operation.ts create mode 100644 apps/sim/lib/uploads/upload-session/application.test.ts create mode 100644 apps/sim/lib/workspace-files/api/index.ts rename apps/sim/lib/{api/server/routes/internal-json-route.test.ts => workspace-files/api/route-policies.test.ts} (94%) create mode 100644 apps/sim/lib/workspace-files/api/route-policies.ts diff --git a/.agents/skills/migrate-file-operation/SKILL.md b/.agents/skills/migrate-file-operation/SKILL.md index a9a2be31ddd..a39f85f88f5 100644 --- a/.agents/skills/migrate-file-operation/SKILL.md +++ b/.agents/skills/migrate-file-operation/SKILL.md @@ -12,11 +12,15 @@ Migrate one bounded semantic operation at a time. Share authorization and busine Read these files before editing: - `packages/auth/src/principal.ts` +- `apps/sim/lib/core/application/operation.ts` +- `apps/sim/lib/core/application/workspace-operation.ts` +- `apps/sim/lib/core/application/workspace-authorization.ts` - `apps/sim/lib/workspace-files/application/operations.ts` - `apps/sim/lib/workspace-files/application/authorization.ts` - `apps/sim/lib/workspace-files/application/rename-workspace-file.ts` - `apps/sim/lib/api/server/routes/internal-json-route.ts` - `apps/sim/lib/api/server/routes/v2-json-route.ts` +- `apps/sim/lib/workspace-files/api/route-policies.ts` - `apps/sim/lib/copilot/auth/file-delegation.ts` - The internal, v2, manager, contract, Copilot, and test files for the requested operation @@ -108,7 +112,7 @@ Inside `execute`: 1. Load the active file by `fileId` without trusting a caller-supplied workspace. 2. Derive the canonical workspace and policy context from that record. 3. Treat an asserted-workspace mismatch like absence when the surface requires concealment. -4. Call `authorizeWorkspaceOperation` with the canonical context and shared operation. +4. Call the file-domain `authorizeWorkspaceFileAccess` wrapper with the canonical context and shared operation. The wrapper supplies file delegation scope to the resource-agnostic `authorizeWorkspaceOperation` primitive. 5. Resolve `PrincipalAttribution` only when a required legacy user field or audit actor needs it. 6. Call a workspace-predicated, throwing manager primitive. 7. Record semantic audit once and send shared realtime/domain notifications once. @@ -197,7 +201,7 @@ Do not claim a check passed unless it was run successfully. - Assign one agent a non-overlapping route-module and caller set. Two methods in one route file are not safe parallel assignments. - Start every agent from the same foundation commit in its own branch/worktree when possible. -- Treat `operations.ts`, contract family files, and shared route builders as merge hotspots. Make only the minimal operation-specific edit. +- Treat the file `operations.ts` registry, contract family files, and file route policies as merge hotspots. The core application primitives and shared route builders should not change for an ordinary file-operation migration. - Do not refactor shared principal, authorization, rate limiting, or error foundations unless the task explicitly assigns that ownership. - Preserve unrelated working-tree changes. Never stage architecture docs, lockfile drift, or another agent's edits. - Do not commit, push, or open a PR unless the task explicitly asks for it. diff --git a/.claude/commands/migrate-file-operation.md b/.claude/commands/migrate-file-operation.md index bf5ad2dc1b1..6e6cdc48c08 100644 --- a/.claude/commands/migrate-file-operation.md +++ b/.claude/commands/migrate-file-operation.md @@ -11,11 +11,15 @@ Migrate one bounded semantic operation at a time. Share authorization and busine Read these files before editing: - `packages/auth/src/principal.ts` +- `apps/sim/lib/core/application/operation.ts` +- `apps/sim/lib/core/application/workspace-operation.ts` +- `apps/sim/lib/core/application/workspace-authorization.ts` - `apps/sim/lib/workspace-files/application/operations.ts` - `apps/sim/lib/workspace-files/application/authorization.ts` - `apps/sim/lib/workspace-files/application/rename-workspace-file.ts` - `apps/sim/lib/api/server/routes/internal-json-route.ts` - `apps/sim/lib/api/server/routes/v2-json-route.ts` +- `apps/sim/lib/workspace-files/api/route-policies.ts` - `apps/sim/lib/copilot/auth/file-delegation.ts` - The internal, v2, manager, contract, Copilot, and test files for the requested operation @@ -107,7 +111,7 @@ Inside `execute`: 1. Load the active file by `fileId` without trusting a caller-supplied workspace. 2. Derive the canonical workspace and policy context from that record. 3. Treat an asserted-workspace mismatch like absence when the surface requires concealment. -4. Call `authorizeWorkspaceOperation` with the canonical context and shared operation. +4. Call the file-domain `authorizeWorkspaceFileAccess` wrapper with the canonical context and shared operation. The wrapper supplies file delegation scope to the resource-agnostic `authorizeWorkspaceOperation` primitive. 5. Resolve `PrincipalAttribution` only when a required legacy user field or audit actor needs it. 6. Call a workspace-predicated, throwing manager primitive. 7. Record semantic audit once and send shared realtime/domain notifications once. @@ -196,7 +200,7 @@ Do not claim a check passed unless it was run successfully. - Assign one agent a non-overlapping route-module and caller set. Two methods in one route file are not safe parallel assignments. - Start every agent from the same foundation commit in its own branch/worktree when possible. -- Treat `operations.ts`, contract family files, and shared route builders as merge hotspots. Make only the minimal operation-specific edit. +- Treat the file `operations.ts` registry, contract family files, and file route policies as merge hotspots. The core application primitives and shared route builders should not change for an ordinary file-operation migration. - Do not refactor shared principal, authorization, rate limiting, or error foundations unless the task explicitly assigns that ownership. - Preserve unrelated working-tree changes. Never stage architecture docs, lockfile drift, or another agent's edits. - Do not commit, push, or open a PR unless the task explicitly asks for it. diff --git a/.cursor/commands/migrate-file-operation.md b/.cursor/commands/migrate-file-operation.md index 822bd3ba2d7..61d033c9b64 100644 --- a/.cursor/commands/migrate-file-operation.md +++ b/.cursor/commands/migrate-file-operation.md @@ -7,11 +7,15 @@ Migrate one bounded semantic operation at a time. Share authorization and busine Read these files before editing: - `packages/auth/src/principal.ts` +- `apps/sim/lib/core/application/operation.ts` +- `apps/sim/lib/core/application/workspace-operation.ts` +- `apps/sim/lib/core/application/workspace-authorization.ts` - `apps/sim/lib/workspace-files/application/operations.ts` - `apps/sim/lib/workspace-files/application/authorization.ts` - `apps/sim/lib/workspace-files/application/rename-workspace-file.ts` - `apps/sim/lib/api/server/routes/internal-json-route.ts` - `apps/sim/lib/api/server/routes/v2-json-route.ts` +- `apps/sim/lib/workspace-files/api/route-policies.ts` - `apps/sim/lib/copilot/auth/file-delegation.ts` - The internal, v2, manager, contract, Copilot, and test files for the requested operation @@ -103,7 +107,7 @@ Inside `execute`: 1. Load the active file by `fileId` without trusting a caller-supplied workspace. 2. Derive the canonical workspace and policy context from that record. 3. Treat an asserted-workspace mismatch like absence when the surface requires concealment. -4. Call `authorizeWorkspaceOperation` with the canonical context and shared operation. +4. Call the file-domain `authorizeWorkspaceFileAccess` wrapper with the canonical context and shared operation. The wrapper supplies file delegation scope to the resource-agnostic `authorizeWorkspaceOperation` primitive. 5. Resolve `PrincipalAttribution` only when a required legacy user field or audit actor needs it. 6. Call a workspace-predicated, throwing manager primitive. 7. Record semantic audit once and send shared realtime/domain notifications once. @@ -192,7 +196,7 @@ Do not claim a check passed unless it was run successfully. - Assign one agent a non-overlapping route-module and caller set. Two methods in one route file are not safe parallel assignments. - Start every agent from the same foundation commit in its own branch/worktree when possible. -- Treat `operations.ts`, contract family files, and shared route builders as merge hotspots. Make only the minimal operation-specific edit. +- Treat the file `operations.ts` registry, contract family files, and file route policies as merge hotspots. The core application primitives and shared route builders should not change for an ordinary file-operation migration. - Do not refactor shared principal, authorization, rate limiting, or error foundations unless the task explicitly assigns that ownership. - Preserve unrelated working-tree changes. Never stage architecture docs, lockfile drift, or another agent's edits. - Do not commit, push, or open a PR unless the task explicitly asks for it. diff --git a/apps/sim/app/api/files/uploads/purposes.ts b/apps/sim/app/api/files/uploads/purposes.ts index 8ae9ffd9a0b..9c8810563a4 100644 --- a/apps/sim/app/api/files/uploads/purposes.ts +++ b/apps/sim/app/api/files/uploads/purposes.ts @@ -4,6 +4,7 @@ import { workspace } from '@sim/db/schema' import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' import { and, eq, isNull } from 'drizzle-orm' import type { CreateInternalFileUploadBody } from '@/lib/api/contracts/upload-sessions' +import type { WorkspaceOperation } from '@/lib/core/application' import { assertWorkspaceFileFolderTarget } from '@/lib/uploads/contexts/workspace' import { assertUploadSessionAuthBinding, @@ -13,8 +14,7 @@ import { } from '@/lib/uploads/upload-session/service' import { isImageFileType } from '@/lib/uploads/utils/file-utils' import { validateAttachmentFileType } from '@/lib/uploads/utils/validation' -import { authorizeWorkspaceOperation } from '@/lib/workspace-files/application/authorization' -import type { WorkspaceOperation } from '@/lib/workspace-files/application/operations' +import { authorizeWorkspaceFileAccess } from '@/lib/workspace-files/application/authorization' import { fileOperations } from '@/lib/workspace-files/application/operations' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' @@ -43,7 +43,7 @@ export async function createPurposeUploadSession( case 'workspace_file': { const context = await loadWorkspaceAuthorizationContext(body.workspaceId) if (!context) throw new UploadSessionError('not_found', 'Workspace not found') - await authorizeWorkspaceOperation(principal, fileOperations.uploadCreate, context) + await authorizeWorkspaceFileAccess(principal, fileOperations.uploadCreate, context) const folderId = await assertWorkspaceFileFolderTarget(body.workspaceId, body.folderId) return createUploadSession({ purpose: body.purpose, @@ -151,7 +151,7 @@ export async function reauthorizeWorkspaceUploadPurpose( assertUploadSessionAuthBinding(session, principal) const context = await loadWorkspaceAuthorizationContext(session.workspaceId) if (!context) throw new UploadSessionError('not_found', 'Upload session not found') - await authorizeWorkspaceOperation(principal, operation, { + await authorizeWorkspaceFileAccess(principal, operation, { workspaceId: context.workspaceId, workspaceOrganizationId: context.workspaceOrganizationId, allowPersonalApiKeys: context.allowPersonalApiKeys, diff --git a/apps/sim/app/api/function/execute/route.test.ts b/apps/sim/app/api/function/execute/route.test.ts index 70885990b8f..f22e484ee2c 100644 --- a/apps/sim/app/api/function/execute/route.test.ts +++ b/apps/sim/app/api/function/execute/route.test.ts @@ -1250,6 +1250,45 @@ describe('Function Execute API Route', () => { expect(data.output.result.message).toContain('/home/user/doc.md') }) + it('continues an overwrite when the advisory comparison fails', async () => { + envFlagsMock.isRemoteSandboxEnabled = true + const newContent = '# doc\nnew content\n' + mockExecuteInSandbox.mockResolvedValueOnce({ + result: 'done', + stdout: 'ok', + sandboxId: 'sandbox-123', + exportedFiles: { '/home/user/doc.md': newContent }, + }) + mockResolveWorkspaceFileReference.mockRejectedValueOnce( + new Error('comparison storage unavailable') + ) + + const response = await POST( + createMockRequest('POST', { + code: 'print("done")', + language: 'python', + workspaceId: 'workspace-1', + outputs: { + files: [ + { + path: 'files/doc.md', + mode: 'overwrite', + sandboxPath: '/home/user/doc.md', + mimeType: 'text/markdown', + }, + ], + }, + }) + ) + const data = await response.json() + + expect(response.status).toBe(200) + expect(data.success).toBe(true) + expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledTimes(1) + expect(data.output.result).toMatchObject({ unchanged: false }) + expect(data.output.result).not.toHaveProperty('previousSize') + }) + it('reports size, previousSize, and sha256 receipts on a successful overwrite export', async () => { envFlagsMock.isRemoteSandboxEnabled = true const newContent = '# doc\nnew content\n' diff --git a/apps/sim/app/api/function/execute/route.ts b/apps/sim/app/api/function/execute/route.ts index b2f658822e5..c8adac49106 100644 --- a/apps/sim/app/api/function/execute/route.ts +++ b/apps/sim/app/api/function/execute/route.ts @@ -1320,8 +1320,9 @@ async function appendPrivateResolvedSecretNames( * either a legitimately idempotent regeneration, or the incident signature of * code that never wrote to the declared sandboxPath (the file still holds the * mounted input). Only the model can tell those apart, so callers surface the - * fact loudly in the receipt instead of failing the write. The current content - * is only downloaded when the sizes already match. + * fact loudly in the receipt instead of failing the write. Comparison is + * advisory and never blocks the authoritative write; the current content is + * only downloaded when the sizes already match. */ async function checkOverwriteTarget( principal: Principal, @@ -1329,24 +1330,33 @@ async function checkOverwriteTarget( targetPath: string, buffer: Buffer ): Promise<{ previousSize?: number; identical: boolean }> { - const existing = await resolveWorkspaceFileReference({ - principal, - operation: fileOperations.updateContent, - workspaceId, - reference: targetPath, - }) - if (existing.size !== buffer.length) { - return { previousSize: existing.size, identical: false } + try { + const existing = await resolveWorkspaceFileReference({ + principal, + operation: fileOperations.updateContent, + workspaceId, + reference: targetPath, + }) + if (existing.size !== buffer.length) { + return { previousSize: existing.size, identical: false } + } + const { content: current } = await readWorkspaceFileContent.execute({ + principal, + input: { + fileId: existing.id, + assertedWorkspaceId: workspaceId, + maxBytes: buffer.length, + }, + }) + return { previousSize: existing.size, identical: current.equals(buffer) } + } catch (error) { + logger.warn('Unable to compare workspace overwrite target before export', { + workspaceId, + targetPath, + error: getErrorMessage(error), + }) + return { identical: false } } - const { content: current } = await readWorkspaceFileContent.execute({ - principal, - input: { - fileId: existing.id, - assertedWorkspaceId: workspaceId, - maxBytes: buffer.length, - }, - }) - return { previousSize: existing.size, identical: current.equals(buffer) } } function formatExportReceipt(bytes: number, previousSize: number | undefined, sha256: string) { diff --git a/apps/sim/app/api/v2/files/[fileId]/content/route.ts b/apps/sim/app/api/v2/files/[fileId]/content/route.ts index 3ada1f526d5..bcb3cc2609c 100644 --- a/apps/sim/app/api/v2/files/[fileId]/content/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/content/route.ts @@ -1,10 +1,6 @@ import { v2UpdateFileContentContract } from '@/lib/api/contracts/v2/files' -import { - defineV2JsonRoute, - v2ApiKeyAuth, - v2FileErrorPolicies, - v2RateLimits, -} from '@/lib/api/server/routes' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2FileErrorPolicies } from '@/lib/workspace-files/api' import { fileOperations } from '@/lib/workspace-files/application/operations' import { updateWorkspaceFileContent } from '@/lib/workspace-files/application/update-workspace-file-content' import { MAX_WORKSPACE_FILE_INLINE_BODY_BYTES } from '@/lib/workspace-files/orchestration' diff --git a/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts b/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts index 62744856fd7..a76a4862229 100644 --- a/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts @@ -1,10 +1,6 @@ import { v2GetFileContract } from '@/lib/api/contracts/v2/files' -import { - defineV2JsonRoute, - v2ApiKeyAuth, - v2FileErrorPolicies, - v2RateLimits, -} from '@/lib/api/server/routes' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2FileErrorPolicies } from '@/lib/workspace-files/api' import { fileOperations } from '@/lib/workspace-files/application/operations' import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' import { toV2File } from '@/app/api/v2/files/utils' diff --git a/apps/sim/app/api/v2/files/[fileId]/route.ts b/apps/sim/app/api/v2/files/[fileId]/route.ts index 455bc41c63d..5db1ff967de 100644 --- a/apps/sim/app/api/v2/files/[fileId]/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/route.ts @@ -7,9 +7,9 @@ import { defineV2BinaryRoute, defineV2JsonRoute, v2ApiKeyAuth, - v2FileErrorPolicies, v2RateLimits, } from '@/lib/api/server/routes' +import { v2FileErrorPolicies } from '@/lib/workspace-files/api' import { deleteWorkspaceFileOperation } from '@/lib/workspace-files/application/delete-workspace-file' import { downloadWorkspaceFileStream } from '@/lib/workspace-files/application/download-workspace-file' import { fileOperations } from '@/lib/workspace-files/application/operations' diff --git a/apps/sim/app/api/v2/files/[fileId]/share/route.ts b/apps/sim/app/api/v2/files/[fileId]/share/route.ts index e60d1d35c40..5b6e5a6a015 100644 --- a/apps/sim/app/api/v2/files/[fileId]/share/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/share/route.ts @@ -1,10 +1,6 @@ import { v2GetFileShareContract, v2UpsertFileShareContract } from '@/lib/api/contracts/v2/files' -import { - defineV2JsonRoute, - v2ApiKeyAuth, - v2FileErrorPolicies, - v2RateLimits, -} from '@/lib/api/server/routes' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2FileErrorPolicies } from '@/lib/workspace-files/api' import { fileOperations } from '@/lib/workspace-files/application/operations' import { getWorkspaceFileShare, diff --git a/apps/sim/app/api/v2/files/bulk-delete/route.ts b/apps/sim/app/api/v2/files/bulk-delete/route.ts index 2044c3fb6fc..35ae13cfe31 100644 --- a/apps/sim/app/api/v2/files/bulk-delete/route.ts +++ b/apps/sim/app/api/v2/files/bulk-delete/route.ts @@ -1,10 +1,6 @@ import { v2BulkDeleteFilesContract } from '@/lib/api/contracts/v2/files' -import { - defineV2JsonRoute, - v2ApiKeyAuth, - v2FileErrorPolicies, - v2RateLimits, -} from '@/lib/api/server/routes' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2FileErrorPolicies } from '@/lib/workspace-files/api' import { archiveWorkspaceFileItemsOperation } from '@/lib/workspace-files/application/archive-workspace-file-items' import { fileOperations } from '@/lib/workspace-files/application/operations' diff --git a/apps/sim/app/api/v2/files/folders/route.ts b/apps/sim/app/api/v2/files/folders/route.ts index 99dfc4f44de..1bbcbe91fd4 100644 --- a/apps/sim/app/api/v2/files/folders/route.ts +++ b/apps/sim/app/api/v2/files/folders/route.ts @@ -4,12 +4,8 @@ import { v2ListFileFoldersContract, v2RelocateFileFolderContract, } from '@/lib/api/contracts/v2/files' -import { - defineV2JsonRoute, - v2ApiKeyAuth, - v2FileErrorPolicies, - v2RateLimits, -} from '@/lib/api/server/routes' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2FileErrorPolicies } from '@/lib/workspace-files/api' import { fileOperations } from '@/lib/workspace-files/application/operations' import { createWorkspaceFileFolderOperation, diff --git a/apps/sim/app/api/v2/files/move/route.ts b/apps/sim/app/api/v2/files/move/route.ts index 6049c88ea56..2efa115b366 100644 --- a/apps/sim/app/api/v2/files/move/route.ts +++ b/apps/sim/app/api/v2/files/move/route.ts @@ -1,10 +1,6 @@ import { v2MoveFileItemsContract } from '@/lib/api/contracts/v2/files' -import { - defineV2JsonRoute, - v2ApiKeyAuth, - v2FileErrorPolicies, - v2RateLimits, -} from '@/lib/api/server/routes' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2FileErrorPolicies } from '@/lib/workspace-files/api' import { moveWorkspaceFileItemsOperation } from '@/lib/workspace-files/application/move-workspace-file-items' import { fileOperations } from '@/lib/workspace-files/application/operations' diff --git a/apps/sim/app/api/v2/files/route.ts b/apps/sim/app/api/v2/files/route.ts index 97b87d8aa29..bba97991356 100644 --- a/apps/sim/app/api/v2/files/route.ts +++ b/apps/sim/app/api/v2/files/route.ts @@ -4,14 +4,10 @@ import { v2ListFilesContract, } from '@/lib/api/contracts/v2/files' import { INVALID_CURSOR_MESSAGE } from '@/lib/api/list-query' -import { - defineV2JsonRoute, - v2ApiKeyAuth, - v2FileErrorPolicies, - v2RateLimits, -} from '@/lib/api/server/routes' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' import { OrchestrationError } from '@/lib/core/orchestration/types' import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' +import { v2FileErrorPolicies } from '@/lib/workspace-files/api' import { createWorkspaceFile } from '@/lib/workspace-files/application/create-workspace-file' import { queryWorkspaceFilePage } from '@/lib/workspace-files/application/list-workspace-files' import { fileOperations } from '@/lib/workspace-files/application/operations' diff --git a/apps/sim/app/api/v2/files/uploads/route.ts b/apps/sim/app/api/v2/files/uploads/route.ts index 78b0deb6a35..61194e27633 100644 --- a/apps/sim/app/api/v2/files/uploads/route.ts +++ b/apps/sim/app/api/v2/files/uploads/route.ts @@ -1,12 +1,8 @@ import { v2CreateFileUploadContract } from '@/lib/api/contracts/v2/files' -import { - defineV2JsonRoute, - v2ApiKeyAuth, - v2FileErrorPolicies, - v2RateLimits, -} from '@/lib/api/server/routes' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' import { ROOT_FOLDER_PATH } from '@/lib/folders/paths' import { createWorkspaceFileUploadOperation } from '@/lib/uploads/upload-session/application' +import { v2FileErrorPolicies } from '@/lib/workspace-files/api' import { fileOperations } from '@/lib/workspace-files/application/operations' import { toV2FileUpload } from '@/app/api/v2/files/uploads/utils' diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts index b26c7022465..fd089c9c480 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts @@ -2,10 +2,10 @@ import { NextResponse } from 'next/server' import { workspaceFileCompiledCheckContract } from '@/lib/api/contracts/workspace-files' import { defineInternalJsonRoute, - internalPlainFileErrorPolicy, internalRateLimits, internalSessionAuth, } from '@/lib/api/server/routes' +import { internalPlainFileErrorPolicy } from '@/lib/workspace-files/api' import { CompiledCheckTooLargeError, CompiledCheckUnsupportedError, diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts index 9b3aa671ef8..2ba09bca4b9 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts @@ -1,10 +1,10 @@ import { updateWorkspaceFileContentContract } from '@/lib/api/contracts/workspace-files' import { defineInternalJsonRoute, - internalFileErrorPolicy, internalRateLimits, internalSessionAuth, } from '@/lib/api/server/routes' +import { internalFileErrorPolicy } from '@/lib/workspace-files/api' import { fileOperations } from '@/lib/workspace-files/application/operations' import { updateWorkspaceFileContent } from '@/lib/workspace-files/application/update-workspace-file-content' import { MAX_WORKSPACE_FILE_INLINE_BODY_BYTES } from '@/lib/workspace-files/orchestration' diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/csv-preview/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/csv-preview/route.ts index 4d65dae99a4..f907f48935b 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/csv-preview/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/csv-preview/route.ts @@ -1,11 +1,10 @@ import { createLogger } from '@sim/logger' import { getWorkspaceCsvPreviewContract } from '@/lib/api/contracts/workspace-file-table' +import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes' import { - defineInternalJsonRoute, internalPlainFileErrorPolicy, - internalRateLimits, internalSessionOrServiceAuth, -} from '@/lib/api/server/routes' +} from '@/lib/workspace-files/api' import { csvPreviewWorkspaceFile } from '@/lib/workspace-files/application/csv-preview-workspace-file' const logger = createLogger('WorkspaceCsvPreviewAPI') diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/dimensions/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/dimensions/route.ts index 1da6fe7b5f4..098db4bd44b 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/dimensions/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/dimensions/route.ts @@ -1,10 +1,10 @@ import { updateWorkspaceFileDimensionsContract } from '@/lib/api/contracts/workspace-files' import { defineInternalJsonRoute, - internalFileErrorPolicy, internalRateLimits, internalSessionAuth, } from '@/lib/api/server/routes' +import { internalFileErrorPolicy } from '@/lib/workspace-files/api' import { fileOperations } from '@/lib/workspace-files/application/operations' import { updateWorkspaceFileDimensionsOperation } from '@/lib/workspace-files/application/update-workspace-file-dimensions' diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/download/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/download/route.ts index b016ab64218..86d51315094 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/download/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/download/route.ts @@ -3,12 +3,12 @@ import { NextResponse } from 'next/server' import { downloadWorkspaceFileUrlContract } from '@/lib/api/contracts/workspace-files' import { defineInternalJsonRoute, - internalFileErrorPolicy, internalRateLimits, internalSessionAuth, } from '@/lib/api/server/routes' import { getBaseUrl } from '@/lib/core/utils/urls' import { captureServerEvent } from '@/lib/posthog/server' +import { internalFileErrorPolicy } from '@/lib/workspace-files/api' import { downloadWorkspaceFile } from '@/lib/workspace-files/application/download-workspace-file' export const dynamic = 'force-dynamic' diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/restore/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/restore/route.ts index 9ae22043f22..a7aad76d456 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/restore/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/restore/route.ts @@ -1,10 +1,10 @@ import { restoreWorkspaceFileContract } from '@/lib/api/contracts/workspace-files' import { defineInternalJsonRoute, - internalFileErrorPolicy, internalRateLimits, internalSessionAuth, } from '@/lib/api/server/routes' +import { internalFileErrorPolicy } from '@/lib/workspace-files/api' import { fileOperations } from '@/lib/workspace-files/application/operations' import { restoreWorkspaceFileOperation } from '@/lib/workspace-files/application/restore-workspace-file' diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts index 08eed10e6fa..27b6c6494f1 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts @@ -4,11 +4,11 @@ import { } from '@/lib/api/contracts/workspace-files' import { defineInternalJsonRoute, - internalFileErrorPolicy, internalRateLimits, internalSessionAuth, } from '@/lib/api/server/routes' import { captureServerEvent } from '@/lib/posthog/server' +import { internalFileErrorPolicy } from '@/lib/workspace-files/api' import { deleteWorkspaceFileOperation } from '@/lib/workspace-files/application/delete-workspace-file' import { fileOperations } from '@/lib/workspace-files/application/operations' import { renameWorkspaceFile } from '@/lib/workspace-files/application/rename-workspace-file' diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.ts index 3e078ba42c7..f1086839fcf 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.ts @@ -1,10 +1,10 @@ import { getFileShareContract, upsertFileShareContract } from '@/lib/api/contracts/public-shares' import { defineInternalJsonRoute, - internalFileErrorPolicy, internalRateLimits, internalSessionAuth, } from '@/lib/api/server/routes' +import { internalFileErrorPolicy } from '@/lib/workspace-files/api' import { fileOperations } from '@/lib/workspace-files/application/operations' import { getWorkspaceFileShare, diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/style/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/style/route.ts index 4616bce027c..d8e82a790aa 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/style/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/style/route.ts @@ -2,10 +2,10 @@ import { NextResponse } from 'next/server' import { workspaceFileStyleContract } from '@/lib/api/contracts/workspace-files' import { defineInternalJsonRoute, - internalPlainFileErrorPolicy, internalRateLimits, internalSessionAuth, } from '@/lib/api/server/routes' +import { internalPlainFileErrorPolicy } from '@/lib/workspace-files/api' import { StyleExtractionUnsupportedError, styleWorkspaceFile, diff --git a/apps/sim/app/api/workspaces/[id]/files/bulk-archive/route.ts b/apps/sim/app/api/workspaces/[id]/files/bulk-archive/route.ts index a1caa9d8ec5..4889b9c7199 100644 --- a/apps/sim/app/api/workspaces/[id]/files/bulk-archive/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/bulk-archive/route.ts @@ -1,11 +1,11 @@ import { bulkArchiveWorkspaceFileItemsContract } from '@/lib/api/contracts/workspace-file-folders' import { defineInternalJsonRoute, - internalFileErrorPolicy, internalRateLimits, internalSessionAuth, } from '@/lib/api/server/routes' import { captureServerEvent } from '@/lib/posthog/server' +import { internalFileErrorPolicy } from '@/lib/workspace-files/api' import { archiveWorkspaceFileItemsOperation } from '@/lib/workspace-files/application/archive-workspace-file-items' import { fileOperations } from '@/lib/workspace-files/application/operations' diff --git a/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/restore/route.ts b/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/restore/route.ts index 631702a3c5b..c5d7697d8b9 100644 --- a/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/restore/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/restore/route.ts @@ -1,11 +1,11 @@ import { restoreWorkspaceFileFolderContract } from '@/lib/api/contracts/workspace-file-folders' import { defineInternalJsonRoute, - internalFileErrorPolicy, internalRateLimits, internalSessionAuth, } from '@/lib/api/server/routes' import { captureServerEvent } from '@/lib/posthog/server' +import { internalFileErrorPolicy } from '@/lib/workspace-files/api' import { fileOperations } from '@/lib/workspace-files/application/operations' import { restoreWorkspaceFileFolderOperation } from '@/lib/workspace-files/application/workspace-file-folders' diff --git a/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.ts b/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.ts index 42cdbb596aa..aadee554633 100644 --- a/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.ts @@ -4,11 +4,11 @@ import { } from '@/lib/api/contracts/workspace-file-folders' import { defineInternalJsonRoute, - internalFileErrorPolicy, internalRateLimits, internalSessionAuth, } from '@/lib/api/server/routes' import { captureServerEvent } from '@/lib/posthog/server' +import { internalFileErrorPolicy } from '@/lib/workspace-files/api' import { fileOperations } from '@/lib/workspace-files/application/operations' import { deleteWorkspaceFileFolderOperation, diff --git a/apps/sim/app/api/workspaces/[id]/files/folders/route.ts b/apps/sim/app/api/workspaces/[id]/files/folders/route.ts index 21e54a67c53..03026fd62e9 100644 --- a/apps/sim/app/api/workspaces/[id]/files/folders/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/folders/route.ts @@ -4,11 +4,11 @@ import { } from '@/lib/api/contracts/workspace-file-folders' import { defineInternalJsonRoute, - internalFileErrorPolicy, internalRateLimits, internalSessionAuth, } from '@/lib/api/server/routes' import { captureServerEvent } from '@/lib/posthog/server' +import { internalFileErrorPolicy } from '@/lib/workspace-files/api' import { fileOperations } from '@/lib/workspace-files/application/operations' import { createWorkspaceFileFolderOperation, diff --git a/apps/sim/app/api/workspaces/[id]/files/move/route.ts b/apps/sim/app/api/workspaces/[id]/files/move/route.ts index da5dcee4646..578820bd1e7 100644 --- a/apps/sim/app/api/workspaces/[id]/files/move/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/move/route.ts @@ -1,11 +1,11 @@ import { moveWorkspaceFileItemsContract } from '@/lib/api/contracts/workspace-file-folders' import { defineInternalJsonRoute, - internalFileErrorPolicy, internalRateLimits, internalSessionAuth, } from '@/lib/api/server/routes' import { captureServerEvent } from '@/lib/posthog/server' +import { internalFileErrorPolicy } from '@/lib/workspace-files/api' import { moveWorkspaceFileItemsOperation } from '@/lib/workspace-files/application/move-workspace-file-items' import { fileOperations } from '@/lib/workspace-files/application/operations' diff --git a/apps/sim/app/api/workspaces/[id]/files/route.ts b/apps/sim/app/api/workspaces/[id]/files/route.ts index 76c5387fc35..8ca74e7d497 100644 --- a/apps/sim/app/api/workspaces/[id]/files/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/route.ts @@ -4,12 +4,12 @@ import { } from '@/lib/api/contracts/workspace-files' import { defineInternalJsonRoute, - internalFileErrorPolicy, internalRateLimits, internalSessionAuth, } from '@/lib/api/server/routes' import { captureServerEvent } from '@/lib/posthog/server' import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' +import { internalFileErrorPolicy } from '@/lib/workspace-files/api' import { createWorkspaceFile } from '@/lib/workspace-files/application/create-workspace-file' import { listAllWorkspaceFiles } from '@/lib/workspace-files/application/list-workspace-files' import { fileOperations } from '@/lib/workspace-files/application/operations' diff --git a/apps/sim/lib/api/server/routes/definition.ts b/apps/sim/lib/api/server/routes/definition.ts index 04894034e8c..580653ad9e3 100644 --- a/apps/sim/lib/api/server/routes/definition.ts +++ b/apps/sim/lib/api/server/routes/definition.ts @@ -1,5 +1,5 @@ import type { AnyApiRouteContract } from '@/lib/api/contracts' -import type { WorkspaceOperation } from '@/lib/workspace-files/application/operations' +import type { ApplicationOperation } from '@/lib/core/application' export interface JsonRouteDefinitionMetadata { successStatus: number @@ -7,8 +7,8 @@ export interface JsonRouteDefinitionMetadata { export function requireJsonRouteDefinition( contract: AnyApiRouteContract, - declaredOperation: WorkspaceOperation, - useCaseOperation: WorkspaceOperation + declaredOperation: ApplicationOperation, + useCaseOperation: ApplicationOperation ): JsonRouteDefinitionMetadata { if (contract.response.mode !== 'json') { throw new Error(`${contract.method} ${contract.path} requires a JSON response contract`) @@ -32,8 +32,8 @@ export function requireJsonRouteDefinition( export function requireBinaryRouteDefinition( contract: AnyApiRouteContract, - declaredOperation: WorkspaceOperation, - useCaseOperation: WorkspaceOperation + declaredOperation: ApplicationOperation, + useCaseOperation: ApplicationOperation ): JsonRouteDefinitionMetadata { if (contract.response.mode !== 'binary') { throw new Error(`${contract.method} ${contract.path} requires a binary response contract`) diff --git a/apps/sim/lib/api/server/routes/index.ts b/apps/sim/lib/api/server/routes/index.ts index 09de43083c8..f23fe8f7548 100644 --- a/apps/sim/lib/api/server/routes/index.ts +++ b/apps/sim/lib/api/server/routes/index.ts @@ -1,16 +1,20 @@ export { defineInternalBinaryRoute } from '@/lib/api/server/routes/internal-binary-route' export { + createInternalSessionOrServiceAuth, defineInternalJsonRoute, - internalFileErrorPolicy, - internalPlainFileErrorPolicy, + type InternalAuthPolicy, + type InternalErrorPolicy, + InternalUnauthenticatedError, + internalOrchestrationErrorPolicy, + internalPlainOrchestrationErrorPolicy, internalRateLimits, internalSessionAuth, - internalSessionOrServiceAuth, } from '@/lib/api/server/routes/internal-json-route' export { defineV2BinaryRoute } from '@/lib/api/server/routes/v2-binary-route' export { defineV2JsonRoute, + type V2ErrorPolicy, v2ApiKeyAuth, - v2FileErrorPolicies, + v2OrchestrationErrorPolicy, v2RateLimits, } from '@/lib/api/server/routes/v2-json-route' diff --git a/apps/sim/lib/api/server/routes/internal-binary-route.ts b/apps/sim/lib/api/server/routes/internal-binary-route.ts index 7c16b5879ee..7d0abcfefb3 100644 --- a/apps/sim/lib/api/server/routes/internal-binary-route.ts +++ b/apps/sim/lib/api/server/routes/internal-binary-route.ts @@ -11,12 +11,11 @@ import type { BinaryResponseDescriptor, JsonNextRouteHandler, JsonRouteContext, - OperationUseCase, } from '@/lib/api/server/routes/types' import type { ParsedRequest } from '@/lib/api/server/validation' import { parseRequest } from '@/lib/api/server/validation' +import type { ApplicationOperation, OperationUseCase } from '@/lib/core/application' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import type { WorkspaceOperation } from '@/lib/workspace-files/application/operations' interface InternalBinaryRateLimitPolicy { readonly kind: 'none' @@ -30,7 +29,7 @@ interface InternalBinaryErrorPolicy { interface InternalBinaryRouteDefinition< C extends BinaryApiRouteContract, - O extends WorkspaceOperation, + O extends ApplicationOperation, I, R, > { @@ -43,7 +42,7 @@ interface InternalBinaryRouteDefinition< interface InternalBinaryRouteOptions< C extends BinaryApiRouteContract, - O extends WorkspaceOperation, + O extends ApplicationOperation, I, R, > extends InternalBinaryRouteDefinition { @@ -61,7 +60,7 @@ interface InternalBinaryRouteOptions< */ export function defineInternalBinaryRoute< C extends BinaryApiRouteContract, - O extends WorkspaceOperation, + O extends ApplicationOperation, I, R, >(options: InternalBinaryRouteOptions): JsonNextRouteHandler { diff --git a/apps/sim/lib/api/server/routes/internal-json-route.ts b/apps/sim/lib/api/server/routes/internal-json-route.ts index 3034c7c2146..7020337a093 100644 --- a/apps/sim/lib/api/server/routes/internal-json-route.ts +++ b/apps/sim/lib/api/server/routes/internal-json-route.ts @@ -11,10 +11,9 @@ import type { import { type ParseRequestOptions, parseRequest } from '@/lib/api/server/validation' import { getSession } from '@/lib/auth' import { verifyInternalToken } from '@/lib/auth/internal' +import type { ApplicationOperation } from '@/lib/core/application' import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { createWorkspaceFileDelegatedPrincipal } from '@/lib/workspace-files/application/delegated-principal' -import type { WorkspaceOperation } from '@/lib/workspace-files/application/operations' export class InternalUnauthenticatedError extends Error { constructor(message = 'Unauthorized') { @@ -33,35 +32,29 @@ export const internalSessionAuth = { }, } as const -export const internalSessionOrServiceAuth = { - async authenticate( - request: NextRequest, +export function createInternalSessionOrServiceAuth

( + bindDelegation: (args: { + subjectUserId: string params: Record - ): Promise { - if (request.headers.has('x-api-key')) { - throw new InternalUnauthenticatedError('Authentication required') - } + }) => P +): InternalAuthPolicy { + return { + async authenticate(request, params) { + if (request.headers.has('x-api-key')) { + throw new InternalUnauthenticatedError('Authentication required') + } - const authorization = request.headers.get('authorization') - if (!authorization?.startsWith('Bearer ')) return internalSessionAuth.authenticate() + const authorization = request.headers.get('authorization') + if (!authorization?.startsWith('Bearer ')) return internalSessionAuth.authenticate() - const verification = await verifyInternalToken(authorization.slice('Bearer '.length)) - if (!verification.valid || !verification.userId) { - throw new InternalUnauthenticatedError('Authentication required') - } - const workspaceId = params.id - if (typeof workspaceId !== 'string' || !workspaceId) { - throw new Error('Internal file delegation requires a workspace route parameter') - } - return createWorkspaceFileDelegatedPrincipal({ - serviceId: 'executor', - subjectUserId: verification.userId, - workspaceId, - delegationId: `internal-file:${verification.userId}`, - fileId: typeof params.fileId === 'string' ? params.fileId : undefined, - }) - }, -} as const + const verification = await verifyInternalToken(authorization.slice('Bearer '.length)) + if (!verification.valid || !verification.userId) { + throw new InternalUnauthenticatedError('Authentication required') + } + return bindDelegation({ subjectUserId: verification.userId, params }) + }, + } +} interface InternalRateLimitPolicy { readonly kind: 'none' @@ -85,7 +78,7 @@ export interface InternalErrorPolicy { unhandled?(): NextResponse } -export const internalFileErrorPolicy: InternalErrorPolicy = { +export const internalOrchestrationErrorPolicy: InternalErrorPolicy = { render(error) { const classified = asOrchestrationError(error) if (!classified) return null @@ -96,7 +89,7 @@ export const internalFileErrorPolicy: InternalErrorPolicy = { }, } -export const internalPlainFileErrorPolicy: InternalErrorPolicy = { +export const internalPlainOrchestrationErrorPolicy: InternalErrorPolicy = { render(error) { const classified = asOrchestrationError(error) if (!classified) return null @@ -110,7 +103,7 @@ export const internalPlainFileErrorPolicy: InternalErrorPolicy = { }, } -interface InternalAuthPolicy

{ +export interface InternalAuthPolicy

{ authenticate( request: NextRequest, params: Record @@ -119,7 +112,7 @@ interface InternalAuthPolicy

{ interface InternalJsonRouteOptions< C extends JsonApiRouteContract, - O extends WorkspaceOperation, + O extends ApplicationOperation, I, R, P extends Principal, @@ -139,7 +132,7 @@ interface InternalJsonRouteOptions< export function defineInternalJsonRoute< C extends JsonApiRouteContract, - O extends WorkspaceOperation, + O extends ApplicationOperation, I, R, P extends Principal, diff --git a/apps/sim/lib/api/server/routes/types.ts b/apps/sim/lib/api/server/routes/types.ts index 2e2b9f8a1ca..304dd1aca3c 100644 --- a/apps/sim/lib/api/server/routes/types.ts +++ b/apps/sim/lib/api/server/routes/types.ts @@ -1,4 +1,3 @@ -import type { Principal } from '@sim/auth/principal' import type { NextRequest } from 'next/server' import type { AnyApiRouteContract, @@ -7,8 +6,7 @@ import type { JsonResponseMode, } from '@/lib/api/contracts' import type { ParsedRequest } from '@/lib/api/server/validation' -import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' -import type { WorkspaceOperation } from '@/lib/workspace-files/application/operations' +import type { ApplicationOperation, OperationUseCase } from '@/lib/core/application' export interface JsonRouteContext { params?: @@ -16,15 +14,6 @@ export interface JsonRouteContext { | Record } -export interface OperationUseCase { - readonly operation: O - execute(args: { - principal: Principal - input: I - request?: OrchestrationRequestContext - }): Promise -} - export type JsonApiRouteContract = AnyApiRouteContract & { response: JsonResponseMode } @@ -43,7 +32,7 @@ export interface BinaryResponseDescriptor { export interface JsonRouteDefinition< C extends JsonApiRouteContract, - O extends WorkspaceOperation, + O extends ApplicationOperation, I, R, > { @@ -61,7 +50,7 @@ export type JsonNextRouteHandler = ( export interface BinaryRouteDefinition< C extends BinaryApiRouteContract, - O extends WorkspaceOperation, + O extends ApplicationOperation, I, R, > { diff --git a/apps/sim/lib/api/server/routes/v2-binary-route.ts b/apps/sim/lib/api/server/routes/v2-binary-route.ts index eb4ed586c14..eaf1a76ef60 100644 --- a/apps/sim/lib/api/server/routes/v2-binary-route.ts +++ b/apps/sim/lib/api/server/routes/v2-binary-route.ts @@ -14,12 +14,16 @@ import { type v2ApiKeyAuth, } from '@/lib/api/server/routes/v2-json-route' import { parseRequest } from '@/lib/api/server/validation' +import type { ApplicationOperation } from '@/lib/core/application' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import type { WorkspaceOperation } from '@/lib/workspace-files/application/operations' import { v2Error, v2ValidationError } from '@/app/api/v2/lib/response' -interface V2BinaryRouteOptions - extends BinaryRouteDefinition { +interface V2BinaryRouteOptions< + C extends BinaryApiRouteContract, + O extends ApplicationOperation, + I, + R, +> extends BinaryRouteDefinition { auth: typeof v2ApiKeyAuth rateLimit: V2RateLimitPolicy errorPolicy: V2ErrorPolicy @@ -27,7 +31,7 @@ interface V2BinaryRouteOptions(options: V2BinaryRouteOptions): JsonNextRouteHandler { diff --git a/apps/sim/lib/api/server/routes/v2-json-route.ts b/apps/sim/lib/api/server/routes/v2-json-route.ts index 9dbdeb95c71..62a0c572814 100644 --- a/apps/sim/lib/api/server/routes/v2-json-route.ts +++ b/apps/sim/lib/api/server/routes/v2-json-route.ts @@ -14,10 +14,10 @@ import { V2ApiKeyUnauthenticatedError, } from '@/lib/api/server/routes/v2-api-key-auth' import { type ParseRequestOptions, parseRequest } from '@/lib/api/server/validation' +import type { ApplicationOperation } from '@/lib/core/application' import { getRateLimit, RateLimiter, type SubscriptionPlan } from '@/lib/core/rate-limiter' import { getClientIp } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import type { WorkspaceOperation } from '@/lib/workspace-files/application/operations' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { v2CaughtOrchestrationError, @@ -51,7 +51,7 @@ export interface V2RateLimitPolicy { enforce( request: NextRequest, auth: V2ApiKeyAuthContext, - operation: WorkspaceOperation + operation: ApplicationOperation ): Promise } @@ -104,25 +104,15 @@ export interface V2ErrorPolicy { render(error: unknown): NextResponse | null } -export const v2FileErrorPolicies = { - default: { - render(error) { - return v2CaughtOrchestrationError(error) - }, - } satisfies V2ErrorPolicy, - concealResourceAuthorization: { - render(error) { - const response = v2CaughtOrchestrationError(error) - if (!response) return null - if (response.status === 403) return v2Error('NOT_FOUND', 'File not found') - return response - }, - } satisfies V2ErrorPolicy, -} as const +export const v2OrchestrationErrorPolicy = { + render(error) { + return v2CaughtOrchestrationError(error) + }, +} satisfies V2ErrorPolicy export async function admitV2Request( request: NextRequest, - operation: WorkspaceOperation, + operation: ApplicationOperation, authPolicy: typeof v2ApiKeyAuth, rateLimitPolicy: V2RateLimitPolicy ): Promise< @@ -163,7 +153,7 @@ export async function admitV2Request( return limited ? { success: false, response: limited } : { success: true, auth } } -interface V2JsonRouteOptions +interface V2JsonRouteOptions extends JsonRouteDefinition { auth: typeof v2ApiKeyAuth rateLimit: V2RateLimitPolicy @@ -178,7 +168,7 @@ interface V2JsonRouteOptions(options: V2JsonRouteOptions): JsonNextRouteHandler { diff --git a/apps/sim/lib/core/application/index.ts b/apps/sim/lib/core/application/index.ts new file mode 100644 index 00000000000..8678d7962eb --- /dev/null +++ b/apps/sim/lib/core/application/index.ts @@ -0,0 +1,14 @@ +export type { + ApplicationOperation, + OperationUseCase, +} from '@/lib/core/application/operation' +export type { + WorkspaceAuthorizationContext, + WorkspaceAuthorizationOptions, + WorkspaceDelegationPolicy, +} from '@/lib/core/application/workspace-authorization' +export { authorizeWorkspaceOperation } from '@/lib/core/application/workspace-authorization' +export { + defineWorkspaceOperation, + type WorkspaceOperation, +} from '@/lib/core/application/workspace-operation' diff --git a/apps/sim/lib/core/application/operation.ts b/apps/sim/lib/core/application/operation.ts new file mode 100644 index 00000000000..618a0aa3757 --- /dev/null +++ b/apps/sim/lib/core/application/operation.ts @@ -0,0 +1,15 @@ +import type { Principal } from '@sim/auth/principal' +import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' + +export interface ApplicationOperation { + readonly id: Id +} + +export interface OperationUseCase { + readonly operation: O + execute(args: { + principal: Principal + input: I + request?: OrchestrationRequestContext + }): Promise +} diff --git a/apps/sim/lib/core/application/workspace-authorization.ts b/apps/sim/lib/core/application/workspace-authorization.ts new file mode 100644 index 00000000000..38c3f094dce --- /dev/null +++ b/apps/sim/lib/core/application/workspace-authorization.ts @@ -0,0 +1,100 @@ +import type { DelegatedPrincipal, Principal } from '@sim/auth/principal' +import type { db } from '@sim/db' +import { + type PermissionType, + permissionSatisfies, + resolveEffectiveWorkspacePermission, +} from '@sim/platform-authz/workspace' +import type { WorkspaceOperation } from '@/lib/core/application/workspace-operation' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +export interface WorkspaceAuthorizationContext { + workspaceId: string + workspaceOrganizationId: string | null + allowPersonalApiKeys: boolean +} + +export interface WorkspaceDelegationPolicy { + audience: string + isWithinScope(principal: DelegatedPrincipal, context: C): boolean +} + +export interface WorkspaceAuthorizationOptions { + executor?: Pick + forUpdate?: boolean + delegation?: WorkspaceDelegationPolicy +} + +function requirePermission(permission: PermissionType | null, required: PermissionType): void { + if (!permissionSatisfies(permission, required)) { + throw new OrchestrationError('forbidden', 'Insufficient workspace permissions') + } +} + +async function requireCurrentHumanPermission( + userId: string, + context: C, + required: PermissionType, + options?: WorkspaceAuthorizationOptions +): Promise { + const permission = await resolveEffectiveWorkspacePermission( + userId, + context.workspaceId, + context.workspaceOrganizationId, + options?.executor, + { forUpdate: options?.forUpdate } + ) + requirePermission(permission, required) +} + +export async function authorizeWorkspaceOperation( + principal: Principal, + operation: WorkspaceOperation, + context: C, + options?: WorkspaceAuthorizationOptions +): Promise { + switch (principal.kind) { + case 'session': + await requireCurrentHumanPermission(principal.userId, context, operation.minimumRole, options) + return + case 'personal_api_key': + if (!context.allowPersonalApiKeys) { + throw new OrchestrationError( + 'forbidden', + 'Personal API keys are disabled for this workspace' + ) + } + await requireCurrentHumanPermission(principal.userId, context, operation.minimumRole, options) + return + case 'workspace_api_key': + if ( + principal.workspaceId !== context.workspaceId || + operation.workspaceApiKey !== 'allow' || + !permissionSatisfies('write', operation.minimumRole) + ) { + throw new OrchestrationError('forbidden', 'Workspace API key cannot perform this operation') + } + return + case 'delegated': { + const delegation = options?.delegation + if (!delegation) { + throw new Error(`Operation ${operation.id} requires an explicit delegation policy`) + } + if ( + principal.audience !== delegation.audience || + principal.expiresAt.getTime() <= Date.now() || + principal.workspaceId !== context.workspaceId || + !delegation.isWithinScope(principal, context) + ) { + throw new OrchestrationError('forbidden', 'Delegated workspace access is no longer valid') + } + await requireCurrentHumanPermission( + principal.subjectUserId, + context, + operation.minimumRole, + options + ) + return + } + } +} diff --git a/apps/sim/lib/core/application/workspace-operation.ts b/apps/sim/lib/core/application/workspace-operation.ts new file mode 100644 index 00000000000..a534e47fc24 --- /dev/null +++ b/apps/sim/lib/core/application/workspace-operation.ts @@ -0,0 +1,19 @@ +import type { PermissionType } from '@sim/platform-authz/workspace' +import type { ApplicationOperation } from '@/lib/core/application/operation' + +type WorkspaceApiKeyPolicy = R extends 'admin' ? 'deny' : 'allow' | 'deny' + +export interface WorkspaceOperation< + Id extends string = string, + Role extends PermissionType = PermissionType, +> extends ApplicationOperation { + readonly minimumRole: Role + readonly workspaceApiKey: WorkspaceApiKeyPolicy +} + +export function defineWorkspaceOperation< + const Id extends string, + const Role extends PermissionType, +>(operation: WorkspaceOperation): WorkspaceOperation { + return Object.freeze(operation) +} diff --git a/apps/sim/lib/uploads/upload-session/application.test.ts b/apps/sim/lib/uploads/upload-session/application.test.ts new file mode 100644 index 00000000000..bdcde448e22 --- /dev/null +++ b/apps/sim/lib/uploads/upload-session/application.test.ts @@ -0,0 +1,117 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + assertAuthBinding: vi.fn(), + completeSession: vi.fn(), + finalizePurpose: vi.fn(), + getOwnedSession: vi.fn(), + reauthorizeWorkspacePurpose: vi.fn(), +})) + +vi.mock('@/lib/uploads/upload-session/service', () => ({ + abortUploadSession: vi.fn(), + assertUploadSessionAuthBinding: mocks.assertAuthBinding, + completeUploadSession: mocks.completeSession, + createUploadPartUrls: vi.fn(), + createUploadSession: vi.fn(), + getOwnedUploadSession: mocks.getOwnedSession, + getPrincipalUploadSession: vi.fn(), +})) + +vi.mock('@/app/api/files/uploads/finalizers', () => ({ + finalizeUploadPurpose: mocks.finalizePurpose, + finalizeWorkspaceFileUpload: vi.fn(), + loadCompletedUploadPurpose: vi.fn(), + loadCompletedWorkspaceFileUpload: vi.fn(), +})) + +vi.mock('@/app/api/files/uploads/purposes', () => ({ + createPurposeUploadSession: vi.fn(), + reauthorizeUploadPurpose: vi.fn(), + reauthorizeWorkspaceUploadPurpose: mocks.reauthorizeWorkspacePurpose, + resolveUploadAttributionUserId: vi.fn(), +})) + +import { completeInternalUploadSession } from '@/lib/uploads/upload-session/application' +import type { UploadSessionRecord } from '@/lib/uploads/upload-session/service' + +const principal = { + kind: 'session' as const, + userId: 'user-1', + sessionId: 'session-1', +} +const actor = { id: 'user-1', name: 'Ada', email: 'ada@example.com' } + +describe('upload session application', () => { + beforeEach(() => { + vi.clearAllMocks() + const session = workspaceUploadSession() + mocks.getOwnedSession.mockResolvedValue(session) + mocks.finalizePurpose.mockResolvedValue({ + value: { id: 'file-1' }, + completedFileId: 'file-1', + }) + mocks.completeSession.mockImplementation(async ({ session: claimed, finalize }) => { + const finalized = await finalize(claimed) + return { + session: { ...claimed, status: 'completed', completedFileId: finalized.completedFileId }, + value: finalized.value, + alreadyCompleted: false, + } + }) + }) + + it('preserves the authenticated actor metadata through internal finalization', async () => { + const request = new NextRequest('http://localhost/api/files/uploads/upload-1/complete', { + method: 'POST', + }) + + await completeInternalUploadSession( + principal, + { uploadId: 'upload-1', uploadToken: 'upload-token', actor }, + request + ) + + expect(mocks.finalizePurpose).toHaveBeenCalledWith( + expect.objectContaining({ actor, principal, request }) + ) + }) +}) + +function workspaceUploadSession(): UploadSessionRecord { + const now = new Date('2026-08-08T00:00:00.000Z') + return { + id: 'upload-1', + workspaceId: 'workspace-1', + userId: principal.userId, + knowledgeBaseId: null, + workflowId: null, + executionId: null, + purpose: 'workspace_file', + method: 'put', + storageContext: 'workspace', + storageKey: 'workspace/workspace-1/file.txt', + finalKey: 'workspace/workspace-1/file.txt', + storageProvider: 's3', + providerUploadId: null, + providerObjectVersion: 'version-1', + fileName: 'file.txt', + contentType: 'text/plain', + fileSize: 4, + partSize: null, + partCount: null, + status: 'finalizing', + metadata: {}, + uploadToken: 'upload-token', + createdAt: now, + expiresAt: new Date(now.getTime() + 24 * 60 * 60 * 1000), + completedFileId: null, + error: null, + completedAt: null, + updatedAt: now, + } +} diff --git a/apps/sim/lib/uploads/upload-session/application.ts b/apps/sim/lib/uploads/upload-session/application.ts index 4f403a52cfd..e423b79dd85 100644 --- a/apps/sim/lib/uploads/upload-session/application.ts +++ b/apps/sim/lib/uploads/upload-session/application.ts @@ -151,7 +151,7 @@ export async function completeInternalUploadSession( } } await authorize(session) - const actor: UploadActor = { id: principalUserId(principal) } + const actor: UploadActor = input.actor ?? { id: principalUserId(principal) } return completeUploadSession({ session, loadCompleted: async (claimed) => { diff --git a/apps/sim/lib/uploads/upload-session/service.test.ts b/apps/sim/lib/uploads/upload-session/service.test.ts index ee0691c6eb8..ea5901633c2 100644 --- a/apps/sim/lib/uploads/upload-session/service.test.ts +++ b/apps/sim/lib/uploads/upload-session/service.test.ts @@ -57,6 +57,7 @@ vi.mock('@/lib/uploads/upload-session/provider', () => ({ import { abortUploadSession, + assertUploadSessionAuthBinding, cleanupExpiredUploadSessions, completeUploadSession, createUploadSession, @@ -138,6 +139,59 @@ describe('upload sessions', () => { ).rejects.toMatchObject({ code: 'not_found' }) }) + it('preserves legacy unbound sessions under their prior ownership rules', () => { + const legacy = sessionRecord({ metadata: {} }) + + expect(() => + assertUploadSessionAuthBinding(legacy, { + kind: 'session', + userId: legacy.userId, + sessionId: 'current-session', + }) + ).not.toThrow() + expect(() => + assertUploadSessionAuthBinding(legacy, { + kind: 'personal_api_key', + userId: legacy.userId, + keyId: 'current-key', + }) + ).not.toThrow() + expect(() => + assertUploadSessionAuthBinding(legacy, { + kind: 'workspace_api_key', + workspaceId: WORKSPACE_ID, + keyId: 'current-workspace-key', + }) + ).not.toThrow() + + expect(() => + assertUploadSessionAuthBinding(legacy, { + kind: 'session', + userId: 'different-user', + sessionId: 'current-session', + }) + ).toThrow('Upload session not found') + expect(() => + assertUploadSessionAuthBinding(legacy, { + kind: 'workspace_api_key', + workspaceId: 'different-workspace', + keyId: 'current-workspace-key', + }) + ).toThrow('Upload session not found') + }) + + it('never treats a malformed credential binding as a legacy session', () => { + const malformed = sessionRecord({ metadata: { authBinding: { version: 1 } } }) + + expect(() => + assertUploadSessionAuthBinding(malformed, { + kind: 'session', + userId: malformed.userId, + sessionId: 'current-session', + }) + ).toThrow('Upload session not found') + }) + it('initiates multipart storage directly at the final key', async () => { const fileSize = UPLOAD_SESSION_PUT_MAX_BYTES + 1 const row = uploadRow({ diff --git a/apps/sim/lib/uploads/upload-session/service.ts b/apps/sim/lib/uploads/upload-session/service.ts index 10433588331..fa6f44e20bf 100644 --- a/apps/sim/lib/uploads/upload-session/service.ts +++ b/apps/sim/lib/uploads/upload-session/service.ts @@ -382,6 +382,10 @@ export function assertUploadSessionAuthBinding( ): void { if (session.purpose !== 'workspace_file') return const candidate = session.metadata.authBinding + if (candidate === undefined) { + assertLegacyUploadSessionOwner(session, principal) + return + } if (!isUploadSessionAuthBinding(candidate) || candidate.workspaceId !== session.workspaceId) { throw uploadNotFound() } @@ -402,6 +406,22 @@ export function assertUploadSessionAuthBinding( if (!matches) throw uploadNotFound() } +/** + * Preserves control access for the bounded set of sessions created before + * immutable credential bindings shipped. New workspace-file sessions always + * persist `authBinding`, and malformed bindings never enter this compatibility + * path. The upload token and current workspace authorization are still checked + * by the calling control-plane use case. + */ +function assertLegacyUploadSessionOwner(session: UploadSessionRecord, principal: Principal): void { + const matches = + principal.kind === 'workspace_api_key' + ? principal.workspaceId === session.workspaceId + : (principal.kind === 'session' || principal.kind === 'personal_api_key') && + principal.userId === session.userId + if (!matches) throw uploadNotFound() +} + export async function verifyUploadSessionToken(uploadToken: string): Promise { const tokenHash = sha256Hex(uploadToken) const [row] = await db diff --git a/apps/sim/lib/workspace-files/api/index.ts b/apps/sim/lib/workspace-files/api/index.ts new file mode 100644 index 00000000000..c94e1017fc8 --- /dev/null +++ b/apps/sim/lib/workspace-files/api/index.ts @@ -0,0 +1,6 @@ +export { + internalFileErrorPolicy, + internalPlainFileErrorPolicy, + internalSessionOrServiceAuth, + v2FileErrorPolicies, +} from '@/lib/workspace-files/api/route-policies' diff --git a/apps/sim/lib/api/server/routes/internal-json-route.test.ts b/apps/sim/lib/workspace-files/api/route-policies.test.ts similarity index 94% rename from apps/sim/lib/api/server/routes/internal-json-route.test.ts rename to apps/sim/lib/workspace-files/api/route-policies.test.ts index 9dd411d60c0..3fd91c612bb 100644 --- a/apps/sim/lib/api/server/routes/internal-json-route.test.ts +++ b/apps/sim/lib/workspace-files/api/route-policies.test.ts @@ -12,10 +12,8 @@ const { mockGetSession, mockVerifyInternalToken } = vi.hoisted(() => ({ vi.mock('@/lib/auth', () => ({ getSession: mockGetSession })) vi.mock('@/lib/auth/internal', () => ({ verifyInternalToken: mockVerifyInternalToken })) -import { - InternalUnauthenticatedError, - internalSessionOrServiceAuth, -} from '@/lib/api/server/routes/internal-json-route' +import { InternalUnauthenticatedError } from '@/lib/api/server/routes' +import { internalSessionOrServiceAuth } from '@/lib/workspace-files/api' describe('internal file route authentication', () => { beforeEach(() => { diff --git a/apps/sim/lib/workspace-files/api/route-policies.ts b/apps/sim/lib/workspace-files/api/route-policies.ts new file mode 100644 index 00000000000..8308db3a279 --- /dev/null +++ b/apps/sim/lib/workspace-files/api/route-policies.ts @@ -0,0 +1,40 @@ +import { + createInternalSessionOrServiceAuth, + internalOrchestrationErrorPolicy, + internalPlainOrchestrationErrorPolicy, + type V2ErrorPolicy, + v2OrchestrationErrorPolicy, +} from '@/lib/api/server/routes' +import { createWorkspaceFileDelegatedPrincipal } from '@/lib/workspace-files/application/delegated-principal' +import { v2CaughtOrchestrationError, v2Error } from '@/app/api/v2/lib/response' + +export const internalSessionOrServiceAuth = createInternalSessionOrServiceAuth( + ({ subjectUserId, params }) => { + const workspaceId = params.id + if (typeof workspaceId !== 'string' || !workspaceId) { + throw new Error('Internal file delegation requires a workspace route parameter') + } + return createWorkspaceFileDelegatedPrincipal({ + serviceId: 'executor', + subjectUserId, + workspaceId, + delegationId: `internal-file:${subjectUserId}`, + fileId: typeof params.fileId === 'string' ? params.fileId : undefined, + }) + } +) + +export const internalFileErrorPolicy = internalOrchestrationErrorPolicy +export const internalPlainFileErrorPolicy = internalPlainOrchestrationErrorPolicy + +export const v2FileErrorPolicies = { + default: v2OrchestrationErrorPolicy, + concealResourceAuthorization: { + render(error) { + const response = v2CaughtOrchestrationError(error) + if (!response) return null + if (response.status === 403) return v2Error('NOT_FOUND', 'File not found') + return response + }, + } satisfies V2ErrorPolicy, +} as const diff --git a/apps/sim/lib/workspace-files/application/authorization.test.ts b/apps/sim/lib/workspace-files/application/authorization.test.ts index 72f040dfde5..5d39bc85d0e 100644 --- a/apps/sim/lib/workspace-files/application/authorization.test.ts +++ b/apps/sim/lib/workspace-files/application/authorization.test.ts @@ -17,7 +17,7 @@ vi.mock('@sim/platform-authz/workspace', () => ({ })) import type { OrchestrationError } from '@/lib/core/orchestration/types' -import { authorizeWorkspaceOperation } from '@/lib/workspace-files/application/authorization' +import { authorizeWorkspaceFileAccess } from '@/lib/workspace-files/application/authorization' import { fileOperations } from '@/lib/workspace-files/application/operations' const authorizationContext = { @@ -29,7 +29,7 @@ const authorizationContext = { async function expectForbidden(principal: Principal) { await expect( - authorizeWorkspaceOperation(principal, fileOperations.rename, authorizationContext) + authorizeWorkspaceFileAccess(principal, fileOperations.rename, authorizationContext) ).rejects.toMatchObject>({ code: 'forbidden' }) } @@ -40,7 +40,7 @@ describe('file operation authorization', () => { }) it('uses the current workspace permission for sessions', async () => { - await authorizeWorkspaceOperation( + await authorizeWorkspaceFileAccess( { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, fileOperations.rename, authorizationContext @@ -61,7 +61,7 @@ describe('file operation authorization', () => { }) it('lets a workspace key use its fixed write ceiling only in its own workspace', async () => { - await authorizeWorkspaceOperation( + await authorizeWorkspaceFileAccess( { kind: 'workspace_api_key', workspaceId: 'workspace-1', keyId: 'key-1' }, fileOperations.rename, authorizationContext @@ -77,7 +77,7 @@ describe('file operation authorization', () => { it('fails a personal key immediately when the workspace disables it', async () => { await expect( - authorizeWorkspaceOperation( + authorizeWorkspaceFileAccess( { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, fileOperations.rename, { ...authorizationContext, allowPersonalApiKeys: false } @@ -87,7 +87,7 @@ describe('file operation authorization', () => { }) it('reauthorizes a valid file-scoped Copilot delegation as its human subject', async () => { - await authorizeWorkspaceOperation( + await authorizeWorkspaceFileAccess( { kind: 'delegated', serviceId: 'copilot', diff --git a/apps/sim/lib/workspace-files/application/authorization.ts b/apps/sim/lib/workspace-files/application/authorization.ts index 10ef39abd63..e561cde3337 100644 --- a/apps/sim/lib/workspace-files/application/authorization.ts +++ b/apps/sim/lib/workspace-files/application/authorization.ts @@ -1,93 +1,35 @@ import type { Principal } from '@sim/auth/principal' -import type { db } from '@sim/db' +import type { WorkspaceOperation } from '@/lib/core/application' import { - type PermissionType, - permissionSatisfies, - resolveEffectiveWorkspacePermission, -} from '@sim/platform-authz/workspace' -import { OrchestrationError } from '@/lib/core/orchestration/types' -import type { WorkspaceOperation } from '@/lib/workspace-files/application/operations' + authorizeWorkspaceOperation, + type WorkspaceAuthorizationContext, + type WorkspaceAuthorizationOptions, +} from '@/lib/core/application' export const WORKSPACE_FILES_DELEGATION_AUDIENCE = 'sim:workspace-files' -export interface WorkspaceAuthorizationContext { - workspaceId: string - workspaceOrganizationId: string | null - allowPersonalApiKeys: boolean +export interface WorkspaceFileAuthorizationContext extends WorkspaceAuthorizationContext { fileId?: string } -function requirePermission(permission: PermissionType | null, required: PermissionType): void { - if (!permissionSatisfies(permission, required)) { - throw new OrchestrationError('forbidden', 'Insufficient workspace permissions') - } -} - -async function requireCurrentHumanPermission( - userId: string, - context: WorkspaceAuthorizationContext, - required: PermissionType, - options?: WorkspaceAuthorizationOptions -): Promise { - const permission = await resolveEffectiveWorkspacePermission( - userId, - context.workspaceId, - context.workspaceOrganizationId, - options?.executor, - { forUpdate: options?.forUpdate } - ) - requirePermission(permission, required) -} - -export interface WorkspaceAuthorizationOptions { - executor?: Pick - forUpdate?: boolean -} +export type WorkspaceFileAuthorizationOptions = Omit< + WorkspaceAuthorizationOptions, + 'delegation' +> -export async function authorizeWorkspaceOperation( +export async function authorizeWorkspaceFileAccess( principal: Principal, operation: WorkspaceOperation, - context: WorkspaceAuthorizationContext, - options?: WorkspaceAuthorizationOptions + context: WorkspaceFileAuthorizationContext, + options?: WorkspaceFileAuthorizationOptions ): Promise { - switch (principal.kind) { - case 'session': - await requireCurrentHumanPermission(principal.userId, context, operation.minimumRole, options) - return - case 'personal_api_key': - if (!context.allowPersonalApiKeys) { - throw new OrchestrationError( - 'forbidden', - 'Personal API keys are disabled for this workspace' - ) - } - await requireCurrentHumanPermission(principal.userId, context, operation.minimumRole, options) - return - case 'workspace_api_key': - if ( - principal.workspaceId !== context.workspaceId || - operation.workspaceApiKey !== 'allow' || - !permissionSatisfies('write', operation.minimumRole) - ) { - throw new OrchestrationError('forbidden', 'Workspace API key cannot perform this operation') - } - return - case 'delegated': - if ( - principal.audience !== WORKSPACE_FILES_DELEGATION_AUDIENCE || - principal.expiresAt.getTime() <= Date.now() || - principal.workspaceId !== context.workspaceId || - (principal.resourceScope?.fileId !== undefined && - principal.resourceScope.fileId !== context.fileId) - ) { - throw new OrchestrationError('forbidden', 'Delegated file access is no longer valid') - } - await requireCurrentHumanPermission( - principal.subjectUserId, - context, - operation.minimumRole, - options - ) - return - } + await authorizeWorkspaceOperation(principal, operation, context, { + ...options, + delegation: { + audience: WORKSPACE_FILES_DELEGATION_AUDIENCE, + isWithinScope: (delegated, canonicalContext) => + delegated.resourceScope?.fileId === undefined || + delegated.resourceScope.fileId === canonicalContext.fileId, + }, + }) } diff --git a/apps/sim/lib/workspace-files/application/create-workspace-file.ts b/apps/sim/lib/workspace-files/application/create-workspace-file.ts index 8e58db1f770..ea72e46ff12 100644 --- a/apps/sim/lib/workspace-files/application/create-workspace-file.ts +++ b/apps/sim/lib/workspace-files/application/create-workspace-file.ts @@ -16,7 +16,7 @@ import { } from '@/lib/uploads/contexts/workspace' import type { WorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' import { EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' -import { authorizeWorkspaceOperation } from '@/lib/workspace-files/application/authorization' +import { authorizeWorkspaceFileAccess } from '@/lib/workspace-files/application/authorization' import { fileOperations } from '@/lib/workspace-files/application/operations' import { MAX_WORKSPACE_FILE_CONTENT_BYTES } from '@/lib/workspace-files/orchestration' @@ -58,7 +58,7 @@ interface CreateWorkspaceFileBufferArguments { async function requireCreateWorkspaceFileAccess(principal: Principal, workspaceId: string) { const workspace = await loadActiveWorkspaceContext(workspaceId) if (!workspace) throw new OrchestrationError('not_found', 'Workspace not found') - await authorizeWorkspaceOperation(principal, fileOperations.create, workspace) + await authorizeWorkspaceFileAccess(principal, fileOperations.create, workspace) return workspace } diff --git a/apps/sim/lib/workspace-files/application/list-workspace-files.ts b/apps/sim/lib/workspace-files/application/list-workspace-files.ts index 966e43338b9..ddddfde405c 100644 --- a/apps/sim/lib/workspace-files/application/list-workspace-files.ts +++ b/apps/sim/lib/workspace-files/application/list-workspace-files.ts @@ -9,7 +9,7 @@ import { loadActiveWorkspaceContext, queryWorkspaceFiles, } from '@/lib/uploads/contexts/workspace' -import { authorizeWorkspaceOperation } from '@/lib/workspace-files/application/authorization' +import { authorizeWorkspaceFileAccess } from '@/lib/workspace-files/application/authorization' import { fileOperations } from '@/lib/workspace-files/application/operations' export interface ListAllWorkspaceFilesInput { @@ -31,7 +31,7 @@ export interface QueryWorkspaceFilePageInput { async function requireListWorkspaceFileAccess(principal: Principal, workspaceId: string) { const workspace = await loadActiveWorkspaceContext(workspaceId) if (!workspace) throw new OrchestrationError('not_found', 'Workspace not found') - await authorizeWorkspaceOperation(principal, fileOperations.list, workspace) + await authorizeWorkspaceFileAccess(principal, fileOperations.list, workspace) return workspace } diff --git a/apps/sim/lib/workspace-files/application/load-authorized-workspace-file.test.ts b/apps/sim/lib/workspace-files/application/load-authorized-workspace-file.test.ts index 6d26662de9b..e7673c8b741 100644 --- a/apps/sim/lib/workspace-files/application/load-authorized-workspace-file.test.ts +++ b/apps/sim/lib/workspace-files/application/load-authorized-workspace-file.test.ts @@ -13,7 +13,7 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ })) vi.mock('@/lib/workspace-files/application/authorization', () => ({ - authorizeWorkspaceOperation: mocks.authorize, + authorizeWorkspaceFileAccess: mocks.authorize, })) import { loadAuthorizedWorkspaceFile } from '@/lib/workspace-files/application/load-authorized-workspace-file' diff --git a/apps/sim/lib/workspace-files/application/load-authorized-workspace-file.ts b/apps/sim/lib/workspace-files/application/load-authorized-workspace-file.ts index f7b49020646..aa7b77d1108 100644 --- a/apps/sim/lib/workspace-files/application/load-authorized-workspace-file.ts +++ b/apps/sim/lib/workspace-files/application/load-authorized-workspace-file.ts @@ -1,11 +1,11 @@ import type { Principal } from '@sim/auth/principal' +import type { WorkspaceOperation } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { type ActiveWorkspaceFileContext, loadActiveWorkspaceFileContext, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { authorizeWorkspaceOperation } from '@/lib/workspace-files/application/authorization' -import type { WorkspaceOperation } from '@/lib/workspace-files/application/operations' +import { authorizeWorkspaceFileAccess } from '@/lib/workspace-files/application/authorization' interface LoadAuthorizedWorkspaceFileArgs { principal: Principal @@ -30,7 +30,7 @@ export async function loadAuthorizedWorkspaceFile({ throw new OrchestrationError('not_found', 'File not found') } - await authorizeWorkspaceOperation(principal, operation, { + await authorizeWorkspaceFileAccess(principal, operation, { workspaceId: canonical.workspaceId, workspaceOrganizationId: canonical.workspaceOrganizationId, allowPersonalApiKeys: canonical.allowPersonalApiKeys, diff --git a/apps/sim/lib/workspace-files/application/operations.ts b/apps/sim/lib/workspace-files/application/operations.ts index 7d86191397d..16fedc8f549 100644 --- a/apps/sim/lib/workspace-files/application/operations.ts +++ b/apps/sim/lib/workspace-files/application/operations.ts @@ -1,21 +1,4 @@ -import type { PermissionType } from '@sim/platform-authz/workspace' - -type WorkspaceApiKeyPolicy = R extends 'admin' ? 'deny' : 'allow' | 'deny' - -export interface WorkspaceOperation< - Id extends string = string, - Role extends PermissionType = PermissionType, -> { - readonly id: Id - readonly minimumRole: Role - readonly workspaceApiKey: WorkspaceApiKeyPolicy -} - -function defineWorkspaceOperation( - operation: WorkspaceOperation -): WorkspaceOperation { - return Object.freeze(operation) -} +import { defineWorkspaceOperation } from '@/lib/core/application' export const fileOperations = { list: defineWorkspaceOperation({ diff --git a/apps/sim/lib/workspace-files/application/read-workspace-file-content.ts b/apps/sim/lib/workspace-files/application/read-workspace-file-content.ts index 958b644043a..1a02ff69c25 100644 --- a/apps/sim/lib/workspace-files/application/read-workspace-file-content.ts +++ b/apps/sim/lib/workspace-files/application/read-workspace-file-content.ts @@ -1,4 +1,5 @@ import type { Principal } from '@sim/auth/principal' +import type { WorkspaceOperation } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { fetchWorkspaceFileBuffer, @@ -6,10 +7,7 @@ import { type WorkspaceFileRecord, } from '@/lib/uploads/contexts/workspace' import { loadAuthorizedWorkspaceFile } from '@/lib/workspace-files/application/load-authorized-workspace-file' -import { - fileOperations, - type WorkspaceOperation, -} from '@/lib/workspace-files/application/operations' +import { fileOperations } from '@/lib/workspace-files/application/operations' export interface ReadWorkspaceFileContentInput { fileId: string diff --git a/apps/sim/lib/workspace-files/application/read-workspace-file-record.ts b/apps/sim/lib/workspace-files/application/read-workspace-file-record.ts index be5c901b5fb..03ebd03a6a1 100644 --- a/apps/sim/lib/workspace-files/application/read-workspace-file-record.ts +++ b/apps/sim/lib/workspace-files/application/read-workspace-file-record.ts @@ -1,4 +1,5 @@ import type { Principal } from '@sim/auth/principal' +import type { WorkspaceOperation } from '@/lib/core/application' import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' import { OrchestrationError } from '@/lib/core/orchestration/types' import { @@ -6,10 +7,7 @@ import { type WorkspaceFileRecord, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { loadAuthorizedWorkspaceFile } from '@/lib/workspace-files/application/load-authorized-workspace-file' -import { - fileOperations, - type WorkspaceOperation, -} from '@/lib/workspace-files/application/operations' +import { fileOperations } from '@/lib/workspace-files/application/operations' export interface ReadWorkspaceFileRecordInput { fileId: string diff --git a/apps/sim/lib/workspace-files/application/rename-workspace-file.test.ts b/apps/sim/lib/workspace-files/application/rename-workspace-file.test.ts index 0960b3545db..5176dbbe87d 100644 --- a/apps/sim/lib/workspace-files/application/rename-workspace-file.test.ts +++ b/apps/sim/lib/workspace-files/application/rename-workspace-file.test.ts @@ -20,7 +20,7 @@ vi.mock('@sim/audit', () => ({ vi.mock('@/lib/realtime/notify', () => ({ notifyWorkspaceFilesChanged: mocks.notify })) vi.mock('@/lib/workspace-files/application/authorization', () => ({ - authorizeWorkspaceOperation: mocks.authorize, + authorizeWorkspaceFileAccess: mocks.authorize, })) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ 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 eb77bad0b46..59397f47646 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 @@ -1,16 +1,14 @@ import type { Principal } from '@sim/auth/principal' +import type { WorkspaceOperation } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { loadActiveWorkspaceContext, resolveWorkspaceFileReference as resolveStoredWorkspaceFileReference, type WorkspaceFileRecord, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { authorizeWorkspaceOperation } from '@/lib/workspace-files/application/authorization' +import { authorizeWorkspaceFileAccess } from '@/lib/workspace-files/application/authorization' import { loadAuthorizedWorkspaceFile } from '@/lib/workspace-files/application/load-authorized-workspace-file' -import { - fileOperations, - type WorkspaceOperation, -} from '@/lib/workspace-files/application/operations' +import { fileOperations } from '@/lib/workspace-files/application/operations' import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' export interface ResolveWorkspaceFileReferenceInput { @@ -29,7 +27,7 @@ export async function resolveWorkspaceFileReference({ }: ResolveWorkspaceFileReferenceInput): Promise { const workspace = await loadActiveWorkspaceContext(workspaceId) if (!workspace) throw new OrchestrationError('not_found', 'Workspace not found') - await authorizeWorkspaceOperation(principal, operation, workspace) + await authorizeWorkspaceFileAccess(principal, operation, workspace) const file = await resolveStoredWorkspaceFileReference(workspaceId, reference) if (!file) throw new OrchestrationError('not_found', 'File not found') diff --git a/apps/sim/lib/workspace-files/application/restore-workspace-file.test.ts b/apps/sim/lib/workspace-files/application/restore-workspace-file.test.ts index 0014313a219..c3994eb1a93 100644 --- a/apps/sim/lib/workspace-files/application/restore-workspace-file.test.ts +++ b/apps/sim/lib/workspace-files/application/restore-workspace-file.test.ts @@ -22,7 +22,7 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ restoreWorkspaceFile: mocks.restoreStored, })) vi.mock('@/lib/workspace-files/application/authorization', () => ({ - authorizeWorkspaceOperation: mocks.authorize, + authorizeWorkspaceFileAccess: mocks.authorize, })) import { restoreWorkspaceFileOperation } from '@/lib/workspace-files/application/restore-workspace-file' diff --git a/apps/sim/lib/workspace-files/application/restore-workspace-file.ts b/apps/sim/lib/workspace-files/application/restore-workspace-file.ts index af6acc75443..cb2bd06288a 100644 --- a/apps/sim/lib/workspace-files/application/restore-workspace-file.ts +++ b/apps/sim/lib/workspace-files/application/restore-workspace-file.ts @@ -8,7 +8,7 @@ import { loadWorkspaceFileLifecycleContext, restoreWorkspaceFile, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { authorizeWorkspaceOperation } from '@/lib/workspace-files/application/authorization' +import { authorizeWorkspaceFileAccess } from '@/lib/workspace-files/application/authorization' import { fileOperations } from '@/lib/workspace-files/application/operations' const logger = createLogger('RestoreWorkspaceFile') @@ -41,7 +41,7 @@ async function executeRestoreWorkspaceFile({ throw new OrchestrationError('not_found', 'File not found') } - await authorizeWorkspaceOperation(principal, fileOperations.restore, { + await authorizeWorkspaceFileAccess(principal, fileOperations.restore, { workspaceId: canonical.workspaceId, workspaceOrganizationId: canonical.workspaceOrganizationId, allowPersonalApiKeys: canonical.allowPersonalApiKeys, diff --git a/apps/sim/lib/workspace-files/application/workspace-operation-context.ts b/apps/sim/lib/workspace-files/application/workspace-operation-context.ts index a08b4d3d4d2..6796c9c70ee 100644 --- a/apps/sim/lib/workspace-files/application/workspace-operation-context.ts +++ b/apps/sim/lib/workspace-files/application/workspace-operation-context.ts @@ -1,11 +1,11 @@ import type { Principal } from '@sim/auth/principal' +import type { WorkspaceOperation } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { loadWorkspaceFileOperationContext, type WorkspaceFileOperationContext, } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' -import { authorizeWorkspaceOperation } from '@/lib/workspace-files/application/authorization' -import type { WorkspaceOperation } from '@/lib/workspace-files/application/operations' +import { authorizeWorkspaceFileAccess } from '@/lib/workspace-files/application/authorization' export interface AuthorizedWorkspaceOperationContext { context: WorkspaceFileOperationContext @@ -20,7 +20,7 @@ export async function authorizeWorkspaceFileOperation( const context = await loadWorkspaceFileOperationContext(workspaceId) if (!context) throw new OrchestrationError('not_found', 'Workspace not found') - await authorizeWorkspaceOperation(principal, operation, { + await authorizeWorkspaceFileAccess(principal, operation, { workspaceId: context.workspaceId, workspaceOrganizationId: context.workspaceOrganizationId, allowPersonalApiKeys: context.allowPersonalApiKeys, From 58d8d1930e998d349b51f18ada2b2dac04e4a782 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Fri, 7 Aug 2026 23:21:03 -0700 Subject: [PATCH 5/9] improvement(api): simplify internal route declarations --- .../skills/migrate-file-operation/SKILL.md | 6 +- .claude/commands/migrate-file-operation.md | 6 +- .cursor/commands/migrate-file-operation.md | 6 +- .../files/[fileId]/compiled-check/route.ts | 25 +-- .../[id]/files/[fileId]/content/route.ts | 6 +- .../[id]/files/[fileId]/csv-preview/route.ts | 10 +- .../[id]/files/[fileId]/dimensions/route.ts | 5 +- .../[id]/files/[fileId]/download/route.ts | 46 +----- .../[id]/files/[fileId]/restore/route.ts | 7 +- .../workspaces/[id]/files/[fileId]/route.ts | 34 ++-- .../[id]/files/[fileId]/share/route.ts | 8 +- .../[id]/files/[fileId]/style/route.ts | 22 +-- .../[id]/files/bulk-archive/route.ts | 21 +-- .../workspaces/[id]/files/download/route.ts | 29 +--- .../files/folders/[folderId]/restore/route.ts | 17 +- .../[id]/files/folders/[folderId]/route.ts | 30 +--- .../workspaces/[id]/files/folders/route.ts | 21 +-- .../api/workspaces/[id]/files/inline/route.ts | 37 +---- .../api/workspaces/[id]/files/move/route.ts | 35 +--- .../app/api/workspaces/[id]/files/route.ts | 27 ++-- apps/sim/lib/api/server/routes/index.ts | 3 + .../server/routes/internal-binary-route.ts | 19 ++- .../server/routes/internal-json-route.test.ts | 85 ++++++++++ .../api/server/routes/internal-json-route.ts | 116 ++++++++++---- apps/sim/lib/api/server/routes/types.ts | 6 + apps/sim/lib/workspace-files/api/index.ts | 5 +- .../workspace-files/api/internal-analytics.ts | 151 ++++++++++++++++++ .../api/internal-error-policies.test.ts | 35 ++++ .../api/internal-error-policies.ts | 87 ++++++++++ .../api/internal-presenters.ts | 29 ++++ .../lib/workspace-files/api/route-policies.ts | 5 - 31 files changed, 597 insertions(+), 342 deletions(-) create mode 100644 apps/sim/lib/api/server/routes/internal-json-route.test.ts create mode 100644 apps/sim/lib/workspace-files/api/internal-analytics.ts create mode 100644 apps/sim/lib/workspace-files/api/internal-error-policies.test.ts create mode 100644 apps/sim/lib/workspace-files/api/internal-error-policies.ts create mode 100644 apps/sim/lib/workspace-files/api/internal-presenters.ts diff --git a/.agents/skills/migrate-file-operation/SKILL.md b/.agents/skills/migrate-file-operation/SKILL.md index a39f85f88f5..57cc7df47a2 100644 --- a/.agents/skills/migrate-file-operation/SKILL.md +++ b/.agents/skills/migrate-file-operation/SKILL.md @@ -52,7 +52,7 @@ Keep responsibilities in these layers: 1. Route/tool adapter: authenticate, construct a `Principal`, select the operation and rate policy, parse the surface contract, call the use case, and render the surface response. 2. Application use case: load the canonical resource, compare any asserted workspace, authorize the operation, run the business operation, record semantic audit, and trigger shared domain notifications. 3. Manager/repository: execute storage and database reads/writes. Accept canonical IDs and workspace scope, not credentials or principals. -4. Presenter: return the contract success body only. Never return `NextResponse`, rate metadata, or errors. +4. Presenter: return the contract success body only. Omit it when the use-case result already matches the contract. Never return `NextResponse`, rate metadata, or errors. Use this order for ordinary JSON routes: @@ -147,9 +147,9 @@ Use `defineInternalJsonRoute` and explicitly declare: - `mapInput` - `useCase` - optional `onSuccess` only to preserve surface-specific behavior -- `present` +- `present` when the surface wire body differs from the use-case result -Keep the existing internal success and error envelope. The builder owns `withRouteHandler`, authentication-before-parse ordering, response validation, and `NextResponse` creation. +Keep the existing internal success and error envelope. Error policies project typed status/body descriptors rather than constructing responses. The builder owns `withRouteHandler`, authentication-before-parse ordering, response validation, and `NextResponse` creation. ### V2 JSON diff --git a/.claude/commands/migrate-file-operation.md b/.claude/commands/migrate-file-operation.md index 6e6cdc48c08..8e16cf6286d 100644 --- a/.claude/commands/migrate-file-operation.md +++ b/.claude/commands/migrate-file-operation.md @@ -51,7 +51,7 @@ Keep responsibilities in these layers: 1. Route/tool adapter: authenticate, construct a `Principal`, select the operation and rate policy, parse the surface contract, call the use case, and render the surface response. 2. Application use case: load the canonical resource, compare any asserted workspace, authorize the operation, run the business operation, record semantic audit, and trigger shared domain notifications. 3. Manager/repository: execute storage and database reads/writes. Accept canonical IDs and workspace scope, not credentials or principals. -4. Presenter: return the contract success body only. Never return `NextResponse`, rate metadata, or errors. +4. Presenter: return the contract success body only. Omit it when the use-case result already matches the contract. Never return `NextResponse`, rate metadata, or errors. Use this order for ordinary JSON routes: @@ -146,9 +146,9 @@ Use `defineInternalJsonRoute` and explicitly declare: - `mapInput` - `useCase` - optional `onSuccess` only to preserve surface-specific behavior -- `present` +- `present` when the surface wire body differs from the use-case result -Keep the existing internal success and error envelope. The builder owns `withRouteHandler`, authentication-before-parse ordering, response validation, and `NextResponse` creation. +Keep the existing internal success and error envelope. Error policies project typed status/body descriptors rather than constructing responses. The builder owns `withRouteHandler`, authentication-before-parse ordering, response validation, and `NextResponse` creation. ### V2 JSON diff --git a/.cursor/commands/migrate-file-operation.md b/.cursor/commands/migrate-file-operation.md index 61d033c9b64..7f2f51fa5d6 100644 --- a/.cursor/commands/migrate-file-operation.md +++ b/.cursor/commands/migrate-file-operation.md @@ -47,7 +47,7 @@ Keep responsibilities in these layers: 1. Route/tool adapter: authenticate, construct a `Principal`, select the operation and rate policy, parse the surface contract, call the use case, and render the surface response. 2. Application use case: load the canonical resource, compare any asserted workspace, authorize the operation, run the business operation, record semantic audit, and trigger shared domain notifications. 3. Manager/repository: execute storage and database reads/writes. Accept canonical IDs and workspace scope, not credentials or principals. -4. Presenter: return the contract success body only. Never return `NextResponse`, rate metadata, or errors. +4. Presenter: return the contract success body only. Omit it when the use-case result already matches the contract. Never return `NextResponse`, rate metadata, or errors. Use this order for ordinary JSON routes: @@ -142,9 +142,9 @@ Use `defineInternalJsonRoute` and explicitly declare: - `mapInput` - `useCase` - optional `onSuccess` only to preserve surface-specific behavior -- `present` +- `present` when the surface wire body differs from the use-case result -Keep the existing internal success and error envelope. The builder owns `withRouteHandler`, authentication-before-parse ordering, response validation, and `NextResponse` creation. +Keep the existing internal success and error envelope. Error policies project typed status/body descriptors rather than constructing responses. The builder owns `withRouteHandler`, authentication-before-parse ordering, response validation, and `NextResponse` creation. ### V2 JSON diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts index fd089c9c480..79860777852 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/compiled-check/route.ts @@ -1,33 +1,15 @@ -import { NextResponse } from 'next/server' import { workspaceFileCompiledCheckContract } from '@/lib/api/contracts/workspace-files' import { defineInternalJsonRoute, internalRateLimits, internalSessionAuth, } from '@/lib/api/server/routes' -import { internalPlainFileErrorPolicy } from '@/lib/workspace-files/api' -import { - CompiledCheckTooLargeError, - CompiledCheckUnsupportedError, - compiledCheckWorkspaceFile, -} from '@/lib/workspace-files/application/compiled-check-workspace-file' +import { internalFileErrorPolicies } from '@/lib/workspace-files/api' +import { compiledCheckWorkspaceFile } from '@/lib/workspace-files/application/compiled-check-workspace-file' export const dynamic = 'force-dynamic' export const runtime = 'nodejs' -const compiledCheckErrorPolicy = { - ...internalPlainFileErrorPolicy, - render(error: unknown) { - if (error instanceof CompiledCheckUnsupportedError) { - return NextResponse.json({ error: error.message }, { status: 422 }) - } - if (error instanceof CompiledCheckTooLargeError) { - return NextResponse.json({ error: error.message }, { status: 413 }) - } - return internalPlainFileErrorPolicy.render(error) - }, -} - export const GET = defineInternalJsonRoute({ contract: workspaceFileCompiledCheckContract, auth: internalSessionAuth, @@ -35,8 +17,7 @@ export const GET = defineInternalJsonRoute({ rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal compiled-check behavior', }), - errorPolicy: compiledCheckErrorPolicy, + errorPolicy: internalFileErrorPolicies.compiledCheck, mapInput: ({ params }) => ({ fileId: params.fileId, assertedWorkspaceId: params.id }), useCase: compiledCheckWorkspaceFile, - present: (result) => result, }) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts index 2ba09bca4b9..71f6b24f066 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts @@ -4,7 +4,7 @@ import { internalRateLimits, internalSessionAuth, } from '@/lib/api/server/routes' -import { internalFileErrorPolicy } from '@/lib/workspace-files/api' +import { internalFileErrorPolicies, internalFilePresenters } from '@/lib/workspace-files/api' import { fileOperations } from '@/lib/workspace-files/application/operations' import { updateWorkspaceFileContent } from '@/lib/workspace-files/application/update-workspace-file-content' import { MAX_WORKSPACE_FILE_INLINE_BODY_BYTES } from '@/lib/workspace-files/orchestration' @@ -19,7 +19,7 @@ export const PUT = defineInternalJsonRoute({ rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal content-update behavior', }), - errorPolicy: internalFileErrorPolicy, + errorPolicy: internalFileErrorPolicies.default, parseOptions: { maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES }, beforeParse: async ({ principal, params }) => { if (typeof params.fileId === 'string') { @@ -33,5 +33,5 @@ export const PUT = defineInternalJsonRoute({ encoding: body.encoding === 'base64' ? ('base64' as const) : ('utf-8' as const), }), useCase: updateWorkspaceFileContent, - present: ({ file }) => ({ success: true, file: { ...file, folderId: file.folderId ?? null } }), + present: internalFilePresenters.successFile, }) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/csv-preview/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/csv-preview/route.ts index f907f48935b..100a7fadca0 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/csv-preview/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/csv-preview/route.ts @@ -1,10 +1,7 @@ import { createLogger } from '@sim/logger' import { getWorkspaceCsvPreviewContract } from '@/lib/api/contracts/workspace-file-table' import { defineInternalJsonRoute, internalRateLimits } from '@/lib/api/server/routes' -import { - internalPlainFileErrorPolicy, - internalSessionOrServiceAuth, -} from '@/lib/workspace-files/api' +import { internalFileErrorPolicies, internalSessionOrServiceAuth } from '@/lib/workspace-files/api' import { csvPreviewWorkspaceFile } from '@/lib/workspace-files/application/csv-preview-workspace-file' const logger = createLogger('WorkspaceCsvPreviewAPI') @@ -17,18 +14,17 @@ export const GET = defineInternalJsonRoute({ auth: internalSessionOrServiceAuth, operation: csvPreviewWorkspaceFile.operation, rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal CSV preview behavior' }), - errorPolicy: internalPlainFileErrorPolicy, + errorPolicy: internalFileErrorPolicies.plain, mapInput: ({ params, query }) => ({ fileId: params.fileId, assertedWorkspaceId: params.id, key: query.key, }), useCase: csvPreviewWorkspaceFile, - present: (result) => { + onSuccess: ({ result }) => { logger.info('CSV preview served', { rows: result.rows.length, truncated: result.truncated, }) - return result }, }) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/dimensions/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/dimensions/route.ts index 098db4bd44b..96a1c73ca53 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/dimensions/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/dimensions/route.ts @@ -4,7 +4,7 @@ import { internalRateLimits, internalSessionAuth, } from '@/lib/api/server/routes' -import { internalFileErrorPolicy } from '@/lib/workspace-files/api' +import { internalFileErrorPolicies } from '@/lib/workspace-files/api' import { fileOperations } from '@/lib/workspace-files/application/operations' import { updateWorkspaceFileDimensionsOperation } from '@/lib/workspace-files/application/update-workspace-file-dimensions' @@ -13,7 +13,7 @@ export const PATCH = defineInternalJsonRoute({ auth: internalSessionAuth, operation: fileOperations.updateMetadata, rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal dimensions behavior' }), - errorPolicy: internalFileErrorPolicy, + errorPolicy: internalFileErrorPolicies.default, mapInput: ({ params, body }) => ({ fileId: params.fileId, assertedWorkspaceId: params.id, @@ -22,5 +22,4 @@ export const PATCH = defineInternalJsonRoute({ height: body.height, }), useCase: updateWorkspaceFileDimensionsOperation, - present: ({ success }) => ({ success }), }) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/download/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/download/route.ts index 86d51315094..d4916e2260c 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/download/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/download/route.ts @@ -1,60 +1,30 @@ -import { createLogger } from '@sim/logger' -import { NextResponse } from 'next/server' import { downloadWorkspaceFileUrlContract } from '@/lib/api/contracts/workspace-files' import { defineInternalJsonRoute, internalRateLimits, internalSessionAuth, } from '@/lib/api/server/routes' -import { getBaseUrl } from '@/lib/core/utils/urls' -import { captureServerEvent } from '@/lib/posthog/server' -import { internalFileErrorPolicy } from '@/lib/workspace-files/api' +import { + internalFileAnalytics, + internalFileErrorPolicies, + internalFilePresenters, +} from '@/lib/workspace-files/api' import { downloadWorkspaceFile } from '@/lib/workspace-files/application/download-workspace-file' export const dynamic = 'force-dynamic' -const logger = createLogger('WorkspaceFileDownloadAPI') - -const downloadErrorPolicy = { - render(error: unknown) { - const typed = internalFileErrorPolicy.render(error) - if (typed) return typed - logger.error('Failed to generate workspace file download URL', { error }) - return NextResponse.json( - { success: false, error: 'Failed to generate download URL' }, - { status: 500 } - ) - }, -} - /** POST /api/workspaces/[id]/files/[fileId]/download — Create an authenticated serve URL. */ export const POST = defineInternalJsonRoute({ contract: downloadWorkspaceFileUrlContract, auth: internalSessionAuth, operation: downloadWorkspaceFile.operation, rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal download behavior' }), - errorPolicy: downloadErrorPolicy, + errorPolicy: internalFileErrorPolicies.downloadUrl, mapInput: ({ params }) => ({ fileId: params.fileId, assertedWorkspaceId: params.id, }), useCase: downloadWorkspaceFile, - onSuccess: ({ principal, result }) => { - captureServerEvent( - principal.userId, - 'file_downloaded', - { workspace_id: result.file.workspaceId, is_bulk: false, file_count: 1 }, - { groups: { workspace: result.file.workspaceId } } - ) - }, - present: ({ file }) => { - const baseUrl = getBaseUrl() - return { - success: true as const, - downloadUrl: `${baseUrl}/api/files/serve/${encodeURIComponent(file.key)}?context=workspace`, - viewerUrl: `${baseUrl}/workspace/${file.workspaceId}/files/${file.id}`, - fileName: file.name, - expiresIn: null, - } - }, + onSuccess: internalFileAnalytics.downloaded, + present: internalFilePresenters.downloadUrl, }) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/restore/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/restore/route.ts index a7aad76d456..f286697997a 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/restore/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/restore/route.ts @@ -1,10 +1,11 @@ import { restoreWorkspaceFileContract } from '@/lib/api/contracts/workspace-files' import { defineInternalJsonRoute, + internalJsonPresenters, internalRateLimits, internalSessionAuth, } from '@/lib/api/server/routes' -import { internalFileErrorPolicy } from '@/lib/workspace-files/api' +import { internalFileErrorPolicies } from '@/lib/workspace-files/api' import { fileOperations } from '@/lib/workspace-files/application/operations' import { restoreWorkspaceFileOperation } from '@/lib/workspace-files/application/restore-workspace-file' @@ -13,8 +14,8 @@ export const POST = defineInternalJsonRoute({ auth: internalSessionAuth, operation: fileOperations.restore, rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal restore behavior' }), - errorPolicy: internalFileErrorPolicy, + errorPolicy: internalFileErrorPolicies.default, mapInput: ({ params }) => ({ fileId: params.fileId, assertedWorkspaceId: params.id }), useCase: restoreWorkspaceFileOperation, - present: ({ restored }) => ({ success: restored }), + present: internalJsonPresenters.successFrom('restored'), }) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts index 27b6c6494f1..2f5bb14e2d1 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/route.ts @@ -4,11 +4,15 @@ import { } from '@/lib/api/contracts/workspace-files' import { defineInternalJsonRoute, + internalJsonPresenters, internalRateLimits, internalSessionAuth, } from '@/lib/api/server/routes' -import { captureServerEvent } from '@/lib/posthog/server' -import { internalFileErrorPolicy } from '@/lib/workspace-files/api' +import { + internalFileAnalytics, + internalFileErrorPolicies, + internalFilePresenters, +} from '@/lib/workspace-files/api' import { deleteWorkspaceFileOperation } from '@/lib/workspace-files/application/delete-workspace-file' import { fileOperations } from '@/lib/workspace-files/application/operations' import { renameWorkspaceFile } from '@/lib/workspace-files/application/rename-workspace-file' @@ -24,22 +28,15 @@ export const PATCH = defineInternalJsonRoute({ auth: internalSessionAuth, operation: fileOperations.rename, rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal rename behavior' }), - errorPolicy: internalFileErrorPolicy, + errorPolicy: internalFileErrorPolicies.default, mapInput: ({ params, body }) => ({ fileId: params.fileId, assertedWorkspaceId: params.id, name: body.name, }), useCase: renameWorkspaceFile, - onSuccess: ({ principal, result }) => { - captureServerEvent( - principal.userId, - 'file_renamed', - { workspace_id: result.file.workspaceId }, - { groups: { workspace: result.file.workspaceId } } - ) - }, - present: ({ file }) => ({ success: true, file: { ...file, folderId: file.folderId ?? null } }), + onSuccess: internalFileAnalytics.renamed, + present: internalFilePresenters.successFile, }) /** @@ -51,16 +48,9 @@ export const DELETE = defineInternalJsonRoute({ auth: internalSessionAuth, operation: fileOperations.delete, rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal delete behavior' }), - errorPolicy: internalFileErrorPolicy, + errorPolicy: internalFileErrorPolicies.default, mapInput: ({ params }) => ({ fileId: params.fileId, assertedWorkspaceId: params.id }), useCase: deleteWorkspaceFileOperation, - onSuccess: ({ principal, result }) => { - captureServerEvent( - principal.userId, - 'file_deleted', - { workspace_id: result.workspaceId }, - { groups: { workspace: result.workspaceId } } - ) - }, - present: ({ deleted }) => ({ success: deleted }), + onSuccess: internalFileAnalytics.deleted, + present: internalJsonPresenters.successFrom('deleted'), }) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.ts index f1086839fcf..10629599374 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/share/route.ts @@ -4,7 +4,7 @@ import { internalRateLimits, internalSessionAuth, } from '@/lib/api/server/routes' -import { internalFileErrorPolicy } from '@/lib/workspace-files/api' +import { internalFileErrorPolicies } from '@/lib/workspace-files/api' import { fileOperations } from '@/lib/workspace-files/application/operations' import { getWorkspaceFileShare, @@ -18,10 +18,9 @@ export const GET = defineInternalJsonRoute({ auth: internalSessionAuth, operation: fileOperations.readShare, rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal share-read behavior' }), - errorPolicy: internalFileErrorPolicy, + errorPolicy: internalFileErrorPolicies.default, mapInput: ({ params }) => ({ fileId: params.fileId, assertedWorkspaceId: params.id }), useCase: getWorkspaceFileShare, - present: ({ share }) => ({ share }), }) export const PUT = defineInternalJsonRoute({ @@ -31,7 +30,7 @@ export const PUT = defineInternalJsonRoute({ rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal share-update behavior', }), - errorPolicy: internalFileErrorPolicy, + errorPolicy: internalFileErrorPolicies.default, mapInput: ({ params, body }) => ({ fileId: params.fileId, assertedWorkspaceId: params.id, @@ -42,5 +41,4 @@ export const PUT = defineInternalJsonRoute({ token: body.token, }), useCase: updateWorkspaceFileShare, - present: ({ share }) => ({ share }), }) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/style/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/style/route.ts index d8e82a790aa..c9355e47a56 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/style/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/style/route.ts @@ -1,37 +1,23 @@ -import { NextResponse } from 'next/server' import { workspaceFileStyleContract } from '@/lib/api/contracts/workspace-files' import { defineInternalJsonRoute, internalRateLimits, internalSessionAuth, } from '@/lib/api/server/routes' -import { internalPlainFileErrorPolicy } from '@/lib/workspace-files/api' -import { - StyleExtractionUnsupportedError, - styleWorkspaceFile, -} from '@/lib/workspace-files/application/style-workspace-file' +import { internalFileErrorPolicies, internalFilePresenters } from '@/lib/workspace-files/api' +import { styleWorkspaceFile } from '@/lib/workspace-files/application/style-workspace-file' export const dynamic = 'force-dynamic' export const runtime = 'nodejs' -const styleErrorPolicy = { - ...internalPlainFileErrorPolicy, - render(error: unknown) { - if (error instanceof StyleExtractionUnsupportedError) { - return NextResponse.json({ error: error.message }, { status: 422 }) - } - return internalPlainFileErrorPolicy.render(error) - }, -} - export const GET = defineInternalJsonRoute({ contract: workspaceFileStyleContract, auth: internalSessionAuth, operation: styleWorkspaceFile.operation, rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal style behavior' }), - errorPolicy: styleErrorPolicy, + errorPolicy: internalFileErrorPolicies.style, mapInput: ({ params }) => ({ fileId: params.fileId, assertedWorkspaceId: params.id }), useCase: styleWorkspaceFile, - present: (result) => workspaceFileStyleContract.response.schema.parse(result), + present: internalFilePresenters.style, responseHeaders: () => ({ 'Cache-Control': 'private, max-age=300' }), }) diff --git a/apps/sim/app/api/workspaces/[id]/files/bulk-archive/route.ts b/apps/sim/app/api/workspaces/[id]/files/bulk-archive/route.ts index 4889b9c7199..09cd4f2cb53 100644 --- a/apps/sim/app/api/workspaces/[id]/files/bulk-archive/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/bulk-archive/route.ts @@ -1,11 +1,11 @@ import { bulkArchiveWorkspaceFileItemsContract } from '@/lib/api/contracts/workspace-file-folders' import { defineInternalJsonRoute, + internalJsonPresenters, internalRateLimits, internalSessionAuth, } from '@/lib/api/server/routes' -import { captureServerEvent } from '@/lib/posthog/server' -import { internalFileErrorPolicy } from '@/lib/workspace-files/api' +import { internalFileAnalytics, internalFileErrorPolicies } from '@/lib/workspace-files/api' import { archiveWorkspaceFileItemsOperation } from '@/lib/workspace-files/application/archive-workspace-file-items' import { fileOperations } from '@/lib/workspace-files/application/operations' @@ -16,24 +16,13 @@ export const POST = defineInternalJsonRoute({ rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal bulk archive behavior', }), - errorPolicy: internalFileErrorPolicy, + errorPolicy: internalFileErrorPolicies.default, mapInput: ({ params, body }) => ({ workspaceId: params.id, fileIds: body.fileIds, folderIds: body.folderIds, }), useCase: archiveWorkspaceFileItemsOperation, - onSuccess: ({ principal, input }) => { - captureServerEvent( - principal.userId, - 'file_bulk_deleted', - { - workspace_id: input.workspaceId, - file_count: input.fileIds?.length ?? 0, - folder_count: input.folderIds?.length ?? 0, - }, - { groups: { workspace: input.workspaceId } } - ) - }, - present: ({ deletedItems }) => ({ success: true, deletedItems }), + onSuccess: internalFileAnalytics.bulkDeleted, + present: internalJsonPresenters.withSuccess, }) diff --git a/apps/sim/app/api/workspaces/[id]/files/download/route.ts b/apps/sim/app/api/workspaces/[id]/files/download/route.ts index 20853cdd77e..6d4220fd1a2 100644 --- a/apps/sim/app/api/workspaces/[id]/files/download/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/download/route.ts @@ -1,37 +1,21 @@ import { Readable } from 'node:stream' import { createLogger } from '@sim/logger' import { ZipArchive } from 'archiver' -import { NextResponse } from 'next/server' import { downloadWorkspaceFileItemsContract } from '@/lib/api/contracts/workspace-file-folders' import { defineInternalBinaryRoute, internalRateLimits, internalSessionAuth, } from '@/lib/api/server/routes' -import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' import { nodeReadableToWebStream } from '@/lib/core/utils/node-stream' -import { captureServerEvent } from '@/lib/posthog/server' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { downloadFileStream } from '@/lib/uploads/core/storage-service' import { buildZipEntryPaths } from '@/lib/uploads/zip-entry-path' +import { internalFileAnalytics, internalFileErrorPolicies } from '@/lib/workspace-files/api' import { downloadWorkspaceFileItems } from '@/lib/workspace-files/application/download-workspace-file-items' const logger = createLogger('WorkspaceFilesDownloadAPI') -const downloadErrorPolicy = { - render(error: unknown): NextResponse | null { - const classified = asOrchestrationError(error) - if (classified) { - return NextResponse.json( - { error: classified.message }, - { status: statusForOrchestrationError(classified.code) } - ) - } - logger.error('Failed to download workspace file selection', { error }) - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) - }, -} - function lazyWorkspaceFileStream(file: WorkspaceFileRecord): Readable { return Readable.from( (async function* () { @@ -49,21 +33,14 @@ export const GET = defineInternalBinaryRoute({ auth: internalSessionAuth, operation: downloadWorkspaceFileItems.operation, rateLimit: internalRateLimits.none({ reason: 'Internal workspace zip download' }), - errorPolicy: downloadErrorPolicy, + errorPolicy: internalFileErrorPolicies.downloadArchive, mapInput: ({ params, query }) => ({ workspaceId: params.id, fileIds: query.fileIds, folderIds: query.folderIds, }), useCase: downloadWorkspaceFileItems, - onSuccess: ({ principal, input, result }) => { - captureServerEvent( - principal.userId, - 'file_downloaded', - { workspace_id: input.workspaceId, is_bulk: true, file_count: result.filesToZip.length }, - { groups: { workspace: input.workspaceId } } - ) - }, + onSuccess: internalFileAnalytics.bulkDownloaded, present: ({ filesToZip, folderPaths, renderedDocuments }) => { const entryPaths = buildZipEntryPaths( filesToZip.map((file) => ({ diff --git a/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/restore/route.ts b/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/restore/route.ts index c5d7697d8b9..9c492438d8d 100644 --- a/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/restore/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/restore/route.ts @@ -1,11 +1,11 @@ import { restoreWorkspaceFileFolderContract } from '@/lib/api/contracts/workspace-file-folders' import { defineInternalJsonRoute, + internalJsonPresenters, internalRateLimits, internalSessionAuth, } from '@/lib/api/server/routes' -import { captureServerEvent } from '@/lib/posthog/server' -import { internalFileErrorPolicy } from '@/lib/workspace-files/api' +import { internalFileAnalytics, internalFileErrorPolicies } from '@/lib/workspace-files/api' import { fileOperations } from '@/lib/workspace-files/application/operations' import { restoreWorkspaceFileFolderOperation } from '@/lib/workspace-files/application/workspace-file-folders' @@ -16,16 +16,9 @@ export const POST = defineInternalJsonRoute({ rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal folder restore behavior', }), - errorPolicy: internalFileErrorPolicy, + errorPolicy: internalFileErrorPolicies.default, mapInput: ({ params }) => ({ workspaceId: params.id, folderId: params.folderId }), useCase: restoreWorkspaceFileFolderOperation, - onSuccess: ({ principal, input }) => { - captureServerEvent( - principal.userId, - 'folder_restored', - { folder_id: input.folderId, workspace_id: input.workspaceId }, - { groups: { workspace: input.workspaceId } } - ) - }, - present: ({ folder, restoredItems }) => ({ success: true, folder, restoredItems }), + onSuccess: internalFileAnalytics.folderRestored, + present: internalJsonPresenters.withSuccess, }) diff --git a/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.ts b/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.ts index aadee554633..c5873d2fdfa 100644 --- a/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/folders/[folderId]/route.ts @@ -4,11 +4,11 @@ import { } from '@/lib/api/contracts/workspace-file-folders' import { defineInternalJsonRoute, + internalJsonPresenters, internalRateLimits, internalSessionAuth, } from '@/lib/api/server/routes' -import { captureServerEvent } from '@/lib/posthog/server' -import { internalFileErrorPolicy } from '@/lib/workspace-files/api' +import { internalFileAnalytics, internalFileErrorPolicies } from '@/lib/workspace-files/api' import { fileOperations } from '@/lib/workspace-files/application/operations' import { deleteWorkspaceFileFolderOperation, @@ -22,18 +22,11 @@ export const PATCH = defineInternalJsonRoute({ rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal folder update behavior', }), - errorPolicy: internalFileErrorPolicy, + errorPolicy: internalFileErrorPolicies.default, mapInput: ({ params, body }) => ({ workspaceId: params.id, folderId: params.folderId, ...body }), useCase: updateWorkspaceFileFolderOperation, - onSuccess: ({ principal, input }) => { - captureServerEvent( - principal.userId, - 'folder_renamed', - { workspace_id: input.workspaceId }, - { groups: { workspace: input.workspaceId } } - ) - }, - present: ({ folder }) => ({ success: true, folder }), + onSuccess: internalFileAnalytics.folderRenamed, + present: internalJsonPresenters.withSuccess, }) export const DELETE = defineInternalJsonRoute({ @@ -43,16 +36,9 @@ export const DELETE = defineInternalJsonRoute({ rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal folder deletion behavior', }), - errorPolicy: internalFileErrorPolicy, + errorPolicy: internalFileErrorPolicies.default, mapInput: ({ params }) => ({ workspaceId: params.id, folderId: params.folderId }), useCase: deleteWorkspaceFileFolderOperation, - onSuccess: ({ principal, input }) => { - captureServerEvent( - principal.userId, - 'folder_deleted', - { workspace_id: input.workspaceId }, - { groups: { workspace: input.workspaceId } } - ) - }, - present: ({ deletedItems }) => ({ success: true, deletedItems }), + onSuccess: internalFileAnalytics.folderDeleted, + present: internalJsonPresenters.withSuccess, }) diff --git a/apps/sim/app/api/workspaces/[id]/files/folders/route.ts b/apps/sim/app/api/workspaces/[id]/files/folders/route.ts index 03026fd62e9..4165d8f42d7 100644 --- a/apps/sim/app/api/workspaces/[id]/files/folders/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/folders/route.ts @@ -4,11 +4,11 @@ import { } from '@/lib/api/contracts/workspace-file-folders' import { defineInternalJsonRoute, + internalJsonPresenters, internalRateLimits, internalSessionAuth, } from '@/lib/api/server/routes' -import { captureServerEvent } from '@/lib/posthog/server' -import { internalFileErrorPolicy } from '@/lib/workspace-files/api' +import { internalFileAnalytics, internalFileErrorPolicies } from '@/lib/workspace-files/api' import { fileOperations } from '@/lib/workspace-files/application/operations' import { createWorkspaceFileFolderOperation, @@ -22,10 +22,10 @@ export const GET = defineInternalJsonRoute({ rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal folder listing behavior', }), - errorPolicy: internalFileErrorPolicy, + errorPolicy: internalFileErrorPolicies.default, mapInput: ({ params, query }) => ({ workspaceId: params.id, scope: query.scope }), useCase: listWorkspaceFileFoldersOperation, - present: ({ folders }) => ({ success: true, folders }), + present: internalJsonPresenters.withSuccess, }) export const POST = defineInternalJsonRoute({ @@ -35,20 +35,13 @@ export const POST = defineInternalJsonRoute({ rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal folder creation behavior', }), - errorPolicy: internalFileErrorPolicy, + errorPolicy: internalFileErrorPolicies.default, mapInput: ({ params, body }) => ({ workspaceId: params.id, name: body.name, parentId: body.parentId, }), useCase: createWorkspaceFileFolderOperation, - onSuccess: ({ principal, input }) => { - captureServerEvent( - principal.userId, - 'folder_created', - { workspace_id: input.workspaceId }, - { groups: { workspace: input.workspaceId } } - ) - }, - present: ({ folder }) => ({ success: true, folder }), + onSuccess: internalFileAnalytics.folderCreated, + present: internalJsonPresenters.withSuccess, }) diff --git a/apps/sim/app/api/workspaces/[id]/files/inline/route.ts b/apps/sim/app/api/workspaces/[id]/files/inline/route.ts index a68fa188b33..ad3780eb4be 100644 --- a/apps/sim/app/api/workspaces/[id]/files/inline/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/inline/route.ts @@ -1,46 +1,15 @@ -import { createLogger } from '@sim/logger' -import type { NextResponse } from 'next/server' import { getInlineWorkspaceFileContract } from '@/lib/api/contracts/workspace-files' import { defineInternalBinaryRoute, internalRateLimits, internalSessionAuth, } from '@/lib/api/server/routes' -import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' +import { internalFileErrorPolicies } from '@/lib/workspace-files/api' import { readWorkspaceInlineFile } from '@/lib/workspace-files/application/read-workspace-inline-file' -import { - createErrorResponse, - encodeFilenameForHeader, - FileNotFoundError, - getSecureFileHeaders, -} from '@/app/api/files/utils' +import { encodeFilenameForHeader, getSecureFileHeaders } from '@/app/api/files/utils' export const dynamic = 'force-dynamic' -const logger = createLogger('WorkspaceInlineFileAPI') - -const inlineFileErrorPolicy: { render(error: unknown): NextResponse | null } = { - render(error): NextResponse | null { - const classified = asOrchestrationError(error) - if (classified) { - if (classified.code === 'not_found' || classified.code === 'forbidden') { - return createErrorResponse(new FileNotFoundError('Not found')) - } - return createErrorResponse( - new Error(classified.message), - statusForOrchestrationError(classified.code) - ) - } - if (error instanceof Error) { - logger.error('Error serving workspace inline image', { error }) - return createErrorResponse(error) - } - const fallback = new Error('Failed to serve file') - logger.error('Error serving workspace inline image', { error }) - return createErrorResponse(fallback) - }, -} - /** * GET /api/workspaces/[id]/files/inline?key=|fileId= * @@ -53,7 +22,7 @@ export const GET = defineInternalBinaryRoute({ auth: internalSessionAuth, operation: readWorkspaceInlineFile.operation, rateLimit: internalRateLimits.none({ reason: 'Internal workspace inline image delivery' }), - errorPolicy: inlineFileErrorPolicy, + errorPolicy: internalFileErrorPolicies.inline, mapInput: ({ params, query }) => ({ workspaceId: params.id, key: query.key, diff --git a/apps/sim/app/api/workspaces/[id]/files/move/route.ts b/apps/sim/app/api/workspaces/[id]/files/move/route.ts index 578820bd1e7..54d2c3adc64 100644 --- a/apps/sim/app/api/workspaces/[id]/files/move/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/move/route.ts @@ -1,11 +1,11 @@ import { moveWorkspaceFileItemsContract } from '@/lib/api/contracts/workspace-file-folders' import { defineInternalJsonRoute, + internalJsonPresenters, internalRateLimits, internalSessionAuth, } from '@/lib/api/server/routes' -import { captureServerEvent } from '@/lib/posthog/server' -import { internalFileErrorPolicy } from '@/lib/workspace-files/api' +import { internalFileAnalytics, internalFileErrorPolicies } from '@/lib/workspace-files/api' import { moveWorkspaceFileItemsOperation } from '@/lib/workspace-files/application/move-workspace-file-items' import { fileOperations } from '@/lib/workspace-files/application/operations' @@ -14,7 +14,7 @@ export const POST = defineInternalJsonRoute({ auth: internalSessionAuth, operation: fileOperations.move, rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal file move behavior' }), - errorPolicy: internalFileErrorPolicy, + errorPolicy: internalFileErrorPolicies.default, mapInput: ({ params, body }) => ({ workspaceId: params.id, fileIds: body.fileIds, @@ -22,31 +22,6 @@ export const POST = defineInternalJsonRoute({ targetFolderId: body.targetFolderId, }), useCase: moveWorkspaceFileItemsOperation, - onSuccess: ({ principal, input }) => { - if (input.fileIds && input.fileIds.length > 0) { - captureServerEvent( - principal.userId, - 'file_moved', - { - workspace_id: input.workspaceId, - file_count: input.fileIds.length, - folder_count: input.folderIds?.length ?? 0, - }, - { groups: { workspace: input.workspaceId } } - ) - } - if (input.folderIds && input.folderIds.length > 0) { - captureServerEvent( - principal.userId, - 'folder_moved', - { - workspace_id: input.workspaceId, - file_count: input.fileIds?.length ?? 0, - folder_count: input.folderIds.length, - }, - { groups: { workspace: input.workspaceId } } - ) - } - }, - present: ({ movedItems }) => ({ success: true, movedItems }), + onSuccess: internalFileAnalytics.moved, + present: internalJsonPresenters.withSuccess, }) diff --git a/apps/sim/app/api/workspaces/[id]/files/route.ts b/apps/sim/app/api/workspaces/[id]/files/route.ts index 8ca74e7d497..267c3cfbf7a 100644 --- a/apps/sim/app/api/workspaces/[id]/files/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/route.ts @@ -7,9 +7,12 @@ import { internalRateLimits, internalSessionAuth, } from '@/lib/api/server/routes' -import { captureServerEvent } from '@/lib/posthog/server' import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' -import { internalFileErrorPolicy } from '@/lib/workspace-files/api' +import { + internalFileAnalytics, + internalFileErrorPolicies, + internalFilePresenters, +} from '@/lib/workspace-files/api' import { createWorkspaceFile } from '@/lib/workspace-files/application/create-workspace-file' import { listAllWorkspaceFiles } from '@/lib/workspace-files/application/list-workspace-files' import { fileOperations } from '@/lib/workspace-files/application/operations' @@ -23,13 +26,10 @@ export const GET = defineInternalJsonRoute({ auth: internalSessionAuth, operation: fileOperations.list, rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal file-list behavior' }), - errorPolicy: internalFileErrorPolicy, + errorPolicy: internalFileErrorPolicies.default, mapInput: ({ params, query }) => ({ workspaceId: params.id, scope: query.scope }), useCase: listAllWorkspaceFiles, - present: ({ files }) => ({ - success: true, - files: files.map((file) => ({ ...file, folderId: file.folderId ?? null })), - }), + present: internalFilePresenters.successFiles, }) /** POST /api/workspaces/[id]/files — Create an authored workspace file. */ @@ -38,7 +38,7 @@ export const POST = defineInternalJsonRoute({ auth: internalSessionAuth, operation: fileOperations.create, rateLimit: internalRateLimits.none({ reason: 'Preserve existing internal file-create behavior' }), - errorPolicy: internalFileErrorPolicy, + errorPolicy: internalFileErrorPolicies.default, parseOptions: { maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES }, beforeParse: async ({ principal, params }) => { if (typeof params.id === 'string') await createWorkspaceFile.admit(principal, params.id) @@ -53,13 +53,6 @@ export const POST = defineInternalJsonRoute({ exactName: false, }), useCase: createWorkspaceFile, - onSuccess: ({ principal, result }) => { - captureServerEvent( - principal.userId, - 'file_uploaded', - { workspace_id: result.file.workspaceId, file_type: result.file.type }, - { groups: { workspace: result.file.workspaceId } } - ) - }, - present: ({ file }) => ({ success: true, file: { ...file, folderId: file.folderId ?? null } }), + onSuccess: internalFileAnalytics.uploaded, + present: internalFilePresenters.successFile, }) diff --git a/apps/sim/lib/api/server/routes/index.ts b/apps/sim/lib/api/server/routes/index.ts index f23fe8f7548..f9fdcfd39c1 100644 --- a/apps/sim/lib/api/server/routes/index.ts +++ b/apps/sim/lib/api/server/routes/index.ts @@ -2,9 +2,12 @@ export { defineInternalBinaryRoute } from '@/lib/api/server/routes/internal-bina export { createInternalSessionOrServiceAuth, defineInternalJsonRoute, + extendInternalErrorPolicy, type InternalAuthPolicy, type InternalErrorPolicy, InternalUnauthenticatedError, + internalErrorResponse, + internalJsonPresenters, internalOrchestrationErrorPolicy, internalPlainOrchestrationErrorPolicy, internalRateLimits, diff --git a/apps/sim/lib/api/server/routes/internal-binary-route.ts b/apps/sim/lib/api/server/routes/internal-binary-route.ts index 7d0abcfefb3..5e40e070dc2 100644 --- a/apps/sim/lib/api/server/routes/internal-binary-route.ts +++ b/apps/sim/lib/api/server/routes/internal-binary-route.ts @@ -3,12 +3,14 @@ import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { requireBinaryRouteDefinition } from '@/lib/api/server/routes/definition' import { + type InternalErrorPolicy, InternalUnauthenticatedError, type internalSessionAuth, } from '@/lib/api/server/routes/internal-json-route' import type { BinaryApiRouteContract, BinaryResponseDescriptor, + JsonErrorResponseDescriptor, JsonNextRouteHandler, JsonRouteContext, } from '@/lib/api/server/routes/types' @@ -23,10 +25,6 @@ interface InternalBinaryRateLimitPolicy { enforce(request: NextRequest, principal: Principal): Promise } -interface InternalBinaryErrorPolicy { - render(error: unknown): NextResponse | null -} - interface InternalBinaryRouteDefinition< C extends BinaryApiRouteContract, O extends ApplicationOperation, @@ -48,7 +46,7 @@ interface InternalBinaryRouteOptions< > extends InternalBinaryRouteDefinition { auth: typeof internalSessionAuth rateLimit: InternalBinaryRateLimitPolicy - errorPolicy: InternalBinaryErrorPolicy + errorPolicy: InternalErrorPolicy onSuccess?(args: { principal: SessionPrincipal; input: I; result: R }): void | Promise } @@ -107,8 +105,8 @@ export function defineInternalBinaryRoute< } return new NextResponse(descriptor.body, { status: successStatus, headers }) } catch (error) { - const response = options.errorPolicy.render(error) - if (response) return response + const response = options.errorPolicy.project(error) + if (response) return createJsonErrorResponse(response) throw error } }, @@ -120,3 +118,10 @@ export function defineInternalBinaryRoute< return async (request, context) => wrapped(request, context) } + +function createJsonErrorResponse(descriptor: JsonErrorResponseDescriptor): NextResponse { + return NextResponse.json(descriptor.body, { + status: descriptor.status, + headers: descriptor.headers, + }) +} diff --git a/apps/sim/lib/api/server/routes/internal-json-route.test.ts b/apps/sim/lib/api/server/routes/internal-json-route.test.ts new file mode 100644 index 00000000000..c295caec6d4 --- /dev/null +++ b/apps/sim/lib/api/server/routes/internal-json-route.test.ts @@ -0,0 +1,85 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { describe, expect, it, vi } from 'vitest' +import { z } from 'zod' +import { defineRouteContract } from '@/lib/api/contracts' +import { + defineInternalJsonRoute, + internalErrorResponse, + internalPlainOrchestrationErrorPolicy, + internalRateLimits, +} from '@/lib/api/server/routes/internal-json-route' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +const operation = { id: 'test.read' } as const +const auth = { + authenticate: vi.fn(async () => ({ + kind: 'session' as const, + userId: 'user-1', + sessionId: 'session-1', + })), +} + +const contract = defineRouteContract({ + method: 'GET', + path: '/api/test/internal-json-route', + response: { + mode: 'json', + schema: z.object({ value: z.string() }), + }, +}) + +describe('defineInternalJsonRoute', () => { + it('uses the use-case result directly when it already matches the contract', async () => { + const handler = defineInternalJsonRoute({ + contract, + auth, + operation, + rateLimit: internalRateLimits.none({ reason: 'Unit test' }), + errorPolicy: internalPlainOrchestrationErrorPolicy, + mapInput: () => undefined, + useCase: { + operation, + async execute() { + return { value: 'ok' } + }, + }, + }) + + const response = await handler(new NextRequest('http://localhost/api/test/internal-json-route')) + + expect(response.status).toBe(200) + await expect(response.json()).resolves.toEqual({ value: 'ok' }) + expect(response.headers.get('x-request-id')).toBeTruthy() + }) + + it('renders typed error descriptors through the shared builder', async () => { + const handler = defineInternalJsonRoute({ + contract, + auth, + operation, + rateLimit: internalRateLimits.none({ reason: 'Unit test' }), + errorPolicy: internalPlainOrchestrationErrorPolicy, + mapInput: () => undefined, + useCase: { + operation, + async execute(): Promise<{ value: string }> { + throw new OrchestrationError('conflict', 'Already exists') + }, + }, + }) + + const response = await handler(new NextRequest('http://localhost/api/test/internal-json-route')) + + expect(response.status).toBe(409) + await expect(response.json()).resolves.toEqual({ error: 'Already exists' }) + }) + + it('rejects invalid error statuses immediately', () => { + expect(() => internalErrorResponse(200, { error: 'Invalid' })).toThrow( + 'Internal error responses require a 4xx or 5xx status' + ) + }) +}) diff --git a/apps/sim/lib/api/server/routes/internal-json-route.ts b/apps/sim/lib/api/server/routes/internal-json-route.ts index 7020337a093..8191e353794 100644 --- a/apps/sim/lib/api/server/routes/internal-json-route.ts +++ b/apps/sim/lib/api/server/routes/internal-json-route.ts @@ -1,17 +1,22 @@ import type { DelegatedPrincipal, Principal, SessionPrincipal } from '@sim/auth/principal' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' +import type { ContractJsonResponse } from '@/lib/api/contracts' import { requireJsonRouteDefinition } from '@/lib/api/server/routes/definition' import type { JsonApiRouteContract, + JsonErrorResponseDescriptor, JsonNextRouteHandler, JsonRouteContext, - JsonRouteDefinition, } from '@/lib/api/server/routes/types' -import { type ParseRequestOptions, parseRequest } from '@/lib/api/server/validation' +import { + type ParsedRequest, + type ParseRequestOptions, + parseRequest, +} from '@/lib/api/server/validation' import { getSession } from '@/lib/auth' import { verifyInternalToken } from '@/lib/auth/internal' -import type { ApplicationOperation } from '@/lib/core/application' +import type { ApplicationOperation, OperationUseCase } from '@/lib/core/application' import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -74,35 +79,66 @@ export const internalRateLimits = { } as const export interface InternalErrorPolicy { - render(error: unknown): NextResponse | null - unhandled?(): NextResponse + project(error: unknown): JsonErrorResponseDescriptor | null + unhandled?(): JsonErrorResponseDescriptor } export const internalOrchestrationErrorPolicy: InternalErrorPolicy = { - render(error) { + project(error) { const classified = asOrchestrationError(error) if (!classified) return null - return NextResponse.json( - { success: false, error: classified.message }, - { status: statusForOrchestrationError(classified.code) } - ) + return internalErrorResponse(statusForOrchestrationError(classified.code), { + success: false, + error: classified.message, + }) }, } export const internalPlainOrchestrationErrorPolicy: InternalErrorPolicy = { - render(error) { + project(error) { const classified = asOrchestrationError(error) if (!classified) return null - return NextResponse.json( - { error: classified.message }, - { status: statusForOrchestrationError(classified.code) } - ) + return internalErrorResponse(statusForOrchestrationError(classified.code), { + error: classified.message, + }) }, unhandled() { - return NextResponse.json({ error: 'Internal server error' }, { status: 500 }) + return internalErrorResponse(500, { error: 'Internal server error' }) }, } +export function internalErrorResponse( + status: number, + body: unknown, + headers?: HeadersInit +): JsonErrorResponseDescriptor { + if (!Number.isInteger(status) || status < 400 || status >= 600) { + throw new Error(`Internal error responses require a 4xx or 5xx status, received ${status}`) + } + return { body, status, headers } +} + +export function extendInternalErrorPolicy( + base: InternalErrorPolicy, + project: (error: unknown) => JsonErrorResponseDescriptor | null +): InternalErrorPolicy { + return { + project(error) { + return project(error) ?? base.project(error) + }, + unhandled: base.unhandled, + } +} + +export const internalJsonPresenters = { + withSuccess(result: R) { + return { ...result, success: true as const } + }, + successFrom(key: K) { + return >(result: R) => ({ success: result[key] }) + }, +} as const + export interface InternalAuthPolicy

{ authenticate( request: NextRequest, @@ -110,13 +146,27 @@ export interface InternalAuthPolicy

{ ): Promise

} -interface InternalJsonRouteOptions< +type InternalJsonPresenter = [R] extends [ + ContractJsonResponse, +] + ? { + present?(result: NoInfer): ContractJsonResponse | Promise> + } + : { + present(result: NoInfer): ContractJsonResponse | Promise> + } + +type InternalJsonRouteOptions< C extends JsonApiRouteContract, O extends ApplicationOperation, I, R, P extends Principal, -> extends JsonRouteDefinition { +> = { + contract: C + operation: O + mapInput(input: ParsedRequest): I + useCase: OperationUseCase, I, R> auth: InternalAuthPolicy

rateLimit: InternalRateLimitPolicy errorPolicy: InternalErrorPolicy @@ -126,8 +176,15 @@ interface InternalJsonRouteOptions< principal: P params: Record }): void | Promise - onSuccess?(args: { principal: P; input: I; result: R }): void | Promise - responseHeaders?(args: { principal: P; input: I; result: R }): HeadersInit + onSuccess?(args: { principal: P; input: NoInfer; result: NoInfer }): void | Promise + responseHeaders?(args: { principal: P; input: NoInfer; result: NoInfer }): HeadersInit +} & InternalJsonPresenter + +function createJsonErrorResponse(descriptor: JsonErrorResponseDescriptor): NextResponse { + return NextResponse.json(descriptor.body, { + status: descriptor.status, + headers: descriptor.headers, + }) } export function defineInternalJsonRoute< @@ -167,8 +224,8 @@ export function defineInternalJsonRoute< try { await options.beforeParse({ request, principal, params: rawParams }) } catch (error) { - const response = options.errorPolicy.render(error) - if (response) return response + const response = options.errorPolicy.project(error) + if (response) return createJsonErrorResponse(response) throw error } } @@ -188,7 +245,7 @@ export function defineInternalJsonRoute< request, }) await options.onSuccess?.({ principal, input, result }) - const body = await options.present(result) + const body = options.present ? await options.present(result) : result const responseSchema = options.contract.response if (responseSchema.mode !== 'json') { throw new Error('Internal JSON route response mode changed after initialization') @@ -199,15 +256,20 @@ export function defineInternalJsonRoute< headers: options.responseHeaders?.({ principal, input, result }), }) } catch (error) { - const response = options.errorPolicy.render(error) - if (response) return response + const response = options.errorPolicy.project(error) + if (response) return createJsonErrorResponse(response) throw error } }, { unhandledErrorResponse: () => - options.errorPolicy.unhandled?.() ?? - NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }), + createJsonErrorResponse( + options.errorPolicy.unhandled?.() ?? + internalErrorResponse(500, { + success: false, + error: 'Internal server error', + }) + ), } ) diff --git a/apps/sim/lib/api/server/routes/types.ts b/apps/sim/lib/api/server/routes/types.ts index 304dd1aca3c..f2411298f38 100644 --- a/apps/sim/lib/api/server/routes/types.ts +++ b/apps/sim/lib/api/server/routes/types.ts @@ -30,6 +30,12 @@ export interface BinaryResponseDescriptor { headers?: HeadersInit } +export interface JsonErrorResponseDescriptor { + body: unknown + status: number + headers?: HeadersInit +} + export interface JsonRouteDefinition< C extends JsonApiRouteContract, O extends ApplicationOperation, diff --git a/apps/sim/lib/workspace-files/api/index.ts b/apps/sim/lib/workspace-files/api/index.ts index c94e1017fc8..2b23ed91382 100644 --- a/apps/sim/lib/workspace-files/api/index.ts +++ b/apps/sim/lib/workspace-files/api/index.ts @@ -1,6 +1,7 @@ +export { internalFileAnalytics } from '@/lib/workspace-files/api/internal-analytics' +export { internalFileErrorPolicies } from '@/lib/workspace-files/api/internal-error-policies' +export { internalFilePresenters } from '@/lib/workspace-files/api/internal-presenters' export { - internalFileErrorPolicy, - internalPlainFileErrorPolicy, internalSessionOrServiceAuth, v2FileErrorPolicies, } from '@/lib/workspace-files/api/route-policies' diff --git a/apps/sim/lib/workspace-files/api/internal-analytics.ts b/apps/sim/lib/workspace-files/api/internal-analytics.ts new file mode 100644 index 00000000000..840c6d5044d --- /dev/null +++ b/apps/sim/lib/workspace-files/api/internal-analytics.ts @@ -0,0 +1,151 @@ +import type { SessionPrincipal } from '@sim/auth/principal' +import { captureServerEvent } from '@/lib/posthog/server' +import type { + ArchiveWorkspaceFileItemsInput, + ArchiveWorkspaceFileItemsResult, +} from '@/lib/workspace-files/application/archive-workspace-file-items' +import type { CreateWorkspaceFileResult } from '@/lib/workspace-files/application/create-workspace-file' +import type { DeleteWorkspaceFileResult } from '@/lib/workspace-files/application/delete-workspace-file' +import type { DownloadWorkspaceFileResult } from '@/lib/workspace-files/application/download-workspace-file' +import type { + DownloadWorkspaceFileItemsInput, + DownloadWorkspaceFileItemsResult, +} from '@/lib/workspace-files/application/download-workspace-file-items' +import type { MoveWorkspaceFileItemsInput } from '@/lib/workspace-files/application/move-workspace-file-items' +import type { RenameWorkspaceFileResult } from '@/lib/workspace-files/application/rename-workspace-file' +import type { + CreateWorkspaceFileFolderInput, + DeleteWorkspaceFileFolderInput, + RestoreWorkspaceFileFolderInput, + UpdateWorkspaceFileFolderInput, +} from '@/lib/workspace-files/application/workspace-file-folders' + +interface InternalSuccessArgs { + principal: SessionPrincipal + input: I + result: R +} + +export const internalFileAnalytics = { + renamed({ principal, result }: InternalSuccessArgs) { + captureServerEvent( + principal.userId, + 'file_renamed', + { workspace_id: result.file.workspaceId }, + { groups: { workspace: result.file.workspaceId } } + ) + }, + deleted({ principal, result }: InternalSuccessArgs) { + captureServerEvent( + principal.userId, + 'file_deleted', + { workspace_id: result.workspaceId }, + { groups: { workspace: result.workspaceId } } + ) + }, + downloaded({ principal, result }: InternalSuccessArgs) { + captureServerEvent( + principal.userId, + 'file_downloaded', + { workspace_id: result.file.workspaceId, is_bulk: false, file_count: 1 }, + { groups: { workspace: result.file.workspaceId } } + ) + }, + bulkDownloaded({ + principal, + input, + result, + }: InternalSuccessArgs) { + captureServerEvent( + principal.userId, + 'file_downloaded', + { + workspace_id: input.workspaceId, + is_bulk: true, + file_count: result.filesToZip.length, + }, + { groups: { workspace: input.workspaceId } } + ) + }, + uploaded({ principal, result }: InternalSuccessArgs) { + captureServerEvent( + principal.userId, + 'file_uploaded', + { workspace_id: result.file.workspaceId, file_type: result.file.type }, + { groups: { workspace: result.file.workspaceId } } + ) + }, + bulkDeleted({ + principal, + input, + }: InternalSuccessArgs) { + captureServerEvent( + principal.userId, + 'file_bulk_deleted', + { + workspace_id: input.workspaceId, + file_count: input.fileIds?.length ?? 0, + folder_count: input.folderIds?.length ?? 0, + }, + { groups: { workspace: input.workspaceId } } + ) + }, + folderRestored({ principal, input }: InternalSuccessArgs) { + captureServerEvent( + principal.userId, + 'folder_restored', + { folder_id: input.folderId, workspace_id: input.workspaceId }, + { groups: { workspace: input.workspaceId } } + ) + }, + folderRenamed({ principal, input }: InternalSuccessArgs) { + captureServerEvent( + principal.userId, + 'folder_renamed', + { workspace_id: input.workspaceId }, + { groups: { workspace: input.workspaceId } } + ) + }, + folderDeleted({ principal, input }: InternalSuccessArgs) { + captureServerEvent( + principal.userId, + 'folder_deleted', + { workspace_id: input.workspaceId }, + { groups: { workspace: input.workspaceId } } + ) + }, + folderCreated({ principal, input }: InternalSuccessArgs) { + captureServerEvent( + principal.userId, + 'folder_created', + { workspace_id: input.workspaceId }, + { groups: { workspace: input.workspaceId } } + ) + }, + moved({ principal, input }: InternalSuccessArgs) { + if (input.fileIds && input.fileIds.length > 0) { + captureServerEvent( + principal.userId, + 'file_moved', + { + workspace_id: input.workspaceId, + file_count: input.fileIds.length, + folder_count: input.folderIds?.length ?? 0, + }, + { groups: { workspace: input.workspaceId } } + ) + } + if (input.folderIds && input.folderIds.length > 0) { + captureServerEvent( + principal.userId, + 'folder_moved', + { + workspace_id: input.workspaceId, + file_count: input.fileIds?.length ?? 0, + folder_count: input.folderIds.length, + }, + { groups: { workspace: input.workspaceId } } + ) + } + }, +} as const diff --git a/apps/sim/lib/workspace-files/api/internal-error-policies.test.ts b/apps/sim/lib/workspace-files/api/internal-error-policies.test.ts new file mode 100644 index 00000000000..641514f0ef7 --- /dev/null +++ b/apps/sim/lib/workspace-files/api/internal-error-policies.test.ts @@ -0,0 +1,35 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { internalFileErrorPolicies } from '@/lib/workspace-files/api/internal-error-policies' +import { + CompiledCheckTooLargeError, + CompiledCheckUnsupportedError, +} from '@/lib/workspace-files/application/compiled-check-workspace-file' +import { StyleExtractionUnsupportedError } from '@/lib/workspace-files/application/style-workspace-file' + +describe('internal file error policies', () => { + it('projects style and compiled-check failures without constructing responses', () => { + expect( + internalFileErrorPolicies.style.project(new StyleExtractionUnsupportedError('Unsupported')) + ).toEqual({ status: 422, body: { error: 'Unsupported' }, headers: undefined }) + expect( + internalFileErrorPolicies.compiledCheck.project(new CompiledCheckUnsupportedError()) + ).toMatchObject({ status: 422 }) + expect( + internalFileErrorPolicies.compiledCheck.project(new CompiledCheckTooLargeError()) + ).toMatchObject({ status: 413 }) + }) + + it('conceals forbidden inline resources with the legacy not-found envelope', () => { + expect( + internalFileErrorPolicies.inline.project(new OrchestrationError('forbidden', 'Forbidden')) + ).toEqual({ + status: 404, + body: { error: 'FileNotFoundError', message: 'Not found' }, + headers: undefined, + }) + }) +}) diff --git a/apps/sim/lib/workspace-files/api/internal-error-policies.ts b/apps/sim/lib/workspace-files/api/internal-error-policies.ts new file mode 100644 index 00000000000..c5814cde15a --- /dev/null +++ b/apps/sim/lib/workspace-files/api/internal-error-policies.ts @@ -0,0 +1,87 @@ +import { createLogger } from '@sim/logger' +import { + extendInternalErrorPolicy, + type InternalErrorPolicy, + internalErrorResponse, + internalOrchestrationErrorPolicy, + internalPlainOrchestrationErrorPolicy, +} from '@/lib/api/server/routes' +import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' +import { + CompiledCheckTooLargeError, + CompiledCheckUnsupportedError, +} from '@/lib/workspace-files/application/compiled-check-workspace-file' +import { StyleExtractionUnsupportedError } from '@/lib/workspace-files/application/style-workspace-file' + +const logger = createLogger('InternalWorkspaceFileErrors') + +const style = extendInternalErrorPolicy(internalPlainOrchestrationErrorPolicy, (error) => { + if (!(error instanceof StyleExtractionUnsupportedError)) return null + return internalErrorResponse(422, { error: error.message }) +}) + +const compiledCheck = extendInternalErrorPolicy(internalPlainOrchestrationErrorPolicy, (error) => { + if (error instanceof CompiledCheckUnsupportedError) { + return internalErrorResponse(422, { error: error.message }) + } + if (error instanceof CompiledCheckTooLargeError) { + return internalErrorResponse(413, { error: error.message }) + } + return null +}) + +const downloadUrl: InternalErrorPolicy = { + project(error) { + const typed = internalOrchestrationErrorPolicy.project(error) + if (typed) return typed + logger.error('Failed to generate workspace file download URL', { error }) + return internalErrorResponse(500, { + success: false, + error: 'Failed to generate download URL', + }) + }, +} + +const downloadArchive: InternalErrorPolicy = { + project(error) { + const classified = asOrchestrationError(error) + if (classified) { + return internalErrorResponse(statusForOrchestrationError(classified.code), { + error: classified.message, + }) + } + logger.error('Failed to download workspace file selection', { error }) + return internalErrorResponse(500, { error: 'Internal server error' }) + }, +} + +const inline: InternalErrorPolicy = { + project(error) { + const classified = asOrchestrationError(error) + if (classified) { + if (classified.code === 'not_found' || classified.code === 'forbidden') { + return internalErrorResponse(404, { error: 'FileNotFoundError', message: 'Not found' }) + } + return internalErrorResponse(statusForOrchestrationError(classified.code), { + error: 'Error', + message: classified.message, + }) + } + if (error instanceof Error) { + logger.error('Error serving workspace inline image', { error }) + return internalErrorResponse(500, { error: error.name, message: error.message }) + } + logger.error('Error serving workspace inline image', { error }) + return internalErrorResponse(500, { error: 'Error', message: 'Failed to serve file' }) + }, +} + +export const internalFileErrorPolicies = { + default: internalOrchestrationErrorPolicy, + plain: internalPlainOrchestrationErrorPolicy, + style, + compiledCheck, + downloadUrl, + downloadArchive, + inline, +} as const diff --git a/apps/sim/lib/workspace-files/api/internal-presenters.ts b/apps/sim/lib/workspace-files/api/internal-presenters.ts new file mode 100644 index 00000000000..7413b44e911 --- /dev/null +++ b/apps/sim/lib/workspace-files/api/internal-presenters.ts @@ -0,0 +1,29 @@ +import { workspaceFileStyleContract } from '@/lib/api/contracts/workspace-files' +import { getBaseUrl } from '@/lib/core/utils/urls' +import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' +import type { DownloadWorkspaceFileResult } from '@/lib/workspace-files/application/download-workspace-file' + +export const internalFilePresenters = { + successFile({ file }: { file: WorkspaceFileRecord }) { + return { success: true as const, file: { ...file, folderId: file.folderId ?? null } } + }, + successFiles({ files }: { files: WorkspaceFileRecord[] }) { + return { + success: true as const, + files: files.map((file) => ({ ...file, folderId: file.folderId ?? null })), + } + }, + downloadUrl({ file }: DownloadWorkspaceFileResult) { + const baseUrl = getBaseUrl() + return { + success: true as const, + downloadUrl: `${baseUrl}/api/files/serve/${encodeURIComponent(file.key)}?context=workspace`, + viewerUrl: `${baseUrl}/workspace/${file.workspaceId}/files/${file.id}`, + fileName: file.name, + expiresIn: null, + } + }, + style(result: Parameters[0]) { + return workspaceFileStyleContract.response.schema.parse(result) + }, +} as const diff --git a/apps/sim/lib/workspace-files/api/route-policies.ts b/apps/sim/lib/workspace-files/api/route-policies.ts index 8308db3a279..c287586ddd2 100644 --- a/apps/sim/lib/workspace-files/api/route-policies.ts +++ b/apps/sim/lib/workspace-files/api/route-policies.ts @@ -1,7 +1,5 @@ import { createInternalSessionOrServiceAuth, - internalOrchestrationErrorPolicy, - internalPlainOrchestrationErrorPolicy, type V2ErrorPolicy, v2OrchestrationErrorPolicy, } from '@/lib/api/server/routes' @@ -24,9 +22,6 @@ export const internalSessionOrServiceAuth = createInternalSessionOrServiceAuth( } ) -export const internalFileErrorPolicy = internalOrchestrationErrorPolicy -export const internalPlainFileErrorPolicy = internalPlainOrchestrationErrorPolicy - export const v2FileErrorPolicies = { default: v2OrchestrationErrorPolicy, concealResourceAuthorization: { From 72ae9f5caf36cc001e90afbd1340a1c71a054a2c Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 8 Aug 2026 00:46:26 -0700 Subject: [PATCH 6/9] improvement(files): centralize application authorization --- .../skills/migrate-file-operation/SKILL.md | 43 ++- .agents/skills/ship/SKILL.md | 5 +- .claude/commands/migrate-file-operation.md | 43 ++- .claude/commands/ship.md | 5 +- .cursor/commands/migrate-file-operation.md | 43 ++- .cursor/commands/ship.md | 5 +- apps/sim/app/api/tools/file/manage/route.ts | 7 +- .../v2/files/[fileId]/content/route.test.ts | 2 +- .../api/v2/files/[fileId]/content/route.ts | 7 +- .../[id]/files/[fileId]/content/route.test.ts | 2 +- .../[id]/files/[fileId]/content/route.ts | 7 +- .../[id]/files/bulk-archive/route.ts | 3 +- .../files/download/route.integration.test.ts | 25 +- .../api/workspaces/[id]/files/move/route.ts | 3 +- .../api/workspaces/[id]/files/route.test.ts | 2 +- .../app/api/workspaces/[id]/files/route.ts | 7 +- .../tools/handlers/materialize-file.test.ts | 2 +- .../tools/handlers/materialize-file.ts | 4 +- .../tools/server/files/workspace-file.ts | 7 +- .../authorized-workspace-use-case.test.ts | 306 ++++++++++++++++++ .../authorized-workspace-use-case.ts | 152 +++++++++ apps/sim/lib/core/application/index.ts | 14 +- .../application/workspace-authorization.ts | 19 +- .../core/application/workspace-operation.ts | 37 ++- .../workspace-file-folder-manager.ts | 32 +- .../archive-workspace-file-items.test.ts | 180 +++++++++-- .../archive-workspace-file-items.ts | 125 +++---- .../application/authorization.test.ts | 14 + .../application/authorization.ts | 17 +- .../authorized-workspace-file-use-case.ts | 28 ++ .../compiled-check-workspace-file.test.ts | 235 ++++++++++++++ .../compiled-check-workspace-file.ts | 48 +-- .../application/create-workspace-file.ts | 137 ++++---- .../application/delete-workspace-file.test.ts | 26 +- .../application/delete-workspace-file.ts | 77 ++--- .../download-workspace-file-items.test.ts | 82 +++-- .../download-workspace-file-items.ts | 78 ++--- .../download-workspace-file.test.ts | 104 ++++++ .../application/download-workspace-file.ts | 99 +++--- .../application/list-workspace-files.ts | 94 +++--- .../load-authorized-workspace-file.test.ts | 92 ------ .../load-authorized-workspace-file.ts | 41 --- .../move-workspace-file-items.test.ts | 116 +++++-- .../application/move-workspace-file-items.ts | 134 ++++---- .../application/operations.test.ts | 27 ++ .../workspace-files/application/operations.ts | 36 +++ .../read-workspace-file-content.test.ts | 87 +++++ .../read-workspace-file-content.ts | 54 ++-- .../read-workspace-file-metadata.test.ts | 19 +- .../read-workspace-file-metadata.ts | 35 +- .../read-workspace-file-record.test.ts | 69 ++++ .../application/read-workspace-file-record.ts | 60 ++-- .../read-workspace-inline-file.test.ts | 31 +- .../application/read-workspace-inline-file.ts | 57 ++-- .../application/rename-workspace-file.test.ts | 28 +- .../application/rename-workspace-file.ts | 67 ++-- .../resolve-workspace-file-reference.test.ts | 107 ++++++ .../resolve-workspace-file-reference.ts | 114 +++++-- .../restore-workspace-file.test.ts | 23 +- .../application/restore-workspace-file.ts | 84 ++--- .../application/share-workspace-file.ts | 177 ++++------ .../update-workspace-file-content.ts | 160 ++++----- .../update-workspace-file-dimensions.test.ts | 18 +- .../update-workspace-file-dimensions.ts | 36 +-- .../application/workspace-file-context.ts | 41 +++ .../workspace-file-folders.test.ts | 101 +++++- .../application/workspace-file-folders.ts | 238 ++++++-------- .../write-workspace-file-by-path.ts | 19 +- 68 files changed, 2811 insertions(+), 1386 deletions(-) create mode 100644 apps/sim/lib/core/application/authorized-workspace-use-case.test.ts create mode 100644 apps/sim/lib/core/application/authorized-workspace-use-case.ts create mode 100644 apps/sim/lib/workspace-files/application/authorized-workspace-file-use-case.ts create mode 100644 apps/sim/lib/workspace-files/application/compiled-check-workspace-file.test.ts create mode 100644 apps/sim/lib/workspace-files/application/download-workspace-file.test.ts delete mode 100644 apps/sim/lib/workspace-files/application/load-authorized-workspace-file.test.ts delete mode 100644 apps/sim/lib/workspace-files/application/load-authorized-workspace-file.ts create mode 100644 apps/sim/lib/workspace-files/application/read-workspace-file-content.test.ts create mode 100644 apps/sim/lib/workspace-files/application/read-workspace-file-record.test.ts create mode 100644 apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.test.ts create mode 100644 apps/sim/lib/workspace-files/application/workspace-file-context.ts diff --git a/.agents/skills/migrate-file-operation/SKILL.md b/.agents/skills/migrate-file-operation/SKILL.md index 57cc7df47a2..0ee72a1b53a 100644 --- a/.agents/skills/migrate-file-operation/SKILL.md +++ b/.agents/skills/migrate-file-operation/SKILL.md @@ -77,16 +77,19 @@ Do not add API-key rechecks, row locks, or transaction-wide authorization solely ## Define the operation once -Add exactly one entry to `fileOperations` with a stable semantic ID, minimum workspace role, and explicit workspace-key policy. +Add exactly one entry to `fileOperations` with a stable semantic ID, minimum workspace role, explicit workspace-key policy, and accepted principal kinds. ```ts operationName: defineWorkspaceOperation({ id: 'files.operation_name', minimumRole: 'read' satisfies PermissionType, workspaceApiKey: 'allow', + principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], }) ``` +Do not accept every principal merely because a use case is shared. An internal-only operation can declare `['session']`; a shared internal/Copilot operation can declare `['session', 'delegated']`. The definition fails immediately when `principalKinds` and the workspace-key policy disagree. The authorized-use-case wrapper rejects disallowed kinds before canonical loading and narrows the business callback's principal type. + Choose policy from behavior, not from the calling surface: - Reads normally require `read`. @@ -98,25 +101,35 @@ The route declaration and use case must expose the same literal operation. Let r ## Implement the application use case -Expose an object with `operation` and `execute` matching `OperationUseCase`. +Define the use case with `defineAuthorizedWorkspaceFileUseCase`, the file-domain binding over the resource-agnostic application foundation. ```ts -export const operationWorkspaceFile = { +export const operationWorkspaceFile = defineAuthorizedWorkspaceFileUseCase({ operation: fileOperations.operationName, + resolveContext: ({ input }: { input: OperationWorkspaceFileInput }) => + loadCanonicalOperationContext(input), execute: executeOperationWorkspaceFile, -} as const + projectAudit: ({ result }) => ({ + action: AuditAction.FILE_UPDATED, + resourceType: AuditResourceType.FILE, + resourceId: result.file.id, + resourceName: result.file.name, + }), + afterSuccess: ({ context }) => notifyWorkspaceFilesChanged(context.workspaceId), +}) ``` -Inside `execute`: +The wrapper owns this lifecycle: + +1. Reject principal kinds not declared by the operation. +2. Resolve the canonical context and authorize it with file delegation scope. +3. Execute the business callback. +4. Resolve audit attribution and record projected semantic entries. +5. Await declared post-success domain effects. + +`resolveContext` loads canonical resources, derives workspace policy state, and conceals asserted-workspace mismatches. It does not authorize. `execute` receives an already-authorized, operation-narrowed principal and calls manager/repository primitives. `projectAudit` derives zero, one, or several entries from the authoritative mutation result; return `[]` for a no-op and never infer affected resources from requested IDs. The wrapper injects workspace, operation, actor, and request metadata. `afterSuccess` owns shared notifications. -1. Load the active file by `fileId` without trusting a caller-supplied workspace. -2. Derive the canonical workspace and policy context from that record. -3. Treat an asserted-workspace mismatch like absence when the surface requires concealment. -4. Call the file-domain `authorizeWorkspaceFileAccess` wrapper with the canonical context and shared operation. The wrapper supplies file delegation scope to the resource-agnostic `authorizeWorkspaceOperation` primitive. -5. Resolve `PrincipalAttribution` only when a required legacy user field or audit actor needs it. -6. Call a workspace-predicated, throwing manager primitive. -7. Record semantic audit once and send shared realtime/domain notifications once. -8. Return a surface-neutral domain result. +Do not call `authorizeWorkspaceFileAccess`, `authorizeWorkspaceFileOperation`, `resolvePrincipalAuditAttribution`, or `recordAudit` from an ordinary migrated use-case body. Resolve `PrincipalAttribution` only when a required legacy user field needs it. Upload lifecycle code and other explicitly deferred special cases may retain their dedicated flow until separately migrated. Propagate infrastructure failures. Use existing typed orchestration errors for expected not-found, forbidden, validation, and conflict outcomes. Do not turn database failures into not-found or authorization failures, and never add fallback behavior. @@ -131,7 +144,7 @@ Inspect legacy orchestration before reusing it. If it already records audit, sen - Use the current billing owner only where a legacy required user ID or billing attribution requires one. Never use that owner for authorization, rate-limit identity, delegated identity, or human product analytics. - Preserve `PrincipalActor` metadata in audit records so a workspace key is not represented as a human actor. -Audit is shared semantic behavior and belongs in the application layer. Product analytics are surface behavior: preserve an existing internal `captureServerEvent` with the internal route's optional `onSuccess`; do not move it into the shared use case or manufacture a human distinct ID for workspace keys or Copilot. +Audit is shared semantic behavior declared through `projectAudit`; the wrapper records it after successful execution using the real `PrincipalActor`. Product analytics are surface behavior: preserve an existing internal `captureServerEvent` with the internal route's optional `onSuccess`; do not move it into the shared use case or manufacture a human distinct ID for workspace keys or Copilot. ## Adapt each surface independently @@ -183,7 +196,7 @@ Add focused tests proportional to the migrated surfaces: - Internal route: authentication before parsing, exact response contract, typed errors, and any preserved `onSuccess` event only after success. - V2 route: personal and workspace keys, rollout/rate behavior, concealment, exact v2 envelope, and rate headers after charging. - Copilot: trusted delegation, aliases/resume paths, permission re-check, safe errors, and unchanged tool response shape. -- Side effects: audit and shared notifications are invoked once per successful use-case execution and never on rejected operations. Do not claim exactly-once delivery across independent client retries without durable idempotency/outbox support. +- Side effects: audit is projected from authoritative results, notifications follow audit projection, and neither occurs for rejected operations or zero-row results. Do not claim exactly-once delivery across independent client retries without durable idempotency/outbox support. Run at minimum: diff --git a/.agents/skills/ship/SKILL.md b/.agents/skills/ship/SKILL.md index 8f144a04aa6..946d235643a 100644 --- a/.agents/skills/ship/SKILL.md +++ b/.agents/skills/ship/SKILL.md @@ -66,11 +66,12 @@ When the user runs `/ship`: exit 1 } rm -f /tmp/ship-audit-results - for s in check:boundaries check:api-validation:strict check:desktop-bridge check:desktop-ipc \ + for s in check:boundaries check:api-validation:strict check:openapi \ + check:desktop-bridge check:desktop-ipc \ check:utils check:zustand-v5 \ check:react-query check:client-boundary check:bare-icons check:icon-paths \ check:realtime-prune check:tool-registry-boundary check:tool-request-boundary \ - tool-metadata:check \ + check:sql-date-binding tool-metadata:check \ integration-catalog:check skills:check agent-stream-docs:check; do ( bun run "$s" >"/tmp/ship-audit-${s//:/-}.log" 2>&1; echo "$? $s" >>/tmp/ship-audit-results ) & done diff --git a/.claude/commands/migrate-file-operation.md b/.claude/commands/migrate-file-operation.md index 8e16cf6286d..2a42d5457db 100644 --- a/.claude/commands/migrate-file-operation.md +++ b/.claude/commands/migrate-file-operation.md @@ -76,16 +76,19 @@ Do not add API-key rechecks, row locks, or transaction-wide authorization solely ## Define the operation once -Add exactly one entry to `fileOperations` with a stable semantic ID, minimum workspace role, and explicit workspace-key policy. +Add exactly one entry to `fileOperations` with a stable semantic ID, minimum workspace role, explicit workspace-key policy, and accepted principal kinds. ```ts operationName: defineWorkspaceOperation({ id: 'files.operation_name', minimumRole: 'read' satisfies PermissionType, workspaceApiKey: 'allow', + principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], }) ``` +Do not accept every principal merely because a use case is shared. An internal-only operation can declare `['session']`; a shared internal/Copilot operation can declare `['session', 'delegated']`. The definition fails immediately when `principalKinds` and the workspace-key policy disagree. The authorized-use-case wrapper rejects disallowed kinds before canonical loading and narrows the business callback's principal type. + Choose policy from behavior, not from the calling surface: - Reads normally require `read`. @@ -97,25 +100,35 @@ The route declaration and use case must expose the same literal operation. Let r ## Implement the application use case -Expose an object with `operation` and `execute` matching `OperationUseCase`. +Define the use case with `defineAuthorizedWorkspaceFileUseCase`, the file-domain binding over the resource-agnostic application foundation. ```ts -export const operationWorkspaceFile = { +export const operationWorkspaceFile = defineAuthorizedWorkspaceFileUseCase({ operation: fileOperations.operationName, + resolveContext: ({ input }: { input: OperationWorkspaceFileInput }) => + loadCanonicalOperationContext(input), execute: executeOperationWorkspaceFile, -} as const + projectAudit: ({ result }) => ({ + action: AuditAction.FILE_UPDATED, + resourceType: AuditResourceType.FILE, + resourceId: result.file.id, + resourceName: result.file.name, + }), + afterSuccess: ({ context }) => notifyWorkspaceFilesChanged(context.workspaceId), +}) ``` -Inside `execute`: +The wrapper owns this lifecycle: + +1. Reject principal kinds not declared by the operation. +2. Resolve the canonical context and authorize it with file delegation scope. +3. Execute the business callback. +4. Resolve audit attribution and record projected semantic entries. +5. Await declared post-success domain effects. + +`resolveContext` loads canonical resources, derives workspace policy state, and conceals asserted-workspace mismatches. It does not authorize. `execute` receives an already-authorized, operation-narrowed principal and calls manager/repository primitives. `projectAudit` derives zero, one, or several entries from the authoritative mutation result; return `[]` for a no-op and never infer affected resources from requested IDs. The wrapper injects workspace, operation, actor, and request metadata. `afterSuccess` owns shared notifications. -1. Load the active file by `fileId` without trusting a caller-supplied workspace. -2. Derive the canonical workspace and policy context from that record. -3. Treat an asserted-workspace mismatch like absence when the surface requires concealment. -4. Call the file-domain `authorizeWorkspaceFileAccess` wrapper with the canonical context and shared operation. The wrapper supplies file delegation scope to the resource-agnostic `authorizeWorkspaceOperation` primitive. -5. Resolve `PrincipalAttribution` only when a required legacy user field or audit actor needs it. -6. Call a workspace-predicated, throwing manager primitive. -7. Record semantic audit once and send shared realtime/domain notifications once. -8. Return a surface-neutral domain result. +Do not call `authorizeWorkspaceFileAccess`, `authorizeWorkspaceFileOperation`, `resolvePrincipalAuditAttribution`, or `recordAudit` from an ordinary migrated use-case body. Resolve `PrincipalAttribution` only when a required legacy user field needs it. Upload lifecycle code and other explicitly deferred special cases may retain their dedicated flow until separately migrated. Propagate infrastructure failures. Use existing typed orchestration errors for expected not-found, forbidden, validation, and conflict outcomes. Do not turn database failures into not-found or authorization failures, and never add fallback behavior. @@ -130,7 +143,7 @@ Inspect legacy orchestration before reusing it. If it already records audit, sen - Use the current billing owner only where a legacy required user ID or billing attribution requires one. Never use that owner for authorization, rate-limit identity, delegated identity, or human product analytics. - Preserve `PrincipalActor` metadata in audit records so a workspace key is not represented as a human actor. -Audit is shared semantic behavior and belongs in the application layer. Product analytics are surface behavior: preserve an existing internal `captureServerEvent` with the internal route's optional `onSuccess`; do not move it into the shared use case or manufacture a human distinct ID for workspace keys or Copilot. +Audit is shared semantic behavior declared through `projectAudit`; the wrapper records it after successful execution using the real `PrincipalActor`. Product analytics are surface behavior: preserve an existing internal `captureServerEvent` with the internal route's optional `onSuccess`; do not move it into the shared use case or manufacture a human distinct ID for workspace keys or Copilot. ## Adapt each surface independently @@ -182,7 +195,7 @@ Add focused tests proportional to the migrated surfaces: - Internal route: authentication before parsing, exact response contract, typed errors, and any preserved `onSuccess` event only after success. - V2 route: personal and workspace keys, rollout/rate behavior, concealment, exact v2 envelope, and rate headers after charging. - Copilot: trusted delegation, aliases/resume paths, permission re-check, safe errors, and unchanged tool response shape. -- Side effects: audit and shared notifications are invoked once per successful use-case execution and never on rejected operations. Do not claim exactly-once delivery across independent client retries without durable idempotency/outbox support. +- Side effects: audit is projected from authoritative results, notifications follow audit projection, and neither occurs for rejected operations or zero-row results. Do not claim exactly-once delivery across independent client retries without durable idempotency/outbox support. Run at minimum: diff --git a/.claude/commands/ship.md b/.claude/commands/ship.md index 6b673b18f8e..c4bb336288b 100644 --- a/.claude/commands/ship.md +++ b/.claude/commands/ship.md @@ -65,11 +65,12 @@ When the user runs `/ship`: exit 1 } rm -f /tmp/ship-audit-results - for s in check:boundaries check:api-validation:strict check:desktop-bridge check:desktop-ipc \ + for s in check:boundaries check:api-validation:strict check:openapi \ + check:desktop-bridge check:desktop-ipc \ check:utils check:zustand-v5 \ check:react-query check:client-boundary check:bare-icons check:icon-paths \ check:realtime-prune check:tool-registry-boundary check:tool-request-boundary \ - tool-metadata:check \ + check:sql-date-binding tool-metadata:check \ integration-catalog:check skills:check agent-stream-docs:check; do ( bun run "$s" >"/tmp/ship-audit-${s//:/-}.log" 2>&1; echo "$? $s" >>/tmp/ship-audit-results ) & done diff --git a/.cursor/commands/migrate-file-operation.md b/.cursor/commands/migrate-file-operation.md index 7f2f51fa5d6..a855777a635 100644 --- a/.cursor/commands/migrate-file-operation.md +++ b/.cursor/commands/migrate-file-operation.md @@ -72,16 +72,19 @@ Do not add API-key rechecks, row locks, or transaction-wide authorization solely ## Define the operation once -Add exactly one entry to `fileOperations` with a stable semantic ID, minimum workspace role, and explicit workspace-key policy. +Add exactly one entry to `fileOperations` with a stable semantic ID, minimum workspace role, explicit workspace-key policy, and accepted principal kinds. ```ts operationName: defineWorkspaceOperation({ id: 'files.operation_name', minimumRole: 'read' satisfies PermissionType, workspaceApiKey: 'allow', + principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], }) ``` +Do not accept every principal merely because a use case is shared. An internal-only operation can declare `['session']`; a shared internal/Copilot operation can declare `['session', 'delegated']`. The definition fails immediately when `principalKinds` and the workspace-key policy disagree. The authorized-use-case wrapper rejects disallowed kinds before canonical loading and narrows the business callback's principal type. + Choose policy from behavior, not from the calling surface: - Reads normally require `read`. @@ -93,25 +96,35 @@ The route declaration and use case must expose the same literal operation. Let r ## Implement the application use case -Expose an object with `operation` and `execute` matching `OperationUseCase`. +Define the use case with `defineAuthorizedWorkspaceFileUseCase`, the file-domain binding over the resource-agnostic application foundation. ```ts -export const operationWorkspaceFile = { +export const operationWorkspaceFile = defineAuthorizedWorkspaceFileUseCase({ operation: fileOperations.operationName, + resolveContext: ({ input }: { input: OperationWorkspaceFileInput }) => + loadCanonicalOperationContext(input), execute: executeOperationWorkspaceFile, -} as const + projectAudit: ({ result }) => ({ + action: AuditAction.FILE_UPDATED, + resourceType: AuditResourceType.FILE, + resourceId: result.file.id, + resourceName: result.file.name, + }), + afterSuccess: ({ context }) => notifyWorkspaceFilesChanged(context.workspaceId), +}) ``` -Inside `execute`: +The wrapper owns this lifecycle: + +1. Reject principal kinds not declared by the operation. +2. Resolve the canonical context and authorize it with file delegation scope. +3. Execute the business callback. +4. Resolve audit attribution and record projected semantic entries. +5. Await declared post-success domain effects. + +`resolveContext` loads canonical resources, derives workspace policy state, and conceals asserted-workspace mismatches. It does not authorize. `execute` receives an already-authorized, operation-narrowed principal and calls manager/repository primitives. `projectAudit` derives zero, one, or several entries from the authoritative mutation result; return `[]` for a no-op and never infer affected resources from requested IDs. The wrapper injects workspace, operation, actor, and request metadata. `afterSuccess` owns shared notifications. -1. Load the active file by `fileId` without trusting a caller-supplied workspace. -2. Derive the canonical workspace and policy context from that record. -3. Treat an asserted-workspace mismatch like absence when the surface requires concealment. -4. Call the file-domain `authorizeWorkspaceFileAccess` wrapper with the canonical context and shared operation. The wrapper supplies file delegation scope to the resource-agnostic `authorizeWorkspaceOperation` primitive. -5. Resolve `PrincipalAttribution` only when a required legacy user field or audit actor needs it. -6. Call a workspace-predicated, throwing manager primitive. -7. Record semantic audit once and send shared realtime/domain notifications once. -8. Return a surface-neutral domain result. +Do not call `authorizeWorkspaceFileAccess`, `authorizeWorkspaceFileOperation`, `resolvePrincipalAuditAttribution`, or `recordAudit` from an ordinary migrated use-case body. Resolve `PrincipalAttribution` only when a required legacy user field needs it. Upload lifecycle code and other explicitly deferred special cases may retain their dedicated flow until separately migrated. Propagate infrastructure failures. Use existing typed orchestration errors for expected not-found, forbidden, validation, and conflict outcomes. Do not turn database failures into not-found or authorization failures, and never add fallback behavior. @@ -126,7 +139,7 @@ Inspect legacy orchestration before reusing it. If it already records audit, sen - Use the current billing owner only where a legacy required user ID or billing attribution requires one. Never use that owner for authorization, rate-limit identity, delegated identity, or human product analytics. - Preserve `PrincipalActor` metadata in audit records so a workspace key is not represented as a human actor. -Audit is shared semantic behavior and belongs in the application layer. Product analytics are surface behavior: preserve an existing internal `captureServerEvent` with the internal route's optional `onSuccess`; do not move it into the shared use case or manufacture a human distinct ID for workspace keys or Copilot. +Audit is shared semantic behavior declared through `projectAudit`; the wrapper records it after successful execution using the real `PrincipalActor`. Product analytics are surface behavior: preserve an existing internal `captureServerEvent` with the internal route's optional `onSuccess`; do not move it into the shared use case or manufacture a human distinct ID for workspace keys or Copilot. ## Adapt each surface independently @@ -178,7 +191,7 @@ Add focused tests proportional to the migrated surfaces: - Internal route: authentication before parsing, exact response contract, typed errors, and any preserved `onSuccess` event only after success. - V2 route: personal and workspace keys, rollout/rate behavior, concealment, exact v2 envelope, and rate headers after charging. - Copilot: trusted delegation, aliases/resume paths, permission re-check, safe errors, and unchanged tool response shape. -- Side effects: audit and shared notifications are invoked once per successful use-case execution and never on rejected operations. Do not claim exactly-once delivery across independent client retries without durable idempotency/outbox support. +- Side effects: audit is projected from authoritative results, notifications follow audit projection, and neither occurs for rejected operations or zero-row results. Do not claim exactly-once delivery across independent client retries without durable idempotency/outbox support. Run at minimum: diff --git a/.cursor/commands/ship.md b/.cursor/commands/ship.md index 77ec67d04a8..c5d92d97ae4 100644 --- a/.cursor/commands/ship.md +++ b/.cursor/commands/ship.md @@ -60,11 +60,12 @@ When the user runs `/ship`: exit 1 } rm -f /tmp/ship-audit-results - for s in check:boundaries check:api-validation:strict check:desktop-bridge check:desktop-ipc \ + for s in check:boundaries check:api-validation:strict check:openapi \ + check:desktop-bridge check:desktop-ipc \ check:utils check:zustand-v5 \ check:react-query check:client-boundary check:bare-icons check:icon-paths \ check:realtime-prune check:tool-registry-boundary check:tool-request-boundary \ - tool-metadata:check \ + check:sql-date-binding tool-metadata:check \ integration-catalog:check skills:check agent-stream-docs:check; do ( bun run "$s" >"/tmp/ship-audit-${s//:/-}.log" 2>&1; echo "$? $s" >>/tmp/ship-audit-results ) & done diff --git a/apps/sim/app/api/tools/file/manage/route.ts b/apps/sim/app/api/tools/file/manage/route.ts index 0cd7b7e3868..e3595aa6fec 100644 --- a/apps/sim/app/api/tools/file/manage/route.ts +++ b/apps/sim/app/api/tools/file/manage/route.ts @@ -49,6 +49,7 @@ import { import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response' import { buildZipEntryPaths } from '@/lib/uploads/zip-entry-path' import { + admitCreateWorkspaceFile, createWorkspaceFile, createWorkspaceFileFromBuffer, } from '@/lib/workspace-files/application/create-workspace-file' @@ -698,7 +699,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) } const { folderSegments, leafName } = splitWorkspaceFilePath(fileName) - await createWorkspaceFile.admit(principal, workspaceId) + await admitCreateWorkspaceFile(principal, workspaceId) const folderId = folderSegments.length === 0 ? null @@ -932,7 +933,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (selectedFileIds.length === 0 && selectedInputFiles.length === 0) { return NextResponse.json({ success: false, error: 'File is required' }, { status: 400 }) } - await createWorkspaceFile.admit(principal, workspaceId) + await admitCreateWorkspaceFile(principal, workspaceId) const workspaceFiles = [] as Array< NonNullable>> @@ -1092,7 +1093,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { { status: 400 } ) } - await createWorkspaceFile.admit(principal, workspaceId) + await admitCreateWorkspaceFile(principal, workspaceId) const workspaceFiles = [] as Array< NonNullable>> diff --git a/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts index a8fc4104082..989a7ef434b 100644 --- a/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts +++ b/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts @@ -18,9 +18,9 @@ vi.mock('@/lib/workspace-files/orchestration', () => ({ })) vi.mock('@/lib/workspace-files/application/update-workspace-file-content', () => ({ + admitUpdateWorkspaceFileContent: mocks.admit, updateWorkspaceFileContent: { operation: { id: 'files.update_content', minimumRole: 'write', workspaceApiKey: 'allow' }, - admit: mocks.admit, execute: mocks.updateContent, }, })) diff --git a/apps/sim/app/api/v2/files/[fileId]/content/route.ts b/apps/sim/app/api/v2/files/[fileId]/content/route.ts index bcb3cc2609c..c85c5251366 100644 --- a/apps/sim/app/api/v2/files/[fileId]/content/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/content/route.ts @@ -2,7 +2,10 @@ import { v2UpdateFileContentContract } from '@/lib/api/contracts/v2/files' import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' import { v2FileErrorPolicies } from '@/lib/workspace-files/api' import { fileOperations } from '@/lib/workspace-files/application/operations' -import { updateWorkspaceFileContent } from '@/lib/workspace-files/application/update-workspace-file-content' +import { + admitUpdateWorkspaceFileContent, + updateWorkspaceFileContent, +} from '@/lib/workspace-files/application/update-workspace-file-content' import { MAX_WORKSPACE_FILE_INLINE_BODY_BYTES } from '@/lib/workspace-files/orchestration' import { toV2File } from '@/app/api/v2/files/utils' import { v2Error } from '@/app/api/v2/lib/response' @@ -24,7 +27,7 @@ export const PUT = defineV2JsonRoute({ }, beforeParse: async ({ principal, params }) => { if (typeof params.fileId === 'string') { - await updateWorkspaceFileContent.admit(principal, params.fileId) + await admitUpdateWorkspaceFileContent(principal, params.fileId) } }, mapInput: ({ params, body }) => ({ diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.test.ts index 961dfd91db5..14104079f62 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.test.ts @@ -12,9 +12,9 @@ vi.mock('@/lib/workspace-files/orchestration', () => ({ })) vi.mock('@/lib/workspace-files/application/update-workspace-file-content', () => ({ + admitUpdateWorkspaceFileContent: mocks.admit, updateWorkspaceFileContent: { operation: { id: 'files.update_content', minimumRole: 'write', workspaceApiKey: 'allow' }, - admit: mocks.admit, execute: mocks.updateContent, }, })) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts index 71f6b24f066..c588f4e04f5 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.ts @@ -6,7 +6,10 @@ import { } from '@/lib/api/server/routes' import { internalFileErrorPolicies, internalFilePresenters } from '@/lib/workspace-files/api' import { fileOperations } from '@/lib/workspace-files/application/operations' -import { updateWorkspaceFileContent } from '@/lib/workspace-files/application/update-workspace-file-content' +import { + admitUpdateWorkspaceFileContent, + updateWorkspaceFileContent, +} from '@/lib/workspace-files/application/update-workspace-file-content' import { MAX_WORKSPACE_FILE_INLINE_BODY_BYTES } from '@/lib/workspace-files/orchestration' export const dynamic = 'force-dynamic' @@ -23,7 +26,7 @@ export const PUT = defineInternalJsonRoute({ parseOptions: { maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES }, beforeParse: async ({ principal, params }) => { if (typeof params.fileId === 'string') { - await updateWorkspaceFileContent.admit(principal, params.fileId) + await admitUpdateWorkspaceFileContent(principal, params.fileId) } }, mapInput: ({ params, body }) => ({ diff --git a/apps/sim/app/api/workspaces/[id]/files/bulk-archive/route.ts b/apps/sim/app/api/workspaces/[id]/files/bulk-archive/route.ts index 09cd4f2cb53..b4d529d1945 100644 --- a/apps/sim/app/api/workspaces/[id]/files/bulk-archive/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/bulk-archive/route.ts @@ -1,7 +1,6 @@ import { bulkArchiveWorkspaceFileItemsContract } from '@/lib/api/contracts/workspace-file-folders' import { defineInternalJsonRoute, - internalJsonPresenters, internalRateLimits, internalSessionAuth, } from '@/lib/api/server/routes' @@ -24,5 +23,5 @@ export const POST = defineInternalJsonRoute({ }), useCase: archiveWorkspaceFileItemsOperation, onSuccess: internalFileAnalytics.bulkDeleted, - present: internalJsonPresenters.withSuccess, + present: ({ deletedItems }) => ({ success: true, deletedItems }), }) diff --git a/apps/sim/app/api/workspaces/[id]/files/download/route.integration.test.ts b/apps/sim/app/api/workspaces/[id]/files/download/route.integration.test.ts index fb2c3a57171..2636bde5ac1 100644 --- a/apps/sim/app/api/workspaces/[id]/files/download/route.integration.test.ts +++ b/apps/sim/app/api/workspaces/[id]/files/download/route.integration.test.ts @@ -20,13 +20,15 @@ const run = promisify(execFile) const { mockGetSession, - mockAuthorizeWorkspaceFileOperation, + mockLoadWorkspaceFileOperationContext, + mockResolvePermission, mockListWorkspaceFiles, mockListFolders, mockDownloadFileStream, } = vi.hoisted(() => ({ mockGetSession: vi.fn(), - mockAuthorizeWorkspaceFileOperation: vi.fn(), + mockLoadWorkspaceFileOperationContext: vi.fn(), + mockResolvePermission: vi.fn(), mockListWorkspaceFiles: vi.fn(), mockListFolders: vi.fn(), mockDownloadFileStream: vi.fn(), @@ -36,8 +38,14 @@ vi.mock('@/lib/auth', () => ({ auth: { api: { getSession: vi.fn() } }, getSession: mockGetSession, })) -vi.mock('@/lib/workspace-files/application/workspace-operation-context', () => ({ - authorizeWorkspaceFileOperation: mockAuthorizeWorkspaceFileOperation, +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: mockResolvePermission, })) vi.mock('@/lib/uploads/contexts/workspace', () => ({ listWorkspaceFiles: mockListWorkspaceFiles, @@ -45,6 +53,7 @@ vi.mock('@/lib/uploads/contexts/workspace', () => ({ buildWorkspaceFileFolderPathMap: (folders: Array<{ id: string; name: string }>) => new Map(folders.map((folder) => [folder.id, folder.name])), fetchServableWorkspaceFileBuffer: vi.fn(), + loadWorkspaceFileOperationContext: mockLoadWorkspaceFileOperationContext, })) vi.mock('@/lib/uploads/core/storage-service', () => ({ downloadFileStream: mockDownloadFileStream, @@ -89,8 +98,12 @@ describe('workspace files download — real archive', () => { beforeEach(() => { vi.clearAllMocks() mockGetSession.mockResolvedValue({ user: { id: 'user-1' }, session: { id: 'session-1' } }) - mockAuthorizeWorkspaceFileOperation.mockResolvedValue({ - context: { workspaceId: WORKSPACE_ID }, + mockResolvePermission.mockResolvedValue('admin') + mockLoadWorkspaceFileOperationContext.mockResolvedValue({ + workspaceId: WORKSPACE_ID, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'user-1', }) mockListFolders.mockResolvedValue([{ id: 'folder-1', name: 'Reports', parentId: null }]) // A real fs stream, not a single pre-made buffer. diff --git a/apps/sim/app/api/workspaces/[id]/files/move/route.ts b/apps/sim/app/api/workspaces/[id]/files/move/route.ts index 54d2c3adc64..bc219539eaf 100644 --- a/apps/sim/app/api/workspaces/[id]/files/move/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/move/route.ts @@ -1,7 +1,6 @@ import { moveWorkspaceFileItemsContract } from '@/lib/api/contracts/workspace-file-folders' import { defineInternalJsonRoute, - internalJsonPresenters, internalRateLimits, internalSessionAuth, } from '@/lib/api/server/routes' @@ -23,5 +22,5 @@ export const POST = defineInternalJsonRoute({ }), useCase: moveWorkspaceFileItemsOperation, onSuccess: internalFileAnalytics.moved, - present: internalJsonPresenters.withSuccess, + present: ({ movedItems }) => ({ success: true, movedItems }), }) diff --git a/apps/sim/app/api/workspaces/[id]/files/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/route.test.ts index 076b4ceed9b..5add87b4660 100644 --- a/apps/sim/app/api/workspaces/[id]/files/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/files/route.test.ts @@ -13,9 +13,9 @@ const mocks = vi.hoisted(() => ({ })) vi.mock('@/lib/workspace-files/application/create-workspace-file', () => ({ + admitCreateWorkspaceFile: mocks.admitCreate, createWorkspaceFile: { operation: { id: 'files.create', minimumRole: 'write', workspaceApiKey: 'allow' }, - admit: mocks.admitCreate, execute: mocks.createFile, }, })) diff --git a/apps/sim/app/api/workspaces/[id]/files/route.ts b/apps/sim/app/api/workspaces/[id]/files/route.ts index 267c3cfbf7a..2d29ddfad10 100644 --- a/apps/sim/app/api/workspaces/[id]/files/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/route.ts @@ -13,7 +13,10 @@ import { internalFileErrorPolicies, internalFilePresenters, } from '@/lib/workspace-files/api' -import { createWorkspaceFile } from '@/lib/workspace-files/application/create-workspace-file' +import { + admitCreateWorkspaceFile, + createWorkspaceFile, +} from '@/lib/workspace-files/application/create-workspace-file' import { listAllWorkspaceFiles } from '@/lib/workspace-files/application/list-workspace-files' import { fileOperations } from '@/lib/workspace-files/application/operations' import { MAX_WORKSPACE_FILE_INLINE_BODY_BYTES } from '@/lib/workspace-files/orchestration' @@ -41,7 +44,7 @@ export const POST = defineInternalJsonRoute({ errorPolicy: internalFileErrorPolicies.default, parseOptions: { maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES }, beforeParse: async ({ principal, params }) => { - if (typeof params.id === 'string') await createWorkspaceFile.admit(principal, params.id) + if (typeof params.id === 'string') await admitCreateWorkspaceFile(principal, params.id) }, mapInput: ({ params, body }) => ({ workspaceId: params.id, diff --git a/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts b/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts index f0cd59c3ce2..69848850802 100644 --- a/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/materialize-file.test.ts @@ -61,7 +61,7 @@ vi.mock('@/lib/workspace-files/application/read-workspace-file-metadata', () => })) vi.mock('@/lib/workspace-files/application/create-workspace-file', () => ({ - createWorkspaceFile: { admit: mockAdmitCreateWorkspaceFile }, + admitCreateWorkspaceFile: mockAdmitCreateWorkspaceFile, })) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-secret-provenance', () => ({ diff --git a/apps/sim/lib/copilot/tools/handlers/materialize-file.ts b/apps/sim/lib/copilot/tools/handlers/materialize-file.ts index c07b79e2f64..c188bc90c28 100644 --- a/apps/sim/lib/copilot/tools/handlers/materialize-file.ts +++ b/apps/sim/lib/copilot/tools/handlers/materialize-file.ts @@ -40,7 +40,7 @@ import { isArchiveFileName } from '@/lib/uploads/utils/file-utils' import { parseWorkflowJson } from '@/lib/workflows/operations/import-export' import { saveWorkflowToNormalizedTables } from '@/lib/workflows/persistence/utils' import { deduplicateWorkflowName } from '@/lib/workflows/utils' -import { createWorkspaceFile } from '@/lib/workspace-files/application/create-workspace-file' +import { admitCreateWorkspaceFile } from '@/lib/workspace-files/application/create-workspace-file' import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' import { extractWorkflowMetadata } from '@/app/api/v1/admin/types' @@ -570,7 +570,7 @@ export async function executeMaterializeFile( if (operation === 'import') { await ensureWorkspaceAccess(context.workspaceId, context.userId, 'write') } else { - await createWorkspaceFile.admit(principal, context.workspaceId) + await admitCreateWorkspaceFile(principal, context.workspaceId) } } catch (error) { return { success: false, error: getErrorMessage(error, 'Workspace write access required') } diff --git a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts index 3e5544e492d..97e46aab43d 100644 --- a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts @@ -16,7 +16,10 @@ import { isDocSandboxEnabled } from '@/lib/core/config/env-flags' import { asOrchestrationError } from '@/lib/core/orchestration/types' import { runSandboxTask } from '@/lib/execution/sandbox/run-task' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { createWorkspaceFile } from '@/lib/workspace-files/application/create-workspace-file' +import { + admitCreateWorkspaceFile, + createWorkspaceFile, +} from '@/lib/workspace-files/application/create-workspace-file' import { deleteWorkspaceFileOperation } from '@/lib/workspace-files/application/delete-workspace-file' import { fileOperations } from '@/lib/workspace-files/application/operations' import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' @@ -342,7 +345,7 @@ export const workspaceFileServerTool: BaseServerTool ({ + events: [] as string[], + recordAudit: vi.fn(() => mocks.events.push('audit')), + resolvePermission: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { FILE_UPDATED: 'file.updated' }, + AuditResourceType: { FILE: 'file' }, + recordAudit: mocks.recordAudit, +})) + +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, +})) + +import { AuditAction, AuditResourceType } from '@sim/audit' +import { defineAuthorizedWorkspaceUseCase, defineWorkspaceOperation } from '@/lib/core/application' +import type { OrchestrationError } from '@/lib/core/orchestration/types' + +const operation = defineWorkspaceOperation({ + id: 'test.rename', + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['session'], +}) + +const delegatedOperation = defineWorkspaceOperation({ + id: 'test.delegated_read', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['delegated'], +}) + +const workspaceKeyOperation = defineWorkspaceOperation({ + id: 'test.workspace_key_read', + minimumRole: 'read', + workspaceApiKey: 'allow', + principalKinds: ['workspace_api_key'], +}) + +interface TestInput { + resourceId: string +} + +interface TestContext { + workspaceId: string + workspaceOrganizationId: string | null + allowPersonalApiKeys: boolean + canonicalResourceId: string +} + +const canonicalContext: TestContext = { + workspaceId: 'workspace-1', + workspaceOrganizationId: 'organization-1', + allowPersonalApiKeys: true, + canonicalResourceId: 'resource-1', +} + +const sessionPrincipal: SessionPrincipal = { + kind: 'session', + userId: 'user-1', + sessionId: 'session-1', +} + +describe('defineAuthorizedWorkspaceUseCase', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.events.length = 0 + mocks.resolvePermission.mockResolvedValue('write') + }) + + it('narrows definition callbacks while keeping public execution principal-safe', async () => { + const resolveContext = vi.fn( + async ({ principal }: { principal: SessionPrincipal; input: TestInput }) => { + expectTypeOf(principal).toEqualTypeOf() + return canonicalContext + } + ) + const useCase = defineAuthorizedWorkspaceUseCase({ + operation, + resolveContext, + authorizationOptions: {}, + async execute({ principal, context }) { + expectTypeOf(principal).toEqualTypeOf() + expectTypeOf(context).toEqualTypeOf() + return { resource: { id: context.canonicalResourceId, name: 'Renamed' } } + }, + }) + + const disallowedPrincipal: PersonalApiKeyPrincipal = { + kind: 'personal_api_key', + userId: 'user-1', + keyId: 'key-1', + } + await expect( + useCase.execute({ principal: disallowedPrincipal, input: { resourceId: 'resource-1' } }) + ).rejects.toMatchObject>({ code: 'forbidden' }) + + expect(resolveContext).not.toHaveBeenCalled() + expect(mocks.resolvePermission).not.toHaveBeenCalled() + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) + + it('authorizes canonical context, enriches one audit entry, then runs afterSuccess', async () => { + const request = { headers: new Headers({ 'user-agent': 'vitest' }) } + const useCase = defineAuthorizedWorkspaceUseCase({ + operation, + resolveContext: async ({ input }: { principal: SessionPrincipal; input: TestInput }) => ({ + ...canonicalContext, + canonicalResourceId: input.resourceId, + }), + authorizationOptions: {}, + async execute({ context }) { + mocks.events.push('execute') + return { resource: { id: context.canonicalResourceId, name: 'Renamed' } } + }, + projectAudit({ result }) { + return { + action: AuditAction.FILE_UPDATED, + resourceType: AuditResourceType.FILE, + resourceId: result.resource.id, + resourceName: result.resource.name, + metadata: { operation: 'spoofed', actor: 'spoofed', retained: true }, + } + }, + async afterSuccess() { + mocks.events.push('afterSuccess') + }, + }) + + await expect( + useCase.execute({ + principal: sessionPrincipal, + input: { resourceId: 'resource-1' }, + request, + }) + ).resolves.toEqual({ resource: { id: 'resource-1', name: 'Renamed' } }) + + expect(mocks.resolvePermission).toHaveBeenCalledWith( + 'user-1', + 'workspace-1', + 'organization-1', + undefined, + { forUpdate: undefined } + ) + expect(mocks.recordAudit).toHaveBeenCalledWith({ + workspaceId: 'workspace-1', + actorId: 'user-1', + actorName: undefined, + action: 'file.updated', + resourceType: 'file', + resourceId: 'resource-1', + resourceName: 'Renamed', + description: undefined, + metadata: { + retained: true, + operation: 'test.rename', + actor: { kind: 'session', userId: 'user-1' }, + }, + request, + }) + expect(mocks.events).toEqual(['execute', 'audit', 'afterSuccess']) + }) + + it('supports zero or many semantic audit entries', async () => { + const buildUseCase = (auditCount: number) => + defineAuthorizedWorkspaceUseCase({ + operation, + resolveContext: async (_args: { principal: SessionPrincipal; input: TestInput }) => + canonicalContext, + authorizationOptions: {}, + async execute() { + return { auditCount } + }, + projectAudit({ result }) { + return Array.from({ length: result.auditCount }, (_, index) => ({ + action: AuditAction.FILE_UPDATED, + resourceType: AuditResourceType.FILE, + resourceId: `resource-${index}`, + })) + }, + }) + + await buildUseCase(0).execute({ + principal: sessionPrincipal, + input: { resourceId: 'resource-1' }, + }) + expect(mocks.recordAudit).not.toHaveBeenCalled() + + await buildUseCase(2).execute({ + principal: sessionPrincipal, + input: { resourceId: 'resource-1' }, + }) + expect(mocks.recordAudit).toHaveBeenCalledTimes(2) + }) + + it('resolves domain-specific delegation options against canonical context', async () => { + const scopeCheck = vi.fn( + (principal: DelegatedPrincipal, context: TestContext) => + principal.resourceScope?.fileId === context.canonicalResourceId + ) + const useCase = defineAuthorizedWorkspaceUseCase({ + operation: delegatedOperation, + resolveContext: async (_args: { principal: DelegatedPrincipal; input: TestInput }) => + canonicalContext, + authorizationOptions: ({ principal }) => { + expectTypeOf(principal).toEqualTypeOf() + return { + delegation: { + audience: 'test:files', + isWithinScope: scopeCheck, + }, + } + }, + async execute({ principal }) { + expectTypeOf(principal).toEqualTypeOf() + return { ok: true as const } + }, + }) + const principal: DelegatedPrincipal = { + kind: 'delegated', + serviceId: 'executor', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'delegation-1', + audience: 'test:files', + issuedAt: new Date(Date.now() - 1_000), + expiresAt: new Date(Date.now() + 60_000), + resourceScope: { fileId: 'resource-1' }, + } + + await expect( + useCase.execute({ principal, input: { resourceId: 'resource-1' } }) + ).resolves.toEqual({ ok: true }) + expect(scopeCheck).toHaveBeenCalledWith(principal, canonicalContext) + expect(mocks.resolvePermission).toHaveBeenCalledWith( + 'user-1', + 'workspace-1', + 'organization-1', + undefined, + { forUpdate: undefined } + ) + }) + + it('records workspace API keys as non-human audit actors', async () => { + const useCase = defineAuthorizedWorkspaceUseCase({ + operation: workspaceKeyOperation, + resolveContext: async (_args: { principal: WorkspaceApiKeyPrincipal; input: TestInput }) => + canonicalContext, + authorizationOptions: {}, + async execute() { + return { id: 'resource-1' } + }, + projectAudit({ result }) { + return { + action: AuditAction.FILE_UPDATED, + resourceType: AuditResourceType.FILE, + resourceId: result.id, + } + }, + }) + + await useCase.execute({ + principal: { + kind: 'workspace_api_key', + workspaceId: 'workspace-1', + keyId: 'workspace-key-1', + }, + input: { resourceId: 'resource-1' }, + }) + + expect(mocks.resolvePermission).not.toHaveBeenCalled() + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + actorId: null, + actorName: 'Workspace API key', + metadata: { + operation: 'test.workspace_key_read', + actor: { + kind: 'workspace_api_key', + workspaceId: 'workspace-1', + keyId: 'workspace-key-1', + }, + }, + }) + ) + }) +}) diff --git a/apps/sim/lib/core/application/authorized-workspace-use-case.ts b/apps/sim/lib/core/application/authorized-workspace-use-case.ts new file mode 100644 index 00000000000..ff0fc8faecb --- /dev/null +++ b/apps/sim/lib/core/application/authorized-workspace-use-case.ts @@ -0,0 +1,152 @@ +import { type AuditActionType, type AuditResourceTypeValue, recordAudit } from '@sim/audit' +import type { PrincipalAuditAttribution } from '@sim/auth/principal' +import { resolvePrincipalAuditAttribution } from '@sim/auth/principal' +import type { OperationUseCase } from '@/lib/core/application/operation' +import { + authorizeWorkspaceOperation, + requireAllowedWorkspacePrincipal, + type WorkspaceAuthorizationContext, + type WorkspaceAuthorizationOptions, +} from '@/lib/core/application/workspace-authorization' +import type { + PrincipalForOperation, + WorkspaceOperation, +} from '@/lib/core/application/workspace-operation' +import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' + +export interface WorkspaceUseCaseAuditEntry { + action: AuditActionType + resourceType: AuditResourceTypeValue + resourceId?: string + resourceName?: string + description?: string + metadata?: Record +} + +export interface AuthorizedWorkspaceUseCaseContext< + O extends WorkspaceOperation, + I, + C extends WorkspaceAuthorizationContext, +> { + principal: PrincipalForOperation + input: I + context: C + request?: OrchestrationRequestContext +} + +export interface AuthorizedWorkspaceUseCaseResultContext< + O extends WorkspaceOperation, + I, + C extends WorkspaceAuthorizationContext, + R, +> extends AuthorizedWorkspaceUseCaseContext { + result: R +} + +export interface AuthorizedWorkspaceUseCaseDefinition< + O extends WorkspaceOperation, + I, + C extends WorkspaceAuthorizationContext, + R, +> { + operation: O + resolveContext(args: { principal: PrincipalForOperation; input: I }): C | Promise + authorizationOptions: + | WorkspaceAuthorizationOptions + | (( + args: AuthorizedWorkspaceUseCaseContext + ) => WorkspaceAuthorizationOptions | Promise>) + execute(args: AuthorizedWorkspaceUseCaseContext): Promise + projectAudit?( + args: AuthorizedWorkspaceUseCaseResultContext + ): WorkspaceUseCaseAuditEntry | WorkspaceUseCaseAuditEntry[] + afterSuccess?(args: AuthorizedWorkspaceUseCaseResultContext): void | Promise +} + +function isAuthorizationOptionsResolver< + O extends WorkspaceOperation, + I, + C extends WorkspaceAuthorizationContext, +>( + options: AuthorizedWorkspaceUseCaseDefinition['authorizationOptions'] +): options is ( + args: AuthorizedWorkspaceUseCaseContext +) => WorkspaceAuthorizationOptions | Promise> { + return typeof options === 'function' +} + +function recordProjectedAuditEntries( + operation: O, + context: WorkspaceAuthorizationContext, + attribution: PrincipalAuditAttribution, + request: OrchestrationRequestContext | undefined, + entries: readonly WorkspaceUseCaseAuditEntry[] +): void { + for (const entry of entries) { + recordAudit({ + workspaceId: context.workspaceId, + actorId: attribution.actorId, + actorName: attribution.actorName, + action: entry.action, + resourceType: entry.resourceType, + resourceId: entry.resourceId, + resourceName: entry.resourceName, + description: entry.description, + metadata: { + ...entry.metadata, + operation: operation.id, + actor: attribution.actor, + }, + request, + }) + } +} + +export function defineAuthorizedWorkspaceUseCase< + const O extends WorkspaceOperation, + I, + C extends WorkspaceAuthorizationContext, + R, +>(definition: AuthorizedWorkspaceUseCaseDefinition): OperationUseCase { + return { + operation: definition.operation, + async execute({ principal, input, request }) { + requireAllowedWorkspacePrincipal(principal, definition.operation) + const context = await definition.resolveContext({ principal, input }) + const executionContext: AuthorizedWorkspaceUseCaseContext = { + principal, + input, + context, + request, + } + const authorizationOptions = isAuthorizationOptionsResolver(definition.authorizationOptions) + ? await definition.authorizationOptions(executionContext) + : definition.authorizationOptions + + await authorizeWorkspaceOperation( + principal, + definition.operation, + context, + authorizationOptions + ) + const result = await definition.execute(executionContext) + const resultContext = { ...executionContext, result } + const projectedAudit = definition.projectAudit?.(resultContext) + if (projectedAudit !== undefined) { + const auditEntries = Array.isArray(projectedAudit) ? projectedAudit : [projectedAudit] + if (auditEntries.length > 0) { + const auditAttribution = resolvePrincipalAuditAttribution(principal) + recordProjectedAuditEntries( + definition.operation, + context, + auditAttribution, + request, + auditEntries + ) + } + } + await definition.afterSuccess?.(resultContext) + return result + }, + } +} diff --git a/apps/sim/lib/core/application/index.ts b/apps/sim/lib/core/application/index.ts index 8678d7962eb..9c28f293d70 100644 --- a/apps/sim/lib/core/application/index.ts +++ b/apps/sim/lib/core/application/index.ts @@ -1,3 +1,10 @@ +export { + type AuthorizedWorkspaceUseCaseContext, + type AuthorizedWorkspaceUseCaseDefinition, + type AuthorizedWorkspaceUseCaseResultContext, + defineAuthorizedWorkspaceUseCase, + type WorkspaceUseCaseAuditEntry, +} from '@/lib/core/application/authorized-workspace-use-case' export type { ApplicationOperation, OperationUseCase, @@ -7,8 +14,13 @@ export type { WorkspaceAuthorizationOptions, WorkspaceDelegationPolicy, } from '@/lib/core/application/workspace-authorization' -export { authorizeWorkspaceOperation } from '@/lib/core/application/workspace-authorization' +export { + authorizeWorkspaceOperation, + requireAllowedWorkspacePrincipal, +} from '@/lib/core/application/workspace-authorization' export { defineWorkspaceOperation, + type PrincipalForOperation, + type PrincipalKind, type WorkspaceOperation, } from '@/lib/core/application/workspace-operation' diff --git a/apps/sim/lib/core/application/workspace-authorization.ts b/apps/sim/lib/core/application/workspace-authorization.ts index 38c3f094dce..01d5e904e31 100644 --- a/apps/sim/lib/core/application/workspace-authorization.ts +++ b/apps/sim/lib/core/application/workspace-authorization.ts @@ -5,7 +5,10 @@ import { permissionSatisfies, resolveEffectiveWorkspacePermission, } from '@sim/platform-authz/workspace' -import type { WorkspaceOperation } from '@/lib/core/application/workspace-operation' +import type { + PrincipalForOperation, + WorkspaceOperation, +} from '@/lib/core/application/workspace-operation' import { OrchestrationError } from '@/lib/core/orchestration/types' export interface WorkspaceAuthorizationContext { @@ -25,6 +28,18 @@ export interface WorkspaceAuthorizationOptions } +export function requireAllowedWorkspacePrincipal( + principal: Principal, + operation: O +): asserts principal is PrincipalForOperation { + if (!operation.principalKinds.some((kind) => kind === principal.kind)) { + throw new OrchestrationError( + 'forbidden', + `Principal kind ${principal.kind} cannot perform operation ${operation.id}` + ) + } +} + function requirePermission(permission: PermissionType | null, required: PermissionType): void { if (!permissionSatisfies(permission, required)) { throw new OrchestrationError('forbidden', 'Insufficient workspace permissions') @@ -53,6 +68,8 @@ export async function authorizeWorkspaceOperation ): Promise { + requireAllowedWorkspacePrincipal(principal, operation) + switch (principal.kind) { case 'session': await requireCurrentHumanPermission(principal.userId, context, operation.minimumRole, options) diff --git a/apps/sim/lib/core/application/workspace-operation.ts b/apps/sim/lib/core/application/workspace-operation.ts index a534e47fc24..ec6d731adfb 100644 --- a/apps/sim/lib/core/application/workspace-operation.ts +++ b/apps/sim/lib/core/application/workspace-operation.ts @@ -1,19 +1,54 @@ +import type { Principal } from '@sim/auth/principal' import type { PermissionType } from '@sim/platform-authz/workspace' import type { ApplicationOperation } from '@/lib/core/application/operation' type WorkspaceApiKeyPolicy = R extends 'admin' ? 'deny' : 'allow' | 'deny' +export type PrincipalKind = Principal['kind'] + +export type PrincipalForOperation = + Extract + export interface WorkspaceOperation< Id extends string = string, Role extends PermissionType = PermissionType, + PrincipalKinds extends readonly PrincipalKind[] = readonly PrincipalKind[], > extends ApplicationOperation { readonly minimumRole: Role readonly workspaceApiKey: WorkspaceApiKeyPolicy + readonly principalKinds: PrincipalKinds } +type WorkspaceApiKeyPrincipalConsistency< + Role extends PermissionType, + PrincipalKinds extends readonly PrincipalKind[], +> = 'workspace_api_key' extends PrincipalKinds[number] + ? { readonly workspaceApiKey: Role extends 'admin' ? never : 'allow' } + : { readonly workspaceApiKey: 'deny' } + export function defineWorkspaceOperation< const Id extends string, const Role extends PermissionType, ->(operation: WorkspaceOperation): WorkspaceOperation { + const PrincipalKinds extends readonly PrincipalKind[], +>( + operation: WorkspaceOperation & + WorkspaceApiKeyPrincipalConsistency +): WorkspaceOperation { + if (operation.principalKinds.length === 0) { + throw new Error(`Operation ${operation.id} must allow at least one principal kind`) + } + if (new Set(operation.principalKinds).size !== operation.principalKinds.length) { + throw new Error(`Operation ${operation.id} declares duplicate principal kinds`) + } + + const allowsWorkspaceApiKey = operation.principalKinds.includes('workspace_api_key') + if (allowsWorkspaceApiKey !== (operation.workspaceApiKey === 'allow')) { + throw new Error(`Operation ${operation.id} has inconsistent workspace API key policy`) + } + if (allowsWorkspaceApiKey && !['read', 'write'].includes(operation.minimumRole)) { + throw new Error(`Operation ${operation.id} exceeds the workspace API key write ceiling`) + } + + Object.freeze(operation.principalKinds) return Object.freeze(operation) } diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts index afbc09c7cb4..b0a18325452 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts @@ -120,6 +120,11 @@ export interface WorkspaceFileArchiveResult { files: number } +export interface WorkspaceFileBulkArchiveResult extends WorkspaceFileArchiveResult { + folderIds: string[] + fileIds: string[] +} + function assertBulkAffectedItemsWithinLimit(count: number): void { if (count > MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS) { throw new OrchestrationError( @@ -806,7 +811,12 @@ export async function moveWorkspaceFileItems(params: { folderIds?: string[] targetFolderId?: string | null targetFolderPath?: string -}): Promise<{ movedFiles: number; movedFolders: number }> { +}): Promise<{ + movedFiles: number + movedFolders: number + movedFileIds: string[] + movedFolderIds: string[] +}> { const fileIds = Array.from(new Set(params.fileIds ?? [])) const folderIds = Array.from(new Set(params.folderIds ?? [])) if (params.targetFolderId !== undefined && params.targetFolderPath !== undefined) { @@ -1023,7 +1033,12 @@ export async function moveWorkspaceFileItems(params: { .returning({ id: folderTable.id }) : [] - return { movedFiles: movedFiles.length, movedFolders: movedFolders.length } + return { + movedFiles: movedFiles.length, + movedFolders: movedFolders.length, + movedFileIds: movedFiles.map((file) => file.id), + movedFolderIds: movedFolders.map((folder) => folder.id), + } }) } @@ -1276,7 +1291,7 @@ export async function bulkArchiveWorkspaceFileItems(params: { workspaceId: string fileIds?: string[] folderIds?: string[] -}): Promise { +}): Promise { const now = new Date() const explicitFileIds = Array.from(new Set(params.fileIds ?? [])) const explicitFolderIds = Array.from(new Set(params.folderIds ?? [])) @@ -1371,10 +1386,15 @@ export async function bulkArchiveWorkspaceFileItems(params: { .returning({ id: folderTable.id }) : [] + const archivedFileIds = Array.from( + new Set([...archivedExplicitFiles, ...archivedDescendantFiles].map((file) => file.id)) + ) + const archivedFolderIds = archivedFolders.map((folder) => folder.id) return { - folders: archivedFolders.length, - files: new Set([...archivedExplicitFiles, ...archivedDescendantFiles].map((file) => file.id)) - .size, + folders: archivedFolderIds.length, + files: archivedFileIds.length, + folderIds: archivedFolderIds, + fileIds: archivedFileIds, } }) } diff --git a/apps/sim/lib/workspace-files/application/archive-workspace-file-items.test.ts b/apps/sim/lib/workspace-files/application/archive-workspace-file-items.test.ts index 6c76afbd839..d7ecfb67b56 100644 --- a/apps/sim/lib/workspace-files/application/archive-workspace-file-items.test.ts +++ b/apps/sim/lib/workspace-files/application/archive-workspace-file-items.test.ts @@ -3,19 +3,33 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockAuthorize, mockArchive, mockAudit, mockNotify } = vi.hoisted(() => ({ - mockAuthorize: vi.fn(), +const { + events, + mockLoadContext, + mockResolvePermission, + mockAssertItems, + mockArchive, + mockAudit, + mockNotify, +} = vi.hoisted(() => ({ + events: [] as string[], + mockLoadContext: vi.fn(), + mockResolvePermission: vi.fn(), + mockAssertItems: vi.fn(), mockArchive: vi.fn(), mockAudit: vi.fn(), mockNotify: vi.fn(), })) -vi.mock('@/lib/workspace-files/application/workspace-operation-context', () => ({ - authorizeWorkspaceFileOperation: mockAuthorize, -})) vi.mock('@/lib/uploads/contexts/workspace', () => ({ - assertWorkspaceFileItemsBelongToWorkspace: mockAuthorize, + assertWorkspaceFileItemsBelongToWorkspace: mockAssertItems, bulkArchiveWorkspaceFileItems: mockArchive, + loadWorkspaceFileOperationContext: mockLoadContext, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => + actual === 'admin' || actual === required || (actual === 'write' && required === 'read'), + resolveEffectiveWorkspacePermission: mockResolvePermission, })) vi.mock('@sim/audit', () => ({ AuditAction: { FILE_DELETED: 'file.deleted', FOLDER_DELETED: 'folder.deleted' }, @@ -25,21 +39,33 @@ vi.mock('@sim/audit', () => ({ vi.mock('@/lib/realtime/notify', () => ({ notifyWorkspaceFilesChanged: mockNotify })) import { archiveWorkspaceFileItemsOperation } from '@/lib/workspace-files/application/archive-workspace-file-items' -import { fileOperations } from '@/lib/workspace-files/application/operations' describe('archiveWorkspaceFileItemsOperation', () => { beforeEach(() => { vi.clearAllMocks() - mockAuthorize.mockResolvedValue({ - context: { + events.length = 0 + mockLoadContext.mockImplementation(async () => { + events.push('resolve') + return { workspaceId: 'ws-1', workspaceOrganizationId: null, allowPersonalApiKeys: true, billedAccountUserId: 'owner-1', - }, - attribution: { attributedUserId: 'user-1', actor: { kind: 'session', userId: 'user-1' } }, + } + }) + mockResolvePermission.mockImplementation(async () => { + events.push('authorize') + return 'write' }) - mockArchive.mockResolvedValue({ files: 1, folders: 0 }) + mockAssertItems.mockImplementation(async () => { + events.push('execute') + }) + mockArchive.mockImplementation(async () => ({ + files: 1, + folders: 0, + fileIds: ['file-1'], + folderIds: [], + })) }) it('preserves atomic bulk archive results and emits side effects once', async () => { @@ -48,24 +74,24 @@ describe('archiveWorkspaceFileItemsOperation', () => { input: { workspaceId: 'ws-1', fileIds: ['file-1'] }, }) - expect(result).toEqual({ deletedItems: { files: 1, folders: 0 } }) - expect(mockAuthorize).toHaveBeenCalledWith( - expect.anything(), - fileOperations.delete, - 'ws-1', - 'file-1' - ) + expect(result).toMatchObject({ deletedItems: { files: 1, folders: 0 } }) + expect(events).toEqual(['resolve', 'authorize', 'execute']) expect(mockArchive).toHaveBeenCalledWith({ workspaceId: 'ws-1', fileIds: ['file-1'], folderIds: [], }) + expect(mockAssertItems).toHaveBeenCalledWith({ + workspaceId: 'ws-1', + fileIds: ['file-1'], + folderIds: [], + }) expect(mockAudit).toHaveBeenCalledOnce() expect(mockNotify).toHaveBeenCalledOnce() }) it('classifies a single missing file without notifying', async () => { - mockArchive.mockResolvedValue({ files: 0, folders: 0 }) + mockArchive.mockResolvedValue({ files: 0, folders: 0, fileIds: [], folderIds: [] }) await expect( archiveWorkspaceFileItemsOperation.execute({ principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, @@ -76,7 +102,117 @@ describe('archiveWorkspaceFileItemsOperation', () => { expect(mockNotify).not.toHaveBeenCalled() }) - it('rejects oversized selections before authorization', async () => { + it('authorizes a delegated bulk selection without borrowing the first file scope', async () => { + await expect( + archiveWorkspaceFileItemsOperation.execute({ + principal: { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'ws-1', + delegationId: 'delegation-1', + audience: 'sim:workspace-files', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2099-01-01T00:00:00Z'), + resourceScope: { fileId: 'file-1' }, + }, + input: { workspaceId: 'ws-1', fileIds: ['file-1', 'file-2'] }, + }) + ).rejects.toThrow('Delegated workspace access is no longer valid') + + expect(mockLoadContext).toHaveBeenCalledWith('ws-1') + expect(mockResolvePermission).not.toHaveBeenCalled() + expect(mockAssertItems).not.toHaveBeenCalled() + expect(mockArchive).not.toHaveBeenCalled() + }) + + it('allows a file-scoped delegated principal to archive its one explicit file', async () => { + const principal = { + kind: 'delegated' as const, + serviceId: 'copilot' as const, + subjectUserId: 'user-1', + workspaceId: 'ws-1', + delegationId: 'delegation-1', + audience: 'sim:workspace-files', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2099-01-01T00:00:00Z'), + resourceScope: { fileId: 'file-1' }, + } + + await archiveWorkspaceFileItemsOperation.execute({ + principal, + input: { workspaceId: 'ws-1', fileIds: ['file-1'] }, + }) + + expect(mockResolvePermission).toHaveBeenCalledOnce() + }) + + it('denies a file-scoped delegated principal selecting a folder before validation', async () => { + await expect( + archiveWorkspaceFileItemsOperation.execute({ + principal: { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'ws-1', + delegationId: 'delegation-1', + audience: 'sim:workspace-files', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2099-01-01T00:00:00Z'), + resourceScope: { fileId: 'file-1' }, + }, + input: { workspaceId: 'ws-1', folderIds: ['folder-1'] }, + }) + ).rejects.toThrow('Delegated workspace access is no longer valid') + + expect(mockResolvePermission).not.toHaveBeenCalled() + expect(mockAssertItems).not.toHaveBeenCalled() + expect(mockArchive).not.toHaveBeenCalled() + }) + + it('audits and notifies only authoritative rows returned by the mutation', async () => { + mockArchive.mockResolvedValue({ + files: 1, + folders: 1, + fileIds: ['file-2'], + folderIds: ['folder-2'], + }) + + const result = await archiveWorkspaceFileItemsOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + workspaceId: 'ws-1', + fileIds: ['file-1', 'file-2'], + folderIds: ['folder-1'], + }, + }) + + expect(result).toMatchObject({ deletedItems: { files: 1, folders: 1 } }) + expect(mockAudit).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ metadata: expect.objectContaining({ fileIds: ['file-2'] }) }) + ) + expect(mockAudit).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ metadata: expect.objectContaining({ folderIds: ['folder-2'] }) }) + ) + expect(mockNotify).toHaveBeenCalledOnce() + }) + + it('does not claim or notify a zero-row bulk mutation', async () => { + mockArchive.mockResolvedValue({ files: 0, folders: 0, fileIds: [], folderIds: [] }) + + const result = await archiveWorkspaceFileItemsOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: 'ws-1', fileIds: ['file-1', 'file-2'] }, + }) + + expect(result).toMatchObject({ deletedItems: { files: 0, folders: 0 } }) + expect(mockAudit).not.toHaveBeenCalled() + expect(mockNotify).not.toHaveBeenCalled() + }) + + it('rejects oversized selections after authorization and before storage', async () => { await expect( archiveWorkspaceFileItemsOperation.execute({ principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, @@ -86,7 +222,7 @@ describe('archiveWorkspaceFileItemsOperation', () => { }, }) ).rejects.toThrow('accept at most 1000') - expect(mockAuthorize).not.toHaveBeenCalled() + expect(events).toEqual(['resolve', 'authorize']) expect(mockArchive).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/lib/workspace-files/application/archive-workspace-file-items.ts b/apps/sim/lib/workspace-files/application/archive-workspace-file-items.ts index 8ecb336b6e9..30e7011c8a9 100644 --- a/apps/sim/lib/workspace-files/application/archive-workspace-file-items.ts +++ b/apps/sim/lib/workspace-files/application/archive-workspace-file-items.ts @@ -1,17 +1,14 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { type Principal, resolvePrincipalAuditAttribution } from '@sim/auth/principal' +import { AuditAction, AuditResourceType } from '@sim/audit' import { createLogger } from '@sim/logger' -import { - OrchestrationError, - type OrchestrationRequestContext, -} from '@/lib/core/orchestration/types' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' import { assertWorkspaceFileItemsBelongToWorkspace, bulkArchiveWorkspaceFileItems, + loadWorkspaceFileOperationContext, } from '@/lib/uploads/contexts/workspace' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' import { fileOperations } from '@/lib/workspace-files/application/operations' -import { authorizeWorkspaceFileOperation } from '@/lib/workspace-files/application/workspace-operation-context' import { MAX_WORKSPACE_FILE_BULK_REQUEST_IDS } from '@/lib/workspace-files/limits' const logger = createLogger('ArchiveWorkspaceFileItems') @@ -24,15 +21,24 @@ export interface ArchiveWorkspaceFileItemsInput { export interface ArchiveWorkspaceFileItemsResult { deletedItems: { files: number; folders: number } + affectedIds: { fileIds: string[]; folderIds: string[] } +} + +function normalizeSelection(input: ArchiveWorkspaceFileItemsInput) { + return { + fileIds: [...new Set(input.fileIds ?? [])], + folderIds: [...new Set(input.folderIds ?? [])], + } } -async function executeArchiveWorkspaceFileItems(args: { - principal: Principal +async function executeArchiveWorkspaceFileItems({ + input, + context, +}: { input: ArchiveWorkspaceFileItemsInput - request?: OrchestrationRequestContext + context: Awaited> }): Promise { - const fileIds = [...new Set(args.input.fileIds ?? [])] - const folderIds = [...new Set(args.input.folderIds ?? [])] + const { fileIds, folderIds } = normalizeSelection(input) if (fileIds.length === 0 && folderIds.length === 0) { throw new OrchestrationError('validation', 'At least one file or folder must be selected') } @@ -46,67 +52,76 @@ async function executeArchiveWorkspaceFileItems(args: { ) } - const { context } = await authorizeWorkspaceFileOperation( - args.principal, - fileOperations.delete, - args.input.workspaceId, - fileIds[0] - ) - const auditAttribution = resolvePrincipalAuditAttribution(args.principal) await assertWorkspaceFileItemsBelongToWorkspace({ workspaceId: context.workspaceId, fileIds, folderIds, }) - const deletedItems = await bulkArchiveWorkspaceFileItems({ + const archived = await bulkArchiveWorkspaceFileItems({ workspaceId: context.workspaceId, fileIds, folderIds, }) + const deletedItems = { files: archived.fileIds.length, folders: archived.folderIds.length } - if (fileIds.length === 1 && folderIds.length === 0 && deletedItems.files === 0) { + if (fileIds.length === 1 && folderIds.length === 0 && archived.fileIds.length === 0) { throw new OrchestrationError('not_found', 'File not found') } - if (folderIds.length === 1 && fileIds.length === 0 && deletedItems.folders === 0) { + if (folderIds.length === 1 && fileIds.length === 0 && archived.folderIds.length === 0) { throw new OrchestrationError('not_found', 'Folder not found') } - if (fileIds.length > 0) { - recordAudit({ - workspaceId: context.workspaceId, - actorId: auditAttribution.actorId, - actorName: auditAttribution.actorName, - action: AuditAction.FILE_DELETED, - resourceType: AuditResourceType.FILE, - description: `Deleted ${fileIds.length} file${fileIds.length === 1 ? '' : 's'}`, - metadata: { operation: fileOperations.delete.id, actor: auditAttribution.actor, fileIds }, - request: args.request, - }) + logger.info('Archived workspace file items', { workspaceId: context.workspaceId, deletedItems }) + return { + deletedItems, + affectedIds: { fileIds: archived.fileIds, folderIds: archived.folderIds }, } - if (folderIds.length > 0) { - recordAudit({ - workspaceId: context.workspaceId, - actorId: auditAttribution.actorId, - actorName: auditAttribution.actorName, - action: AuditAction.FOLDER_DELETED, - resourceType: AuditResourceType.FOLDER, - resourceId: folderIds.length === 1 ? folderIds[0] : undefined, - description: `Deleted ${folderIds.length} file folder${folderIds.length === 1 ? '' : 's'}`, - metadata: { - operation: fileOperations.delete.id, - actor: auditAttribution.actor, - folderIds, - affected: deletedItems, - }, - request: args.request, - }) +} + +async function resolveArchiveContext({ input }: { input: ArchiveWorkspaceFileItemsInput }) { + const context = await loadWorkspaceFileOperationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + const { fileIds, folderIds } = normalizeSelection(input) + return { + ...context, + fileId: fileIds.length === 1 && folderIds.length === 0 ? fileIds[0] : undefined, } - await notifyWorkspaceFilesChanged(context.workspaceId) - logger.info('Archived workspace file items', { workspaceId: context.workspaceId, deletedItems }) - return { deletedItems } } -export const archiveWorkspaceFileItemsOperation = { +export const archiveWorkspaceFileItemsOperation = defineAuthorizedWorkspaceFileUseCase({ operation: fileOperations.delete, + resolveContext: resolveArchiveContext, execute: executeArchiveWorkspaceFileItems, -} as const + projectAudit({ result }) { + const entries = [] + if (result.affectedIds.fileIds.length > 0) { + entries.push({ + action: AuditAction.FILE_DELETED, + resourceType: AuditResourceType.FILE, + description: `Deleted ${result.affectedIds.fileIds.length} file${result.affectedIds.fileIds.length === 1 ? '' : 's'}`, + metadata: { + fileIds: result.affectedIds.fileIds, + }, + }) + } + if (result.affectedIds.folderIds.length > 0) { + entries.push({ + action: AuditAction.FOLDER_DELETED, + resourceType: AuditResourceType.FOLDER, + resourceId: + result.affectedIds.folderIds.length === 1 ? result.affectedIds.folderIds[0] : undefined, + description: `Deleted ${result.affectedIds.folderIds.length} file folder${result.affectedIds.folderIds.length === 1 ? '' : 's'}`, + metadata: { + folderIds: result.affectedIds.folderIds, + affected: result.deletedItems, + }, + }) + } + return entries + }, + async afterSuccess({ context, result }) { + if (result.affectedIds.fileIds.length > 0 || result.affectedIds.folderIds.length > 0) { + await notifyWorkspaceFilesChanged(context.workspaceId) + } + }, +}) diff --git a/apps/sim/lib/workspace-files/application/authorization.test.ts b/apps/sim/lib/workspace-files/application/authorization.test.ts index 5d39bc85d0e..524fd4aed21 100644 --- a/apps/sim/lib/workspace-files/application/authorization.test.ts +++ b/apps/sim/lib/workspace-files/application/authorization.test.ts @@ -86,6 +86,20 @@ describe('file operation authorization', () => { expect(resolvePermission).not.toHaveBeenCalled() }) + it('rejects a disallowed principal kind before principal-specific authorization', async () => { + await expect( + authorizeWorkspaceFileAccess( + { kind: 'personal_api_key', userId: 'user-1', keyId: 'key-1' }, + fileOperations.compiledCheck, + { ...authorizationContext, allowPersonalApiKeys: false } + ) + ).rejects.toMatchObject>({ + code: 'forbidden', + message: 'Principal kind personal_api_key cannot perform operation files.compiled_check', + }) + expect(resolvePermission).not.toHaveBeenCalled() + }) + it('reauthorizes a valid file-scoped Copilot delegation as its human subject', async () => { await authorizeWorkspaceFileAccess( { diff --git a/apps/sim/lib/workspace-files/application/authorization.ts b/apps/sim/lib/workspace-files/application/authorization.ts index e561cde3337..dca73ebab1e 100644 --- a/apps/sim/lib/workspace-files/application/authorization.ts +++ b/apps/sim/lib/workspace-files/application/authorization.ts @@ -17,6 +17,16 @@ export type WorkspaceFileAuthorizationOptions = Omit< 'delegation' > +export const workspaceFileDelegationPolicy = { + audience: WORKSPACE_FILES_DELEGATION_AUDIENCE, + isWithinScope: ( + delegated: Extract, + canonicalContext: WorkspaceFileAuthorizationContext + ) => + delegated.resourceScope?.fileId === undefined || + delegated.resourceScope.fileId === canonicalContext.fileId, +} as const + export async function authorizeWorkspaceFileAccess( principal: Principal, operation: WorkspaceOperation, @@ -25,11 +35,6 @@ export async function authorizeWorkspaceFileAccess( ): Promise { await authorizeWorkspaceOperation(principal, operation, context, { ...options, - delegation: { - audience: WORKSPACE_FILES_DELEGATION_AUDIENCE, - isWithinScope: (delegated, canonicalContext) => - delegated.resourceScope?.fileId === undefined || - delegated.resourceScope.fileId === canonicalContext.fileId, - }, + delegation: workspaceFileDelegationPolicy, }) } diff --git a/apps/sim/lib/workspace-files/application/authorized-workspace-file-use-case.ts b/apps/sim/lib/workspace-files/application/authorized-workspace-file-use-case.ts new file mode 100644 index 00000000000..46ecccbc243 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/authorized-workspace-file-use-case.ts @@ -0,0 +1,28 @@ +import { + type AuthorizedWorkspaceUseCaseDefinition, + defineAuthorizedWorkspaceUseCase, + type WorkspaceOperation, +} from '@/lib/core/application' +import { + type WorkspaceFileAuthorizationContext, + workspaceFileDelegationPolicy, +} from '@/lib/workspace-files/application/authorization' + +type AuthorizedWorkspaceFileUseCaseDefinition< + O extends WorkspaceOperation, + I, + C extends WorkspaceFileAuthorizationContext, + R, +> = Omit, 'authorizationOptions'> + +export function defineAuthorizedWorkspaceFileUseCase< + const O extends WorkspaceOperation, + I, + C extends WorkspaceFileAuthorizationContext, + R, +>(definition: AuthorizedWorkspaceFileUseCaseDefinition) { + return defineAuthorizedWorkspaceUseCase({ + ...definition, + authorizationOptions: { delegation: workspaceFileDelegationPolicy }, + }) +} diff --git a/apps/sim/lib/workspace-files/application/compiled-check-workspace-file.test.ts b/apps/sim/lib/workspace-files/application/compiled-check-workspace-file.test.ts new file mode 100644 index 00000000000..04172b19904 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/compiled-check-workspace-file.test.ts @@ -0,0 +1,235 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getE2BDocFormat: vi.fn(), + getFile: vi.fn(), + loadContext: vi.fn(), + resolvePermission: vi.fn(), + fetchBuffer: vi.fn(), + runE2BCompiledCheck: vi.fn(), + runSandboxTask: vi.fn(), + validateMermaidSource: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: () => true, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/copilot/tools/server/files/doc-compile', () => ({ + getE2BDocFormat: mocks.getE2BDocFormat, +})) + +vi.mock('@/lib/copilot/tools/server/files/doc-recalc', () => ({ + runE2BCompiledCheck: mocks.runE2BCompiledCheck, +})) + +vi.mock('@/lib/core/config/env-flags', () => ({ isDocSandboxEnabled: true })) + +vi.mock('@/lib/execution/constants', () => ({ + BINARY_DOC_TASKS: { pptx: 'document-pptx' }, + MAX_DOCUMENT_PREVIEW_CODE_BYTES: 1_000, +})) + +vi.mock('@/lib/execution/sandbox/run-task', () => ({ + runSandboxTask: mocks.runSandboxTask, + SandboxUserCodeError: class SandboxUserCodeError extends Error { + constructor(message: string, name: string) { + super(message) + this.name = name + } + }, +})) + +vi.mock('@/lib/mermaid/validate', () => ({ + validateMermaidSource: mocks.validateMermaidSource, +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + loadActiveWorkspaceFileContext: mocks.loadContext, +})) + +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + fetchWorkspaceFileBuffer: mocks.fetchBuffer, + getWorkspaceFile: mocks.getFile, +})) + +import { SandboxUserCodeError } from '@/lib/execution/sandbox/run-task' +import { compiledCheckWorkspaceFile } from '@/lib/workspace-files/application/compiled-check-workspace-file' + +const sessionPrincipal = { + kind: 'session' as const, + userId: 'current-user', + sessionId: 'session-1', +} + +function mockFile(name: string) { + mocks.getFile.mockResolvedValue({ + id: 'file-1', + workspaceId: 'workspace-1', + name, + size: 20, + uploadedBy: 'original-uploader', + }) + mocks.fetchBuffer.mockResolvedValue(Buffer.from('source code')) +} + +describe('compiledCheckWorkspaceFile', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.getE2BDocFormat.mockResolvedValue(null) + mocks.resolvePermission.mockResolvedValue('admin') + mocks.loadContext.mockResolvedValue({ + fileId: 'file-1', + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: false, + billedAccountUserId: 'billing-owner', + }) + mocks.runSandboxTask.mockResolvedValue(Buffer.from('compiled')) + }) + + it('rejects API-key principals before canonical loading or business execution', async () => { + const unsupportedPrincipals = [ + { kind: 'personal_api_key' as const, userId: 'user-1', keyId: 'personal-key' }, + { + kind: 'workspace_api_key' as const, + workspaceId: 'workspace-1', + keyId: 'workspace-key', + }, + ] + + for (const principal of unsupportedPrincipals) { + await expect( + compiledCheckWorkspaceFile.execute({ + principal, + input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-1' }, + }) + ).rejects.toMatchObject({ + code: 'forbidden', + message: `Principal kind ${principal.kind} cannot perform operation files.compiled_check`, + }) + } + + expect(compiledCheckWorkspaceFile.operation).toMatchObject({ + id: 'files.compiled_check', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }) + expect(mocks.loadContext).not.toHaveBeenCalled() + expect(mocks.getFile).not.toHaveBeenCalled() + expect(mocks.fetchBuffer).not.toHaveBeenCalled() + }) + + it('uses the current session user as the legacy sandbox owner, never the uploader', async () => { + mockFile('report.pptx') + + await expect( + compiledCheckWorkspaceFile.execute({ + principal: sessionPrincipal, + input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-1' }, + }) + ).resolves.toEqual({ ok: true }) + + expect(mocks.runSandboxTask).toHaveBeenCalledWith( + 'document-pptx', + { code: 'source code', workspaceId: 'workspace-1' }, + { ownerKey: 'user:current-user' } + ) + expect(mocks.runSandboxTask).not.toHaveBeenCalledWith(expect.anything(), expect.anything(), { + ownerKey: 'user:original-uploader', + }) + expect(mocks.loadContext).toHaveBeenCalledTimes(1) + expect(mocks.resolvePermission).toHaveBeenCalledTimes(1) + expect(mocks.getFile).toHaveBeenCalledTimes(1) + expect(mocks.fetchBuffer).toHaveBeenCalledTimes(1) + }) + + it('preserves legacy sandbox user-code failures in the successful response envelope', async () => { + mockFile('report.pptx') + mocks.runSandboxTask.mockRejectedValue( + new SandboxUserCodeError('Presentation source is invalid', 'SyntaxError') + ) + + await expect( + compiledCheckWorkspaceFile.execute({ + principal: sessionPrincipal, + input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-1' }, + }) + ).resolves.toEqual({ + ok: false, + error: 'Presentation source is invalid', + errorName: 'SyntaxError', + }) + }) + + it('keeps Mermaid validation on the in-process path', async () => { + mockFile('diagram.mmd') + mocks.validateMermaidSource.mockResolvedValue({ + ok: false, + error: 'Unexpected token', + errorName: 'MermaidError', + }) + + await expect( + compiledCheckWorkspaceFile.execute({ + principal: sessionPrincipal, + input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-1' }, + }) + ).resolves.toEqual({ + ok: false, + error: 'Unexpected token', + errorName: 'MermaidError', + }) + + expect(mocks.validateMermaidSource).toHaveBeenCalledWith('source code') + expect(mocks.runE2BCompiledCheck).not.toHaveBeenCalled() + expect(mocks.runSandboxTask).not.toHaveBeenCalled() + }) + + it('keeps E2B user-code failures in the successful response envelope', async () => { + mockFile('report.pptx') + mocks.getE2BDocFormat.mockResolvedValue({ ext: 'pptx' }) + mocks.runE2BCompiledCheck.mockResolvedValue({ ok: false, error: 'Script failed' }) + + await expect( + compiledCheckWorkspaceFile.execute({ + principal: sessionPrincipal, + input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-1' }, + }) + ).resolves.toEqual({ + ok: false, + error: 'Script failed', + errorName: 'CompiledCheckError', + }) + + expect(mocks.runE2BCompiledCheck).toHaveBeenCalledWith({ + source: 'source code', + fileName: 'report.pptx', + workspaceId: 'workspace-1', + ext: 'pptx', + principal: sessionPrincipal, + }) + expect(mocks.runSandboxTask).not.toHaveBeenCalled() + }) + + it('propagates E2B infrastructure failures', async () => { + mockFile('report.pptx') + mocks.getE2BDocFormat.mockResolvedValue({ ext: 'pptx' }) + const failure = new Error('E2B unavailable') + mocks.runE2BCompiledCheck.mockRejectedValue(failure) + + await expect( + compiledCheckWorkspaceFile.execute({ + principal: sessionPrincipal, + input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-1' }, + }) + ).rejects.toBe(failure) + + expect(mocks.runSandboxTask).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workspace-files/application/compiled-check-workspace-file.ts b/apps/sim/lib/workspace-files/application/compiled-check-workspace-file.ts index 0b155cb7859..f10d806d9a4 100644 --- a/apps/sim/lib/workspace-files/application/compiled-check-workspace-file.ts +++ b/apps/sim/lib/workspace-files/application/compiled-check-workspace-file.ts @@ -1,13 +1,16 @@ -import type { Principal } from '@sim/auth/principal' import { getE2BDocFormat } from '@/lib/copilot/tools/server/files/doc-compile' import { runE2BCompiledCheck } from '@/lib/copilot/tools/server/files/doc-recalc' +import type { AuthorizedWorkspaceUseCaseContext } from '@/lib/core/application' import { isDocSandboxEnabled } from '@/lib/core/config/env-flags' -import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { BINARY_DOC_TASKS, MAX_DOCUMENT_PREVIEW_CODE_BYTES } from '@/lib/execution/constants' import { runSandboxTask, SandboxUserCodeError } from '@/lib/execution/sandbox/run-task' import { validateMermaidSource } from '@/lib/mermaid/validate' -import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' -import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' +import { fetchWorkspaceFileBuffer, getWorkspaceFile } from '@/lib/uploads/contexts/workspace' +import type { ActiveWorkspaceFileContext } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { resolveActiveWorkspaceFileContext } from '@/lib/workspace-files/application/workspace-file-context' export class CompiledCheckUnsupportedError extends Error { constructor() { @@ -47,13 +50,16 @@ function normalizeCompiledCheckResult(result: { async function executeCompiledCheckWorkspaceFile({ principal, - input, -}: { - principal: Principal - input: CompiledCheckWorkspaceFileInput - request?: OrchestrationRequestContext -}): Promise { - const { file } = await readWorkspaceFileMetadata.execute({ principal, input }) + context, +}: AuthorizedWorkspaceUseCaseContext< + typeof fileOperations.compiledCheck, + CompiledCheckWorkspaceFileInput, + ActiveWorkspaceFileContext +>): Promise { + const file = await getWorkspaceFile(context.workspaceId, context.fileId, { + throwOnError: true, + }) + if (!file) throw new OrchestrationError('not_found', 'File not found') const ext = file.name.split('.').pop()?.toLowerCase() ?? '' const e2bFmt = isDocSandboxEnabled ? await getE2BDocFormat(file.name) : null const taskId = BINARY_DOC_TASKS[ext] @@ -64,9 +70,8 @@ async function executeCompiledCheckWorkspaceFile({ throw new CompiledCheckTooLargeError() } - const { content } = await readWorkspaceFileContent.execute({ - principal, - input: { ...input, maxBytes: MAX_DOCUMENT_PREVIEW_CODE_BYTES }, + const content = await fetchWorkspaceFileBuffer(file, { + maxBytes: MAX_DOCUMENT_PREVIEW_CODE_BYTES, }) const code = content.toString('utf-8') @@ -88,16 +93,10 @@ async function executeCompiledCheckWorkspaceFile({ try { if (!taskId) throw new CompiledCheckUnsupportedError() - const ownerId = - principal.kind === 'session' || principal.kind === 'personal_api_key' - ? principal.userId - : principal.kind === 'delegated' - ? principal.subjectUserId - : file.uploadedBy await runSandboxTask( taskId, { code, workspaceId: file.workspaceId }, - { ownerKey: `user:${ownerId}` } + { ownerKey: `user:${principal.userId}` } ) return { ok: true } } catch (error) { @@ -108,7 +107,8 @@ async function executeCompiledCheckWorkspaceFile({ } } -export const compiledCheckWorkspaceFile = { - operation: readWorkspaceFileContent.operation, +export const compiledCheckWorkspaceFile = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.compiledCheck, + resolveContext: ({ input }) => resolveActiveWorkspaceFileContext(input), execute: executeCompiledCheckWorkspaceFile, -} as const +}) diff --git a/apps/sim/lib/workspace-files/application/create-workspace-file.ts b/apps/sim/lib/workspace-files/application/create-workspace-file.ts index ea72e46ff12..edcbc830a91 100644 --- a/apps/sim/lib/workspace-files/application/create-workspace-file.ts +++ b/apps/sim/lib/workspace-files/application/create-workspace-file.ts @@ -1,12 +1,7 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { - type Principal, - resolvePrincipalAttribution, - resolvePrincipalAuditAttribution, -} from '@sim/auth/principal' +import { AuditAction, AuditResourceType } from '@sim/audit' +import { type Principal, resolvePrincipalAttribution } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { getPostgresErrorCode } from '@sim/utils/errors' -import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' import { OrchestrationError } from '@/lib/core/orchestration/types' import { FileConflictError, @@ -16,7 +11,7 @@ import { } from '@/lib/uploads/contexts/workspace' import type { WorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' import { EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' -import { authorizeWorkspaceFileAccess } from '@/lib/workspace-files/application/authorization' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' import { fileOperations } from '@/lib/workspace-files/application/operations' import { MAX_WORKSPACE_FILE_CONTENT_BYTES } from '@/lib/workspace-files/orchestration' @@ -43,76 +38,26 @@ export interface CreateWorkspaceFileBufferInput content: Buffer } -interface CreateWorkspaceFileArguments { - principal: Principal - input: CreateWorkspaceFileInput - request?: OrchestrationRequestContext -} - -interface CreateWorkspaceFileBufferArguments { - principal: Principal - input: CreateWorkspaceFileBufferInput - request?: OrchestrationRequestContext -} - -async function requireCreateWorkspaceFileAccess(principal: Principal, workspaceId: string) { +async function resolveCreateWorkspaceFileContext(workspaceId: string) { const workspace = await loadActiveWorkspaceContext(workspaceId) if (!workspace) throw new OrchestrationError('not_found', 'Workspace not found') - await authorizeWorkspaceFileAccess(principal, fileOperations.create, workspace) return workspace } -async function executeCreateWorkspaceFile({ - principal, - input, - request, -}: CreateWorkspaceFileArguments): Promise { - const workspace = await requireCreateWorkspaceFileAccess(principal, input.workspaceId) - - const content = Buffer.from(input.content, input.encoding === 'base64' ? 'base64' : 'utf-8') - if (content.length > MAX_WORKSPACE_FILE_CONTENT_BYTES) { - throw new OrchestrationError( - 'payload_too_large', - `File size exceeds ${MAX_WORKSPACE_FILE_CONTENT_BYTES / 1024 / 1024}MB limit` - ) - } - - return createAuthorizedWorkspaceFile({ principal, input, content, request, workspace }) -} - -async function executeCreateWorkspaceFileBuffer({ - principal, - input, - request, -}: CreateWorkspaceFileBufferArguments): Promise { - const workspace = await requireCreateWorkspaceFileAccess(principal, input.workspaceId) - return createAuthorizedWorkspaceFile({ - principal, - input, - content: input.content, - request, - workspace, - }) -} - async function createAuthorizedWorkspaceFile({ principal, input, content, - request, workspace, }: { principal: Principal input: Omit content: Buffer - request?: OrchestrationRequestContext - workspace: Awaited> + workspace: Awaited> }): Promise { const attribution = resolvePrincipalAttribution(principal, { workspaceBillingOwnerUserId: workspace.billedAccountUserId, }) - const auditAttribution = resolvePrincipalAuditAttribution(principal) - let file: WorkspaceFileRecord try { file = await uploadWorkspaceFile( @@ -142,34 +87,64 @@ async function createAuthorizedWorkspaceFile({ size: file.size, principalKind: principal.kind, }) - recordAudit({ - workspaceId: workspace.workspaceId, - actorId: auditAttribution.actorId, - actorName: auditAttribution.actorName, + return { file } +} + +function projectCreateWorkspaceFileAudit(result: CreateWorkspaceFileResult) { + return { action: AuditAction.FILE_UPLOADED, resourceType: AuditResourceType.FILE, - resourceId: file.id, - resourceName: file.name, - description: `Uploaded file "${file.name}"`, + resourceId: result.file.id, + resourceName: result.file.name, + description: `Uploaded file "${result.file.name}"`, metadata: { - operation: fileOperations.create.id, - actor: auditAttribution.actor, - fileSize: file.size, - fileType: file.type, + fileSize: result.file.size, + fileType: result.file.type, }, - request, - }) + } as const +} - return { file } +const admitCreateWorkspaceFileUseCase = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.create, + resolveContext: ({ input }: { input: { workspaceId: string } }) => + resolveCreateWorkspaceFileContext(input.workspaceId), + async execute() {}, +}) + +export async function admitCreateWorkspaceFile( + principal: Principal, + workspaceId: string +): Promise { + await admitCreateWorkspaceFileUseCase.execute({ principal, input: { workspaceId } }) } -export const createWorkspaceFile = { +export const createWorkspaceFile = defineAuthorizedWorkspaceFileUseCase({ operation: fileOperations.create, - admit: requireCreateWorkspaceFileAccess, - execute: executeCreateWorkspaceFile, -} as const + resolveContext: ({ input }: { input: CreateWorkspaceFileInput }) => + resolveCreateWorkspaceFileContext(input.workspaceId), + async execute({ principal, input, context }): Promise { + const content = Buffer.from(input.content, input.encoding === 'base64' ? 'base64' : 'utf-8') + if (content.length > MAX_WORKSPACE_FILE_CONTENT_BYTES) { + throw new OrchestrationError( + 'payload_too_large', + `File size exceeds ${MAX_WORKSPACE_FILE_CONTENT_BYTES / 1024 / 1024}MB limit` + ) + } + return createAuthorizedWorkspaceFile({ principal, input, content, workspace: context }) + }, + projectAudit: ({ result }) => projectCreateWorkspaceFileAudit(result), +}) -export const createWorkspaceFileFromBuffer = { +export const createWorkspaceFileFromBuffer = defineAuthorizedWorkspaceFileUseCase({ operation: fileOperations.create, - execute: executeCreateWorkspaceFileBuffer, -} as const + resolveContext: ({ input }: { input: CreateWorkspaceFileBufferInput }) => + resolveCreateWorkspaceFileContext(input.workspaceId), + execute: ({ principal, input, context }) => + createAuthorizedWorkspaceFile({ + principal, + input, + content: input.content, + workspace: context, + }), + projectAudit: ({ result }) => projectCreateWorkspaceFileAudit(result), +}) diff --git a/apps/sim/lib/workspace-files/application/delete-workspace-file.test.ts b/apps/sim/lib/workspace-files/application/delete-workspace-file.test.ts index 274ae732ad3..21058040263 100644 --- a/apps/sim/lib/workspace-files/application/delete-workspace-file.test.ts +++ b/apps/sim/lib/workspace-files/application/delete-workspace-file.test.ts @@ -4,12 +4,18 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - loadAuthorized: vi.fn(), + loadContext: vi.fn(), deleteStored: vi.fn(), + resolvePermission: vi.fn(), recordAudit: vi.fn(), notify: vi.fn(), })) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: () => true, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + vi.mock('@sim/audit', () => ({ AuditAction: { FILE_DELETED: 'FILE_DELETED' }, AuditResourceType: { FILE: 'FILE' }, @@ -18,9 +24,7 @@ vi.mock('@sim/audit', () => ({ vi.mock('@/lib/realtime/notify', () => ({ notifyWorkspaceFilesChanged: mocks.notify })) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ deleteWorkspaceFile: mocks.deleteStored, -})) -vi.mock('@/lib/workspace-files/application/load-authorized-workspace-file', () => ({ - loadAuthorizedWorkspaceFile: mocks.loadAuthorized, + loadActiveWorkspaceFileContext: mocks.loadContext, })) import { deleteWorkspaceFileOperation } from '@/lib/workspace-files/application/delete-workspace-file' @@ -36,8 +40,9 @@ const context = { describe('deleteWorkspaceFileOperation', () => { beforeEach(() => { vi.clearAllMocks() - mocks.loadAuthorized.mockResolvedValue(context) + mocks.loadContext.mockResolvedValue(context) mocks.deleteStored.mockResolvedValue(undefined) + mocks.resolvePermission.mockResolvedValue('admin') mocks.notify.mockResolvedValue(undefined) }) @@ -48,12 +53,8 @@ describe('deleteWorkspaceFileOperation', () => { }) expect(result).toEqual({ id: 'file-1', workspaceId: 'workspace-1', deleted: true }) - expect(mocks.loadAuthorized).toHaveBeenCalledWith({ - principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, - operation: expect.objectContaining({ id: 'files.delete' }), - fileId: 'file-1', - assertedWorkspaceId: 'workspace-1', - }) + expect(mocks.loadContext).toHaveBeenCalledWith('file-1', { includeDeleted: undefined }) + expect(mocks.resolvePermission).toHaveBeenCalled() expect(mocks.deleteStored).toHaveBeenCalledWith('workspace-1', 'file-1') expect(mocks.recordAudit).toHaveBeenCalledWith( expect.objectContaining({ @@ -63,6 +64,9 @@ describe('deleteWorkspaceFileOperation', () => { }) ) expect(mocks.notify).toHaveBeenCalledWith('workspace-1') + expect(mocks.recordAudit.mock.invocationCallOrder[0]).toBeLessThan( + mocks.notify.mock.invocationCallOrder[0] + ) }) it('does not emit side effects when storage fails', async () => { diff --git a/apps/sim/lib/workspace-files/application/delete-workspace-file.ts b/apps/sim/lib/workspace-files/application/delete-workspace-file.ts index 62fd7ca8b2f..1e29b040c83 100644 --- a/apps/sim/lib/workspace-files/application/delete-workspace-file.ts +++ b/apps/sim/lib/workspace-files/application/delete-workspace-file.ts @@ -1,11 +1,14 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { type Principal, resolvePrincipalAuditAttribution } from '@sim/auth/principal' +import { AuditAction, AuditResourceType } from '@sim/audit' import { createLogger } from '@sim/logger' -import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' +import type { AuthorizedWorkspaceUseCaseContext } from '@/lib/core/application' import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' -import { deleteWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { loadAuthorizedWorkspaceFile } from '@/lib/workspace-files/application/load-authorized-workspace-file' +import { + type ActiveWorkspaceFileContext, + deleteWorkspaceFile, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' import { fileOperations } from '@/lib/workspace-files/application/operations' +import { resolveActiveWorkspaceFileContext } from '@/lib/workspace-files/application/workspace-file-context' const logger = createLogger('DeleteWorkspaceFile') @@ -20,48 +23,34 @@ export interface DeleteWorkspaceFileResult { deleted: true } -interface DeleteWorkspaceFileArguments { - principal: Principal - input: DeleteWorkspaceFileInput - request?: OrchestrationRequestContext -} - async function executeDeleteWorkspaceFile({ principal, - input, - request, -}: DeleteWorkspaceFileArguments): Promise { - const canonical = await loadAuthorizedWorkspaceFile({ - principal, - operation: fileOperations.delete, - fileId: input.fileId, - assertedWorkspaceId: input.assertedWorkspaceId, - }) - - const auditAttribution = resolvePrincipalAuditAttribution(principal) - await deleteWorkspaceFile(canonical.workspaceId, canonical.fileId) - - recordAudit({ - workspaceId: canonical.workspaceId, - actorId: auditAttribution.actorId, - actorName: auditAttribution.actorName, - action: AuditAction.FILE_DELETED, - resourceType: AuditResourceType.FILE, - resourceId: canonical.fileId, - description: `Deleted workspace file ${canonical.fileId}`, - metadata: { operation: fileOperations.delete.id, actor: auditAttribution.actor }, - request, - }) - await notifyWorkspaceFilesChanged(canonical.workspaceId) - logger.info('Deleted workspace file', { - workspaceId: canonical.workspaceId, - fileId: canonical.fileId, - principalKind: principal.kind, - }) - return { id: canonical.fileId, workspaceId: canonical.workspaceId, deleted: true } + context, +}: AuthorizedWorkspaceUseCaseContext< + typeof fileOperations.delete, + DeleteWorkspaceFileInput, + ActiveWorkspaceFileContext +>): Promise { + await deleteWorkspaceFile(context.workspaceId, context.fileId) + return { id: context.fileId, workspaceId: context.workspaceId, deleted: true } } -export const deleteWorkspaceFileOperation = { +export const deleteWorkspaceFileOperation = defineAuthorizedWorkspaceFileUseCase({ operation: fileOperations.delete, + resolveContext: ({ input }) => resolveActiveWorkspaceFileContext(input), execute: executeDeleteWorkspaceFile, -} as const + projectAudit: ({ result }) => ({ + action: AuditAction.FILE_DELETED, + resourceType: AuditResourceType.FILE, + resourceId: result.id, + description: `Deleted workspace file ${result.id}`, + }), + async afterSuccess({ principal, result }) { + await notifyWorkspaceFilesChanged(result.workspaceId) + logger.info('Deleted workspace file', { + workspaceId: result.workspaceId, + fileId: result.id, + principalKind: principal.kind, + }) + }, +}) diff --git a/apps/sim/lib/workspace-files/application/download-workspace-file-items.test.ts b/apps/sim/lib/workspace-files/application/download-workspace-file-items.test.ts index 388a0326869..459baa164cb 100644 --- a/apps/sim/lib/workspace-files/application/download-workspace-file-items.test.ts +++ b/apps/sim/lib/workspace-files/application/download-workspace-file-items.test.ts @@ -4,7 +4,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const { - mockAuthorize, + events, + mockLoadContext, + mockResolvePermission, mockListFiles, mockListFolders, mockFetchServable, @@ -13,7 +15,9 @@ const { mockIsRenderable, mockIsDocNotReady, } = vi.hoisted(() => ({ - mockAuthorize: vi.fn(), + events: [] as string[], + mockLoadContext: vi.fn(), + mockResolvePermission: vi.fn(), mockListFiles: vi.fn(), mockListFolders: vi.fn(), mockFetchServable: vi.fn(), @@ -23,15 +27,18 @@ const { mockIsDocNotReady: vi.fn(), })) -vi.mock('@/lib/workspace-files/application/workspace-operation-context', () => ({ - authorizeWorkspaceFileOperation: mockAuthorize, -})) vi.mock('@/lib/uploads/contexts/workspace', () => ({ buildWorkspaceFileFolderPathMap: (folders: Array<{ id: string; path?: string; name: string }>) => new Map(folders.map((folder) => [folder.id, folder.path ?? folder.name])), fetchServableWorkspaceFileBuffer: mockFetchServable, listWorkspaceFileFolders: mockListFolders, listWorkspaceFiles: mockListFiles, + loadWorkspaceFileOperationContext: mockLoadContext, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => + actual === 'admin' || actual === required || (actual === 'write' && required === 'read'), + resolveEffectiveWorkspacePermission: mockResolvePermission, })) vi.mock('@/lib/uploads/utils/file-utils', () => ({ formatFileSize: (bytes: number) => `${bytes} bytes`, @@ -52,6 +59,17 @@ vi.mock('@sim/audit', () => ({ import { downloadWorkspaceFileItems } from '@/lib/workspace-files/application/download-workspace-file-items' const principal = { kind: 'session' as const, userId: 'u1', sessionId: 's1' } +const delegatedPrincipal = { + kind: 'delegated' as const, + serviceId: 'copilot' as const, + subjectUserId: 'u1', + workspaceId: 'ws-1', + delegationId: 'delegation-1', + audience: 'sim:workspace-files', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2099-01-01T00:00:00Z'), + resourceScope: { fileId: 'f1' }, +} const workspace = { workspaceId: 'ws-1', workspaceOrganizationId: null, @@ -66,14 +84,19 @@ function file(id: string, name: string, folderId: string | null = null, size = 1 describe('downloadWorkspaceFileItems', () => { beforeEach(() => { vi.clearAllMocks() - mockAuthorize.mockResolvedValue({ - context: workspace, - attribution: { - attributedUserId: 'u1', - actor: { kind: 'session', userId: 'u1' }, - }, + events.length = 0 + mockLoadContext.mockImplementation(async () => { + events.push('resolve') + return workspace + }) + mockResolvePermission.mockImplementation(async () => { + events.push('authorize') + return 'read' + }) + mockListFiles.mockImplementation(async () => { + events.push('execute') + return [file('f1', 'clip.mp4')] }) - mockListFiles.mockResolvedValue([file('f1', 'clip.mp4')]) mockListFolders.mockResolvedValue([]) mockIsGenerated.mockReturnValue(false) mockIsRenderable.mockReturnValue(false) @@ -86,17 +109,40 @@ describe('downloadWorkspaceFileItems', () => { input: { workspaceId: 'ws-1', fileIds: ['f1'], folderIds: [] }, }) - expect(mockAuthorize).toHaveBeenCalledWith( - principal, - expect.objectContaining({ id: 'files.download' }), - 'ws-1' - ) + expect(events).toEqual(['resolve', 'authorize', 'execute']) expect(result.filesToZip).toHaveLength(1) expect(mockRecordAudit).toHaveBeenCalledWith( expect.objectContaining({ actorId: 'u1', workspaceId: 'ws-1' }) ) }) + it('allows a file-scoped delegated principal to download its one explicit file', async () => { + const result = await downloadWorkspaceFileItems.execute({ + principal: delegatedPrincipal, + input: { workspaceId: 'ws-1', fileIds: ['f1'], folderIds: [] }, + }) + + expect(result.filesToZip.map((item) => item.id)).toEqual(['f1']) + expect(mockResolvePermission).toHaveBeenCalledOnce() + }) + + it.each([ + { label: 'multiple files', fileIds: ['f1', 'f2'], folderIds: [] }, + { label: 'a folder', fileIds: [], folderIds: ['folder-1'] }, + { label: 'a file and folder', fileIds: ['f1'], folderIds: ['folder-1'] }, + ])('denies a file-scoped delegated principal selecting $label before listing', async (input) => { + await expect( + downloadWorkspaceFileItems.execute({ + principal: delegatedPrincipal, + input: { workspaceId: 'ws-1', ...input }, + }) + ).rejects.toThrow('Delegated workspace access is no longer valid') + + expect(mockResolvePermission).not.toHaveBeenCalled() + expect(mockListFiles).not.toHaveBeenCalled() + expect(mockListFolders).not.toHaveBeenCalled() + }) + it('expands selected folders and renders generated documents before returning', async () => { mockListFolders.mockResolvedValue([ { id: 'folder-1', name: 'Reports', path: 'Reports', parentId: null }, @@ -125,7 +171,7 @@ describe('downloadWorkspaceFileItems', () => { input: { workspaceId: 'ws-1', fileIds: [], folderIds: [] }, }) ).rejects.toMatchObject({ code: 'validation' }) - expect(mockAuthorize).not.toHaveBeenCalled() + expect(events).toEqual(['resolve', 'authorize']) mockListFiles.mockResolvedValue([file('f1', 'pending.docx')]) mockIsGenerated.mockReturnValue(true) diff --git a/apps/sim/lib/workspace-files/application/download-workspace-file-items.ts b/apps/sim/lib/workspace-files/application/download-workspace-file-items.ts index fabba422874..4509350875a 100644 --- a/apps/sim/lib/workspace-files/application/download-workspace-file-items.ts +++ b/apps/sim/lib/workspace-files/application/download-workspace-file-items.ts @@ -1,6 +1,4 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { type Principal, resolvePrincipalAuditAttribution } from '@sim/auth/principal' -import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' +import { AuditAction, AuditResourceType } from '@sim/audit' import { OrchestrationError } from '@/lib/core/orchestration/types' import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { @@ -8,6 +6,7 @@ import { fetchServableWorkspaceFileBuffer, listWorkspaceFileFolders, listWorkspaceFiles, + loadWorkspaceFileOperationContext, type WorkspaceFileRecord, } from '@/lib/uploads/contexts/workspace' import { @@ -17,8 +16,8 @@ import { MAX_RENDERED_DOCUMENT_BYTES, } from '@/lib/uploads/utils/file-utils' import { docNotReadyMessage, isDocNotReadyError } from '@/lib/uploads/utils/servable-file-response' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' import { fileOperations } from '@/lib/workspace-files/application/operations' -import { authorizeWorkspaceFileOperation } from '@/lib/workspace-files/application/workspace-operation-context' export const MAX_ZIP_DOWNLOAD_FILES = 100 export const MAX_ZIP_DOWNLOAD_BYTES = 250 * 1024 * 1024 @@ -65,32 +64,26 @@ function validationError(message: string): never { } async function executeDownloadWorkspaceFileItems({ - principal, input, - request, + context, }: { - principal: Principal input: DownloadWorkspaceFileItemsInput - request?: OrchestrationRequestContext + context: Awaited> }): Promise { - if (input.fileIds.length > MAX_REQUESTED_FILE_IDS) { + const fileIds = [...new Set(input.fileIds)] + const folderIds = [...new Set(input.folderIds)] + if (fileIds.length > MAX_REQUESTED_FILE_IDS) { validationError(`Too many file IDs selected. Select ${MAX_REQUESTED_FILE_IDS} or fewer files.`) } - if (input.folderIds.length > MAX_REQUESTED_FOLDER_IDS) { + if (folderIds.length > MAX_REQUESTED_FOLDER_IDS) { validationError( `Too many folder IDs selected. Select ${MAX_REQUESTED_FOLDER_IDS} or fewer folders.` ) } - if (input.fileIds.length === 0 && input.folderIds.length === 0) { + if (fileIds.length === 0 && folderIds.length === 0) { validationError('No files selected for download') } - const { context } = await authorizeWorkspaceFileOperation( - principal, - fileOperations.download, - input.workspaceId - ) - const auditAttribution = resolvePrincipalAuditAttribution(principal) const [files, folders] = await Promise.all([ listWorkspaceFiles(context.workspaceId, { hydrateFolderPaths: false, throwOnError: true }), listWorkspaceFileFolders(context.workspaceId), @@ -99,13 +92,13 @@ async function executeDownloadWorkspaceFileItems({ const knownFileIds = new Set(files.map((file) => file.id)) const knownFolderIds = new Set(folders.map((folder) => folder.id)) if ( - input.fileIds.some((fileId) => !knownFileIds.has(fileId)) || - input.folderIds.some((folderId) => !knownFolderIds.has(folderId)) + fileIds.some((fileId) => !knownFileIds.has(fileId)) || + folderIds.some((folderId) => !knownFolderIds.has(folderId)) ) { throw new OrchestrationError('not_found', 'File selection not found') } - const selectedFolderIds = collectDescendantFolderIds(input.folderIds, folders) - const requestedFileIds = new Set(input.fileIds) + const selectedFolderIds = collectDescendantFolderIds(folderIds, folders) + const requestedFileIds = new Set(fileIds) const filesToZip = files.filter( (file) => requestedFileIds.has(file.id) || @@ -158,26 +151,33 @@ async function executeDownloadWorkspaceFileItems({ throw new OrchestrationError('conflict', docNotReadyMessage(pendingNames)) } - recordAudit({ - workspaceId: context.workspaceId, - actorId: auditAttribution.actorId, - actorName: auditAttribution.actorName, - action: AuditAction.FILE_DOWNLOADED, - resourceType: AuditResourceType.FILE, - description: `Downloaded ${filesToZip.length} file${filesToZip.length === 1 ? '' : 's'} as zip`, - metadata: { - operation: fileOperations.download.id, - actor: auditAttribution.actor, - fileCount: filesToZip.length, - totalBytes: declaredBytes, - }, - request, - }) - return { filesToZip, folderPaths, renderedDocuments, declaredBytes } } -export const downloadWorkspaceFileItems = { +async function resolveDownloadContext({ input }: { input: DownloadWorkspaceFileItemsInput }) { + const context = await loadWorkspaceFileOperationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + const fileIds = [...new Set(input.fileIds)] + const folderIds = [...new Set(input.folderIds)] + return { + ...context, + fileId: fileIds.length === 1 && folderIds.length === 0 ? fileIds[0] : undefined, + } +} + +export const downloadWorkspaceFileItems = defineAuthorizedWorkspaceFileUseCase({ operation: fileOperations.download, + resolveContext: resolveDownloadContext, execute: executeDownloadWorkspaceFileItems, -} as const + projectAudit({ result }) { + return { + action: AuditAction.FILE_DOWNLOADED, + resourceType: AuditResourceType.FILE, + description: `Downloaded ${result.filesToZip.length} file${result.filesToZip.length === 1 ? '' : 's'} as zip`, + metadata: { + fileCount: result.filesToZip.length, + totalBytes: result.declaredBytes, + }, + } + }, +}) diff --git a/apps/sim/lib/workspace-files/application/download-workspace-file.test.ts b/apps/sim/lib/workspace-files/application/download-workspace-file.test.ts new file mode 100644 index 00000000000..37e932ca15f --- /dev/null +++ b/apps/sim/lib/workspace-files/application/download-workspace-file.test.ts @@ -0,0 +1,104 @@ +/** + * @vitest-environment node + */ +import { Readable } from 'node:stream' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + downloadStream: vi.fn(), + getFile: vi.fn(), + loadContext: vi.fn(), + recordAudit: vi.fn(), + resolvePermission: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + AuditAction: { FILE_DOWNLOADED: 'FILE_DOWNLOADED' }, + AuditResourceType: { FILE: 'FILE' }, + recordAudit: mocks.recordAudit, +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: () => true, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + getWorkspaceFile: mocks.getFile, + loadActiveWorkspaceFileContext: mocks.loadContext, +})) + +vi.mock('@/lib/uploads/core/storage-service', () => ({ + downloadFileStream: mocks.downloadStream, +})) + +import { + downloadWorkspaceFile, + downloadWorkspaceFileStream, +} from '@/lib/workspace-files/application/download-workspace-file' + +const context = { + fileId: 'file-1', + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner', +} + +const file = { + id: 'file-1', + workspaceId: 'workspace-1', + name: 'report.pdf', + key: 'workspace/workspace-1/report.pdf', + size: 42, + storageContext: 'workspace', +} + +describe('workspace file downloads', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadContext.mockResolvedValue(context) + mocks.resolvePermission.mockResolvedValue('admin') + mocks.getFile.mockResolvedValue(file) + mocks.downloadStream.mockResolvedValue(Readable.from(Buffer.from('pdf'))) + }) + + it('returns the authoritative file and records its semantic download audit', async () => { + await expect( + downloadWorkspaceFile.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-1' }, + }) + ).resolves.toEqual({ file }) + + expect(mocks.recordAudit).toHaveBeenCalledWith( + expect.objectContaining({ + workspaceId: 'workspace-1', + actorId: 'user-1', + action: 'FILE_DOWNLOADED', + resourceId: 'file-1', + resourceName: 'report.pdf', + metadata: expect.objectContaining({ + operation: 'files.download', + fileId: 'file-1', + fileName: 'report.pdf', + bytes: 42, + }), + }) + ) + }) + + it('does not audit a streaming download when storage acquisition fails', async () => { + const failure = new Error('storage unavailable') + mocks.downloadStream.mockRejectedValueOnce(failure) + + await expect( + downloadWorkspaceFileStream.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { fileId: 'file-1' }, + }) + ).rejects.toBe(failure) + + expect(mocks.recordAudit).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workspace-files/application/download-workspace-file.ts b/apps/sim/lib/workspace-files/application/download-workspace-file.ts index f9236d24c16..70be04fbe93 100644 --- a/apps/sim/lib/workspace-files/application/download-workspace-file.ts +++ b/apps/sim/lib/workspace-files/application/download-workspace-file.ts @@ -1,9 +1,15 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { type Principal, resolvePrincipalAuditAttribution } from '@sim/auth/principal' -import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' +import { AuditAction, AuditResourceType } from '@sim/audit' +import type { AuthorizedWorkspaceUseCaseContext } from '@/lib/core/application' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { nodeReadableToWebStream } from '@/lib/core/utils/node-stream' +import { + type ActiveWorkspaceFileContext, + getWorkspaceFile, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { downloadFileStream } from '@/lib/uploads/core/storage-service' -import { downloadWorkspaceFileRecord } from '@/lib/workspace-files/application/read-workspace-file-record' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { resolveActiveWorkspaceFileContext } from '@/lib/workspace-files/application/workspace-file-context' export interface DownloadWorkspaceFileInput { fileId: string @@ -11,24 +17,15 @@ export interface DownloadWorkspaceFileInput { } export interface DownloadWorkspaceFileResult { - file: Awaited>['file'] + file: NonNullable>> } export interface DownloadWorkspaceFileStreamResult extends DownloadWorkspaceFileResult { stream: ReadableStream } -function recordDownloadAudit( - file: DownloadWorkspaceFileResult['file'], - principal: Principal, - request?: OrchestrationRequestContext -) { - const auditAttribution = resolvePrincipalAuditAttribution(principal) - - recordAudit({ - workspaceId: file.workspaceId, - actorId: auditAttribution.actorId, - actorName: auditAttribution.actorName, +function projectDownloadAudit(file: DownloadWorkspaceFileResult['file']) { + return { action: AuditAction.FILE_DOWNLOADED, resourceType: AuditResourceType.FILE, resourceId: file.id, @@ -38,55 +35,53 @@ function recordDownloadAudit( fileId: file.id, fileName: file.name, bytes: file.size, - actor: auditAttribution.actor, }, - request, - }) + } } async function executeDownloadWorkspaceFile({ - principal, - input, - request, -}: { - principal: Principal - input: DownloadWorkspaceFileInput - request?: OrchestrationRequestContext -}): Promise { - const result = await downloadWorkspaceFileRecord.execute({ principal, input, request }) - recordDownloadAudit(result.file, principal, request) - return { file: result.file } + context, +}: AuthorizedWorkspaceUseCaseContext< + typeof fileOperations.download, + DownloadWorkspaceFileInput, + ActiveWorkspaceFileContext +>): Promise { + const file = await getWorkspaceFile(context.workspaceId, context.fileId, { + throwOnError: true, + }) + if (!file) throw new OrchestrationError('not_found', 'File not found') + return { file } } -export const downloadWorkspaceFile = { - operation: downloadWorkspaceFileRecord.operation, +export const downloadWorkspaceFile = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.download, + resolveContext: ({ input }) => resolveActiveWorkspaceFileContext(input), execute: executeDownloadWorkspaceFile, -} as const + projectAudit: ({ result }) => projectDownloadAudit(result.file), +}) async function executeDownloadWorkspaceFileStream({ - principal, - input, - request, -}: { - principal: Principal - input: DownloadWorkspaceFileInput - request?: OrchestrationRequestContext -}): Promise { - const result = await downloadWorkspaceFileRecord.execute({ - principal, - input, - request, + context, +}: AuthorizedWorkspaceUseCaseContext< + typeof fileOperations.download, + DownloadWorkspaceFileInput, + ActiveWorkspaceFileContext +>): Promise { + const file = await getWorkspaceFile(context.workspaceId, context.fileId, { + throwOnError: true, }) + if (!file) throw new OrchestrationError('not_found', 'File not found') const stream = await downloadFileStream({ - key: result.file.key, - context: result.file.storageContext ?? 'workspace', + key: file.key, + context: file.storageContext ?? 'workspace', }) - recordDownloadAudit(result.file, principal, request) - return { file: result.file, stream: nodeReadableToWebStream(stream) } + return { file, stream: nodeReadableToWebStream(stream) } } /** Authorized and audited binary download without materializing the file in memory. */ -export const downloadWorkspaceFileStream = { - operation: downloadWorkspaceFileRecord.operation, +export const downloadWorkspaceFileStream = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.download, + resolveContext: ({ input }) => resolveActiveWorkspaceFileContext(input), execute: executeDownloadWorkspaceFileStream, -} as const + projectAudit: ({ result }) => projectDownloadAudit(result.file), +}) diff --git a/apps/sim/lib/workspace-files/application/list-workspace-files.ts b/apps/sim/lib/workspace-files/application/list-workspace-files.ts index ddddfde405c..bbf9924e47c 100644 --- a/apps/sim/lib/workspace-files/application/list-workspace-files.ts +++ b/apps/sim/lib/workspace-files/application/list-workspace-files.ts @@ -1,4 +1,3 @@ -import type { Principal } from '@sim/auth/principal' import type { CursorKey } from '@/lib/api/list-query' import { OrchestrationError } from '@/lib/core/orchestration/types' import { ROOT_FOLDER_PATH } from '@/lib/folders/paths' @@ -9,7 +8,7 @@ import { loadActiveWorkspaceContext, queryWorkspaceFiles, } from '@/lib/uploads/contexts/workspace' -import { authorizeWorkspaceFileAccess } from '@/lib/workspace-files/application/authorization' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' import { fileOperations } from '@/lib/workspace-files/application/operations' export interface ListAllWorkspaceFilesInput { @@ -28,64 +27,49 @@ export interface QueryWorkspaceFilePageInput { cursorSort: string } -async function requireListWorkspaceFileAccess(principal: Principal, workspaceId: string) { +async function resolveListWorkspaceFileContext(workspaceId: string) { const workspace = await loadActiveWorkspaceContext(workspaceId) if (!workspace) throw new OrchestrationError('not_found', 'Workspace not found') - await authorizeWorkspaceFileAccess(principal, fileOperations.list, workspace) return workspace } -async function executeListAllWorkspaceFiles({ - principal, - input, -}: { - principal: Principal - input: ListAllWorkspaceFilesInput -}) { - const workspace = await requireListWorkspaceFileAccess(principal, input.workspaceId) - const files = await listWorkspaceFiles(workspace.workspaceId, { scope: input.scope }) - const shares = await getWorkspaceShares('file', workspace.workspaceId) - return { - files: files.map((file) => ({ ...file, share: shares.get(file.id) ?? null })), - } -} - -async function executeQueryWorkspaceFilePage({ - principal, - input, -}: { - principal: Principal - input: QueryWorkspaceFilePageInput -}) { - const workspace = await requireListWorkspaceFileAccess(principal, input.workspaceId) - const folderIndex = await loadActiveFolderPathIndex(workspace.workspaceId, 'file') - const folderId = - input.folderPath === undefined - ? undefined - : input.folderPath === ROOT_FOLDER_PATH - ? null - : folderIndex.idByPath.get(input.folderPath) - if (input.folderPath !== undefined && folderId === undefined) { - throw new OrchestrationError('not_found', 'Folder not found') - } - - const { files, nextKeys } = await queryWorkspaceFiles(workspace.workspaceId, { - folderId, - search: input.search, - sortBy: input.sortBy, - sortOrder: input.sortOrder, - limit: input.limit, - after: input.after, - }) - return { files, nextKeys, cursorSort: input.cursorSort } -} - -export const listAllWorkspaceFiles = { +export const listAllWorkspaceFiles = defineAuthorizedWorkspaceFileUseCase({ operation: fileOperations.list, - execute: executeListAllWorkspaceFiles, -} as const + resolveContext: ({ input }: { input: ListAllWorkspaceFilesInput }) => + resolveListWorkspaceFileContext(input.workspaceId), + async execute({ input, context }) { + const files = await listWorkspaceFiles(context.workspaceId, { scope: input.scope }) + const shares = await getWorkspaceShares('file', context.workspaceId) + return { + files: files.map((file) => ({ ...file, share: shares.get(file.id) ?? null })), + } + }, +}) -export const queryWorkspaceFilePage = { +export const queryWorkspaceFilePage = defineAuthorizedWorkspaceFileUseCase({ operation: fileOperations.list, - execute: executeQueryWorkspaceFilePage, -} as const + resolveContext: ({ input }: { input: QueryWorkspaceFilePageInput }) => + resolveListWorkspaceFileContext(input.workspaceId), + async execute({ input, context }) { + const folderIndex = await loadActiveFolderPathIndex(context.workspaceId, 'file') + const folderId = + input.folderPath === undefined + ? undefined + : input.folderPath === ROOT_FOLDER_PATH + ? null + : folderIndex.idByPath.get(input.folderPath) + if (input.folderPath !== undefined && folderId === undefined) { + throw new OrchestrationError('not_found', 'Folder not found') + } + + const { files, nextKeys } = await queryWorkspaceFiles(context.workspaceId, { + folderId, + search: input.search, + sortBy: input.sortBy, + sortOrder: input.sortOrder, + limit: input.limit, + after: input.after, + }) + return { files, nextKeys, cursorSort: input.cursorSort } + }, +}) diff --git a/apps/sim/lib/workspace-files/application/load-authorized-workspace-file.test.ts b/apps/sim/lib/workspace-files/application/load-authorized-workspace-file.test.ts deleted file mode 100644 index e7673c8b741..00000000000 --- a/apps/sim/lib/workspace-files/application/load-authorized-workspace-file.test.ts +++ /dev/null @@ -1,92 +0,0 @@ -/** - * @vitest-environment node - */ -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const mocks = vi.hoisted(() => ({ - loadContext: vi.fn(), - authorize: vi.fn(), -})) - -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ - loadActiveWorkspaceFileContext: mocks.loadContext, -})) - -vi.mock('@/lib/workspace-files/application/authorization', () => ({ - authorizeWorkspaceFileAccess: mocks.authorize, -})) - -import { loadAuthorizedWorkspaceFile } from '@/lib/workspace-files/application/load-authorized-workspace-file' -import { fileOperations } from '@/lib/workspace-files/application/operations' - -const canonical = { - fileId: 'file-1', - workspaceId: 'workspace-1', - workspaceOrganizationId: 'organization-1', - allowPersonalApiKeys: true, - billedAccountUserId: 'billing-owner-1', -} - -describe('loadAuthorizedWorkspaceFile', () => { - beforeEach(() => { - vi.clearAllMocks() - mocks.loadContext.mockResolvedValue(canonical) - mocks.authorize.mockResolvedValue(undefined) - }) - - it('loads canonically and authorizes the requested operation', async () => { - const principal = { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' } - - await expect( - loadAuthorizedWorkspaceFile({ - principal, - operation: fileOperations.readMetadata, - fileId: 'file-1', - assertedWorkspaceId: 'workspace-1', - }) - ).resolves.toBe(canonical) - - expect(mocks.authorize).toHaveBeenCalledWith(principal, fileOperations.readMetadata, { - workspaceId: 'workspace-1', - workspaceOrganizationId: 'organization-1', - allowPersonalApiKeys: true, - fileId: 'file-1', - }) - }) - - it('conceals missing and asserted-workspace mismatches before authorization', async () => { - const principal = { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' } - mocks.loadContext.mockResolvedValueOnce(null) - - await expect( - loadAuthorizedWorkspaceFile({ - principal, - operation: fileOperations.readMetadata, - fileId: 'file-1', - }) - ).rejects.toMatchObject({ code: 'not_found' }) - - await expect( - loadAuthorizedWorkspaceFile({ - principal, - operation: fileOperations.readMetadata, - fileId: 'file-1', - assertedWorkspaceId: 'workspace-2', - }) - ).rejects.toMatchObject({ code: 'not_found' }) - expect(mocks.authorize).not.toHaveBeenCalled() - }) - - it('propagates canonical-load infrastructure failures', async () => { - const failure = new Error('database unavailable') - mocks.loadContext.mockRejectedValueOnce(failure) - - await expect( - loadAuthorizedWorkspaceFile({ - principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, - operation: fileOperations.readMetadata, - fileId: 'file-1', - }) - ).rejects.toBe(failure) - }) -}) diff --git a/apps/sim/lib/workspace-files/application/load-authorized-workspace-file.ts b/apps/sim/lib/workspace-files/application/load-authorized-workspace-file.ts deleted file mode 100644 index aa7b77d1108..00000000000 --- a/apps/sim/lib/workspace-files/application/load-authorized-workspace-file.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { Principal } from '@sim/auth/principal' -import type { WorkspaceOperation } from '@/lib/core/application' -import { OrchestrationError } from '@/lib/core/orchestration/types' -import { - type ActiveWorkspaceFileContext, - loadActiveWorkspaceFileContext, -} from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { authorizeWorkspaceFileAccess } from '@/lib/workspace-files/application/authorization' - -interface LoadAuthorizedWorkspaceFileArgs { - principal: Principal - operation: WorkspaceOperation - fileId: string - assertedWorkspaceId?: string - includeDeleted?: boolean -} - -export async function loadAuthorizedWorkspaceFile({ - principal, - operation, - fileId, - assertedWorkspaceId, - includeDeleted, -}: LoadAuthorizedWorkspaceFileArgs): Promise { - const canonical = await loadActiveWorkspaceFileContext(fileId, { includeDeleted }) - if ( - !canonical || - (assertedWorkspaceId !== undefined && assertedWorkspaceId !== canonical.workspaceId) - ) { - throw new OrchestrationError('not_found', 'File not found') - } - - await authorizeWorkspaceFileAccess(principal, operation, { - workspaceId: canonical.workspaceId, - workspaceOrganizationId: canonical.workspaceOrganizationId, - allowPersonalApiKeys: canonical.allowPersonalApiKeys, - fileId: canonical.fileId, - }) - - return canonical -} diff --git a/apps/sim/lib/workspace-files/application/move-workspace-file-items.test.ts b/apps/sim/lib/workspace-files/application/move-workspace-file-items.test.ts index 80b6c22428b..61ced7919d4 100644 --- a/apps/sim/lib/workspace-files/application/move-workspace-file-items.test.ts +++ b/apps/sim/lib/workspace-files/application/move-workspace-file-items.test.ts @@ -3,20 +3,34 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockAuthorize, mockMove, mockAudit, mockNotify } = vi.hoisted(() => ({ - mockAuthorize: vi.fn(), +const { + events, + mockLoadContext, + mockResolvePermission, + mockAssertItems, + mockMove, + mockAudit, + mockNotify, +} = vi.hoisted(() => ({ + events: [] as string[], + mockLoadContext: vi.fn(), + mockResolvePermission: vi.fn(), + mockAssertItems: vi.fn(), mockMove: vi.fn(), mockAudit: vi.fn(), mockNotify: vi.fn(), })) -vi.mock('@/lib/workspace-files/application/workspace-operation-context', () => ({ - authorizeWorkspaceFileOperation: mockAuthorize, -})) vi.mock('@/lib/uploads/contexts/workspace', () => ({ - assertWorkspaceFileItemsBelongToWorkspace: mockAuthorize, + assertWorkspaceFileItemsBelongToWorkspace: mockAssertItems, + loadWorkspaceFileOperationContext: mockLoadContext, moveWorkspaceFileItems: mockMove, })) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => + actual === 'admin' || actual === required || (actual === 'write' && required === 'read'), + resolveEffectiveWorkspacePermission: mockResolvePermission, +})) vi.mock('@sim/audit', () => ({ AuditAction: { FILE_MOVED: 'file.moved', FOLDER_MOVED: 'folder.moved' }, AuditResourceType: { FILE: 'file', FOLDER: 'folder' }, @@ -25,21 +39,33 @@ vi.mock('@sim/audit', () => ({ vi.mock('@/lib/realtime/notify', () => ({ notifyWorkspaceFilesChanged: mockNotify })) import { moveWorkspaceFileItemsOperation } from '@/lib/workspace-files/application/move-workspace-file-items' -import { fileOperations } from '@/lib/workspace-files/application/operations' describe('moveWorkspaceFileItemsOperation', () => { beforeEach(() => { vi.clearAllMocks() - mockAuthorize.mockResolvedValue({ - context: { + events.length = 0 + mockLoadContext.mockImplementation(async () => { + events.push('resolve') + return { workspaceId: 'ws-1', workspaceOrganizationId: null, allowPersonalApiKeys: true, billedAccountUserId: 'owner-1', - }, - attribution: { attributedUserId: 'user-1', actor: { kind: 'session', userId: 'user-1' } }, + } + }) + mockResolvePermission.mockImplementation(async () => { + events.push('authorize') + return 'write' }) - mockMove.mockResolvedValue({ movedFiles: 2, movedFolders: 1 }) + mockAssertItems.mockImplementation(async () => { + events.push('execute') + }) + mockMove.mockImplementation(async () => ({ + movedFiles: 2, + movedFolders: 1, + movedFileIds: ['file-1', 'file-2'], + movedFolderIds: ['folder-1'], + })) }) it('uses the atomic manager primitive and records each semantic category once', async () => { @@ -53,13 +79,13 @@ describe('moveWorkspaceFileItemsOperation', () => { }, }) - expect(result).toEqual({ movedItems: { files: 2, folders: 1 } }) - expect(mockAuthorize).toHaveBeenCalledWith( - expect.anything(), - fileOperations.move, - 'ws-1', - 'file-1' - ) + expect(result).toMatchObject({ movedItems: { files: 2, folders: 1 } }) + expect(events).toEqual(['resolve', 'authorize', 'execute']) + expect(mockAssertItems).toHaveBeenCalledWith({ + workspaceId: 'ws-1', + fileIds: ['file-1', 'file-2'], + folderIds: ['folder-1'], + }) expect(mockMove).toHaveBeenCalledWith({ workspaceId: 'ws-1', fileIds: ['file-1', 'file-2'], @@ -71,18 +97,62 @@ describe('moveWorkspaceFileItemsOperation', () => { expect(mockNotify).toHaveBeenCalledOnce() }) - it('fails before touching storage when the selection is empty', async () => { + it('allows authorization to carry a resource ID only for one explicit file', async () => { + mockMove.mockResolvedValue({ + movedFiles: 1, + movedFolders: 0, + movedFileIds: ['file-1'], + movedFolderIds: [], + }) + + await moveWorkspaceFileItemsOperation.execute({ + principal: { + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'ws-1', + delegationId: 'delegation-1', + audience: 'sim:workspace-files', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2099-01-01T00:00:00Z'), + resourceScope: { fileId: 'file-1' }, + }, + input: { workspaceId: 'ws-1', fileIds: ['file-1'], targetFolderId: null }, + }) + + expect(mockResolvePermission).toHaveBeenCalledOnce() + }) + + it('does not audit or notify requested IDs absent from the mutation result', async () => { + mockMove.mockResolvedValue({ + movedFiles: 0, + movedFolders: 0, + movedFileIds: [], + movedFolderIds: [], + }) + + const result = await moveWorkspaceFileItemsOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: 'ws-1', fileIds: ['file-1'], targetFolderId: null }, + }) + + expect(result).toMatchObject({ movedItems: { files: 0, folders: 0 } }) + expect(mockAudit).not.toHaveBeenCalled() + expect(mockNotify).not.toHaveBeenCalled() + }) + + it('authorizes before rejecting an empty selection without touching storage', async () => { await expect( moveWorkspaceFileItemsOperation.execute({ principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, input: { workspaceId: 'ws-1' }, }) ).rejects.toThrow('At least one file or folder must be selected') - expect(mockAuthorize).not.toHaveBeenCalled() + expect(events).toEqual(['resolve', 'authorize']) expect(mockMove).not.toHaveBeenCalled() }) - it('rejects oversized selections before authorization', async () => { + it('rejects oversized selections after authorization', async () => { await expect( moveWorkspaceFileItemsOperation.execute({ principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, @@ -92,7 +162,7 @@ describe('moveWorkspaceFileItemsOperation', () => { }, }) ).rejects.toThrow('accept at most 1000') - expect(mockAuthorize).not.toHaveBeenCalled() + expect(events).toEqual(['resolve', 'authorize']) expect(mockMove).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/lib/workspace-files/application/move-workspace-file-items.ts b/apps/sim/lib/workspace-files/application/move-workspace-file-items.ts index 74a2d6f35dc..5e8a998e335 100644 --- a/apps/sim/lib/workspace-files/application/move-workspace-file-items.ts +++ b/apps/sim/lib/workspace-files/application/move-workspace-file-items.ts @@ -1,17 +1,14 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { type Principal, resolvePrincipalAuditAttribution } from '@sim/auth/principal' +import { AuditAction, AuditResourceType } from '@sim/audit' import { createLogger } from '@sim/logger' -import { - OrchestrationError, - type OrchestrationRequestContext, -} from '@/lib/core/orchestration/types' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' import { assertWorkspaceFileItemsBelongToWorkspace, + loadWorkspaceFileOperationContext, moveWorkspaceFileItems, } from '@/lib/uploads/contexts/workspace' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' import { fileOperations } from '@/lib/workspace-files/application/operations' -import { authorizeWorkspaceFileOperation } from '@/lib/workspace-files/application/workspace-operation-context' import { MAX_WORKSPACE_FILE_BULK_REQUEST_IDS } from '@/lib/workspace-files/limits' const logger = createLogger('MoveWorkspaceFileItems') @@ -26,15 +23,24 @@ export interface MoveWorkspaceFileItemsInput { export interface MoveWorkspaceFileItemsResult { movedItems: { files: number; folders: number } + affectedIds: { fileIds: string[]; folderIds: string[] } +} + +function normalizeSelection(input: MoveWorkspaceFileItemsInput) { + return { + fileIds: [...new Set(input.fileIds ?? [])], + folderIds: [...new Set(input.folderIds ?? [])], + } } -async function executeMoveWorkspaceFileItems(args: { - principal: Principal +async function executeMoveWorkspaceFileItems({ + input, + context, +}: { input: MoveWorkspaceFileItemsInput - request?: OrchestrationRequestContext + context: Awaited> }): Promise { - const fileIds = [...new Set(args.input.fileIds ?? [])] - const folderIds = [...new Set(args.input.folderIds ?? [])] + const { fileIds, folderIds } = normalizeSelection(input) if (fileIds.length === 0 && folderIds.length === 0) { throw new OrchestrationError('validation', 'At least one file or folder must be selected') } @@ -48,13 +54,6 @@ async function executeMoveWorkspaceFileItems(args: { ) } - const { context } = await authorizeWorkspaceFileOperation( - args.principal, - fileOperations.move, - args.input.workspaceId, - fileIds[0] - ) - const auditAttribution = resolvePrincipalAuditAttribution(args.principal) await assertWorkspaceFileItemsBelongToWorkspace({ workspaceId: context.workspaceId, fileIds, @@ -64,54 +63,65 @@ async function executeMoveWorkspaceFileItems(args: { workspaceId: context.workspaceId, fileIds, folderIds, - targetFolderId: args.input.targetFolderId, - targetFolderPath: args.input.targetFolderPath, + targetFolderId: input.targetFolderId, + targetFolderPath: input.targetFolderPath, }) - const movedItems = { files: moved.movedFiles, folders: moved.movedFolders } + const movedItems = { files: moved.movedFileIds.length, folders: moved.movedFolderIds.length } - if (fileIds.length > 0) { - recordAudit({ - workspaceId: context.workspaceId, - actorId: auditAttribution.actorId, - actorName: auditAttribution.actorName, - action: AuditAction.FILE_MOVED, - resourceType: AuditResourceType.FILE, - description: `Moved ${fileIds.length} file${fileIds.length === 1 ? '' : 's'}`, - metadata: { - operation: fileOperations.move.id, - actor: auditAttribution.actor, - fileIds, - targetFolderId: args.input.targetFolderId, - targetFolderPath: args.input.targetFolderPath, - }, - request: args.request, - }) + logger.info('Moved workspace file items', { workspaceId: context.workspaceId, movedItems }) + return { + movedItems, + affectedIds: { fileIds: moved.movedFileIds, folderIds: moved.movedFolderIds }, } - if (folderIds.length > 0) { - recordAudit({ - workspaceId: context.workspaceId, - actorId: auditAttribution.actorId, - actorName: auditAttribution.actorName, - action: AuditAction.FOLDER_MOVED, - resourceType: AuditResourceType.FOLDER, - resourceId: folderIds.length === 1 ? folderIds[0] : undefined, - description: `Moved ${folderIds.length} file folder${folderIds.length === 1 ? '' : 's'}`, - metadata: { - operation: fileOperations.move.id, - actor: auditAttribution.actor, - folderIds, - targetFolderId: args.input.targetFolderId, - targetFolderPath: args.input.targetFolderPath, - }, - request: args.request, - }) +} + +async function resolveMoveContext({ input }: { input: MoveWorkspaceFileItemsInput }) { + const context = await loadWorkspaceFileOperationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + const { fileIds, folderIds } = normalizeSelection(input) + return { + ...context, + fileId: fileIds.length === 1 && folderIds.length === 0 ? fileIds[0] : undefined, } - await notifyWorkspaceFilesChanged(context.workspaceId) - logger.info('Moved workspace file items', { workspaceId: context.workspaceId, movedItems }) - return { movedItems } } -export const moveWorkspaceFileItemsOperation = { +export const moveWorkspaceFileItemsOperation = defineAuthorizedWorkspaceFileUseCase({ operation: fileOperations.move, + resolveContext: resolveMoveContext, execute: executeMoveWorkspaceFileItems, -} as const + projectAudit({ input, result }) { + const entries = [] + if (result.affectedIds.fileIds.length > 0) { + entries.push({ + action: AuditAction.FILE_MOVED, + resourceType: AuditResourceType.FILE, + description: `Moved ${result.affectedIds.fileIds.length} file${result.affectedIds.fileIds.length === 1 ? '' : 's'}`, + metadata: { + fileIds: result.affectedIds.fileIds, + targetFolderId: input.targetFolderId, + targetFolderPath: input.targetFolderPath, + }, + }) + } + if (result.affectedIds.folderIds.length > 0) { + entries.push({ + action: AuditAction.FOLDER_MOVED, + resourceType: AuditResourceType.FOLDER, + resourceId: + result.affectedIds.folderIds.length === 1 ? result.affectedIds.folderIds[0] : undefined, + description: `Moved ${result.affectedIds.folderIds.length} file folder${result.affectedIds.folderIds.length === 1 ? '' : 's'}`, + metadata: { + folderIds: result.affectedIds.folderIds, + targetFolderId: input.targetFolderId, + targetFolderPath: input.targetFolderPath, + }, + }) + } + return entries + }, + async afterSuccess({ context, result }) { + if (result.affectedIds.fileIds.length > 0 || result.affectedIds.folderIds.length > 0) { + await notifyWorkspaceFilesChanged(context.workspaceId) + } + }, +}) diff --git a/apps/sim/lib/workspace-files/application/operations.test.ts b/apps/sim/lib/workspace-files/application/operations.test.ts index cd115a058df..82b5bdbd55c 100644 --- a/apps/sim/lib/workspace-files/application/operations.test.ts +++ b/apps/sim/lib/workspace-files/application/operations.test.ts @@ -9,6 +9,19 @@ import { fileOperations } from '@/lib/workspace-files/application/operations' describe('file operation registry', () => { it('keeps every workspace-key operation at or below the fixed write ceiling', () => { for (const operation of Object.values(fileOperations)) { + expect( + operation.principalKinds.length, + `${operation.id} has no allowed principals` + ).toBeGreaterThan(0) + expect( + new Set(operation.principalKinds).size, + `${operation.id} repeats a principal kind` + ).toBe(operation.principalKinds.length) + expect( + operation.principalKinds.includes('workspace_api_key'), + `${operation.id} has inconsistent workspace API-key declarations` + ).toBe(operation.workspaceApiKey === 'allow') + if (operation.workspaceApiKey === 'allow') { expect( permissionSatisfies('write', operation.minimumRole), @@ -25,5 +38,19 @@ describe('file operation registry', () => { it('keeps external sharing policy changes human-delegated', () => { expect(fileOperations.updateShare.workspaceApiKey).toBe('deny') + expect(fileOperations.updateShare.principalKinds).toEqual([ + 'session', + 'personal_api_key', + 'delegated', + ]) + }) + + it('restricts compiled checks to authenticated sessions', () => { + expect(fileOperations.compiledCheck).toMatchObject({ + id: 'files.compiled_check', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }) }) }) diff --git a/apps/sim/lib/workspace-files/application/operations.ts b/apps/sim/lib/workspace-files/application/operations.ts index 16fedc8f549..cd4f6179b23 100644 --- a/apps/sim/lib/workspace-files/application/operations.ts +++ b/apps/sim/lib/workspace-files/application/operations.ts @@ -1,115 +1,151 @@ import { defineWorkspaceOperation } from '@/lib/core/application' +const ALL_PRINCIPAL_KINDS = [ + 'session', + 'personal_api_key', + 'workspace_api_key', + 'delegated', +] as const +const HUMAN_PRINCIPAL_KINDS = ['session', 'personal_api_key', 'delegated'] as const + export const fileOperations = { list: defineWorkspaceOperation({ id: 'files.list', minimumRole: 'read', workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, }), readMetadata: defineWorkspaceOperation({ id: 'files.read_metadata', minimumRole: 'read', workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, }), readContent: defineWorkspaceOperation({ id: 'files.read_content', minimumRole: 'read', workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, }), download: defineWorkspaceOperation({ id: 'files.download', minimumRole: 'read', workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, + }), + compiledCheck: defineWorkspaceOperation({ + id: 'files.compiled_check', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['session'], }), create: defineWorkspaceOperation({ id: 'files.create', minimumRole: 'write', workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, }), rename: defineWorkspaceOperation({ id: 'files.rename', minimumRole: 'write', workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, }), updateContent: defineWorkspaceOperation({ id: 'files.update_content', minimumRole: 'write', workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, }), updateMetadata: defineWorkspaceOperation({ id: 'files.update_metadata', minimumRole: 'write', workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, }), move: defineWorkspaceOperation({ id: 'files.move', minimumRole: 'write', workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, }), delete: defineWorkspaceOperation({ id: 'files.delete', minimumRole: 'write', workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, }), restore: defineWorkspaceOperation({ id: 'files.restore', minimumRole: 'write', workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, }), readShare: defineWorkspaceOperation({ id: 'files.share.read', minimumRole: 'read', workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, }), updateShare: defineWorkspaceOperation({ id: 'files.share.update', minimumRole: 'write', workspaceApiKey: 'deny', + principalKinds: HUMAN_PRINCIPAL_KINDS, }), listFolders: defineWorkspaceOperation({ id: 'files.folders.list', minimumRole: 'read', workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, }), createFolder: defineWorkspaceOperation({ id: 'files.folders.create', minimumRole: 'write', workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, }), updateFolder: defineWorkspaceOperation({ id: 'files.folders.update', minimumRole: 'write', workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, }), deleteFolder: defineWorkspaceOperation({ id: 'files.folders.delete', minimumRole: 'write', workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, }), restoreFolder: defineWorkspaceOperation({ id: 'files.folders.restore', minimumRole: 'write', workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, }), uploadCreate: defineWorkspaceOperation({ id: 'files.upload.create', minimumRole: 'write', workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, }), uploadParts: defineWorkspaceOperation({ id: 'files.upload.parts', minimumRole: 'write', workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, }), uploadComplete: defineWorkspaceOperation({ id: 'files.upload.complete', minimumRole: 'write', workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, }), uploadCancel: defineWorkspaceOperation({ id: 'files.upload.cancel', minimumRole: 'write', workspaceApiKey: 'allow', + principalKinds: ALL_PRINCIPAL_KINDS, }), } as const diff --git a/apps/sim/lib/workspace-files/application/read-workspace-file-content.test.ts b/apps/sim/lib/workspace-files/application/read-workspace-file-content.test.ts new file mode 100644 index 00000000000..71a666fa392 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/read-workspace-file-content.test.ts @@ -0,0 +1,87 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + fetchBuffer: vi.fn(), + getFile: vi.fn(), + loadContext: vi.fn(), + resolvePermission: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: () => true, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + fetchWorkspaceFileBuffer: mocks.fetchBuffer, + getWorkspaceFile: mocks.getFile, +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + loadActiveWorkspaceFileContext: mocks.loadContext, +})) + +import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' + +const context = { + fileId: 'file-1', + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner', +} + +const file = { + id: 'file-1', + workspaceId: 'workspace-1', + name: 'source.txt', + key: 'workspace/workspace-1/source.txt', + size: 12, +} + +describe('readWorkspaceFileContent', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadContext.mockResolvedValue(context) + mocks.resolvePermission.mockResolvedValue('admin') + mocks.getFile.mockResolvedValue(file) + mocks.fetchBuffer.mockResolvedValue(Buffer.from('source')) + }) + + it('authorizes the canonical file before performing a bounded content read', async () => { + await expect( + readWorkspaceFileContent.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { + fileId: 'file-1', + assertedWorkspaceId: 'workspace-1', + includeDeleted: true, + maxBytes: 512, + }, + }) + ).resolves.toEqual({ file, content: Buffer.from('source') }) + + expect(mocks.loadContext).toHaveBeenCalledWith('file-1', { includeDeleted: true }) + expect(mocks.getFile).toHaveBeenCalledWith('workspace-1', 'file-1', { + includeDeleted: true, + throwOnError: true, + }) + expect(mocks.fetchBuffer).toHaveBeenCalledWith(file, { maxBytes: 512 }) + }) + + it('conceals an asserted-workspace mismatch before authorization or storage reads', async () => { + await expect( + readWorkspaceFileContent.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-2' }, + }) + ).rejects.toMatchObject({ code: 'not_found', message: 'File not found' }) + + expect(mocks.resolvePermission).not.toHaveBeenCalled() + expect(mocks.getFile).not.toHaveBeenCalled() + expect(mocks.fetchBuffer).not.toHaveBeenCalled() + }) +}) diff --git a/apps/sim/lib/workspace-files/application/read-workspace-file-content.ts b/apps/sim/lib/workspace-files/application/read-workspace-file-content.ts index 1a02ff69c25..7ac5d641d13 100644 --- a/apps/sim/lib/workspace-files/application/read-workspace-file-content.ts +++ b/apps/sim/lib/workspace-files/application/read-workspace-file-content.ts @@ -1,13 +1,14 @@ -import type { Principal } from '@sim/auth/principal' -import type { WorkspaceOperation } from '@/lib/core/application' +import type { AuthorizedWorkspaceUseCaseContext } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { + type ActiveWorkspaceFileContext, fetchWorkspaceFileBuffer, getWorkspaceFile, type WorkspaceFileRecord, } from '@/lib/uploads/contexts/workspace' -import { loadAuthorizedWorkspaceFile } from '@/lib/workspace-files/application/load-authorized-workspace-file' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' import { fileOperations } from '@/lib/workspace-files/application/operations' +import { resolveActiveWorkspaceFileContext } from '@/lib/workspace-files/application/workspace-file-context' export interface ReadWorkspaceFileContentInput { fileId: string @@ -22,34 +23,27 @@ export interface ReadWorkspaceFileContentResult { content: Buffer } -function executeReadWorkspaceFileContent(operation: WorkspaceOperation) { - return async ({ - principal, - input, - }: { - principal: Principal - input: ReadWorkspaceFileContentInput - }): Promise => { - const canonical = await loadAuthorizedWorkspaceFile({ - principal, - operation, - fileId: input.fileId, - assertedWorkspaceId: input.assertedWorkspaceId, - includeDeleted: input.includeDeleted, - }) - const file = await getWorkspaceFile(canonical.workspaceId, canonical.fileId, { - includeDeleted: input.includeDeleted, - throwOnError: true, - }) - if (!file) throw new OrchestrationError('not_found', 'File not found') - return { - file, - content: await fetchWorkspaceFileBuffer(file, { maxBytes: input.maxBytes }), - } +async function executeReadWorkspaceFileContent({ + input, + context, +}: AuthorizedWorkspaceUseCaseContext< + typeof fileOperations.readContent, + ReadWorkspaceFileContentInput, + ActiveWorkspaceFileContext +>): Promise { + const file = await getWorkspaceFile(context.workspaceId, context.fileId, { + includeDeleted: input.includeDeleted, + throwOnError: true, + }) + if (!file) throw new OrchestrationError('not_found', 'File not found') + return { + file, + content: await fetchWorkspaceFileBuffer(file, { maxBytes: input.maxBytes }), } } -export const readWorkspaceFileContent = { +export const readWorkspaceFileContent = defineAuthorizedWorkspaceFileUseCase({ operation: fileOperations.readContent, - execute: executeReadWorkspaceFileContent(fileOperations.readContent), -} as const + resolveContext: ({ input }) => resolveActiveWorkspaceFileContext(input), + execute: executeReadWorkspaceFileContent, +}) diff --git a/apps/sim/lib/workspace-files/application/read-workspace-file-metadata.test.ts b/apps/sim/lib/workspace-files/application/read-workspace-file-metadata.test.ts index 6cb73ce7f9f..888869e4918 100644 --- a/apps/sim/lib/workspace-files/application/read-workspace-file-metadata.test.ts +++ b/apps/sim/lib/workspace-files/application/read-workspace-file-metadata.test.ts @@ -4,16 +4,19 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - loadAuthorized: vi.fn(), + loadContext: vi.fn(), getWorkspaceFile: vi.fn(), + resolvePermission: vi.fn(), })) -vi.mock('@/lib/workspace-files/application/load-authorized-workspace-file', () => ({ - loadAuthorizedWorkspaceFile: mocks.loadAuthorized, +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: () => true, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, })) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ getWorkspaceFile: mocks.getWorkspaceFile, + loadActiveWorkspaceFileContext: mocks.loadContext, })) import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' @@ -43,8 +46,9 @@ const file = { describe('readWorkspaceFileMetadata', () => { beforeEach(() => { vi.clearAllMocks() - mocks.loadAuthorized.mockResolvedValue(canonical) + mocks.loadContext.mockResolvedValue(canonical) mocks.getWorkspaceFile.mockResolvedValue(file) + mocks.resolvePermission.mockResolvedValue('admin') }) it('returns the canonical active file without side effects', async () => { @@ -60,11 +64,8 @@ describe('readWorkspaceFileMetadata', () => { }) ).resolves.toEqual({ file }) - expect(mocks.loadAuthorized).toHaveBeenCalledWith({ - principal, - operation: readWorkspaceFileMetadata.operation, - fileId: 'file-1', - assertedWorkspaceId: 'workspace-1', + expect(mocks.loadContext).toHaveBeenCalledWith('file-1', { + includeDeleted: undefined, }) expect(mocks.getWorkspaceFile).toHaveBeenCalledWith('workspace-1', 'file-1', { throwOnError: true, diff --git a/apps/sim/lib/workspace-files/application/read-workspace-file-metadata.ts b/apps/sim/lib/workspace-files/application/read-workspace-file-metadata.ts index 89b5aa89aea..9fbc52ff850 100644 --- a/apps/sim/lib/workspace-files/application/read-workspace-file-metadata.ts +++ b/apps/sim/lib/workspace-files/application/read-workspace-file-metadata.ts @@ -1,12 +1,13 @@ -import type { Principal } from '@sim/auth/principal' -import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' +import type { AuthorizedWorkspaceUseCaseContext } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { + type ActiveWorkspaceFileContext, getWorkspaceFile, type WorkspaceFileRecord, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { loadAuthorizedWorkspaceFile } from '@/lib/workspace-files/application/load-authorized-workspace-file' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' import { fileOperations } from '@/lib/workspace-files/application/operations' +import { resolveActiveWorkspaceFileContext } from '@/lib/workspace-files/application/workspace-file-context' export interface ReadWorkspaceFileMetadataInput { fileId: string @@ -18,24 +19,15 @@ export interface ReadWorkspaceFileMetadataResult { file: WorkspaceFileRecord } -interface ReadWorkspaceFileMetadataArguments { - principal: Principal - input: ReadWorkspaceFileMetadataInput - request?: OrchestrationRequestContext -} - async function executeReadWorkspaceFileMetadata({ - principal, input, -}: ReadWorkspaceFileMetadataArguments): Promise { - const canonical = await loadAuthorizedWorkspaceFile({ - principal, - operation: fileOperations.readMetadata, - fileId: input.fileId, - assertedWorkspaceId: input.assertedWorkspaceId, - includeDeleted: input.includeDeleted, - }) - const file = await getWorkspaceFile(canonical.workspaceId, canonical.fileId, { + context, +}: AuthorizedWorkspaceUseCaseContext< + typeof fileOperations.readMetadata, + ReadWorkspaceFileMetadataInput, + ActiveWorkspaceFileContext +>): Promise { + const file = await getWorkspaceFile(context.workspaceId, context.fileId, { includeDeleted: input.includeDeleted, throwOnError: true, }) @@ -43,7 +35,8 @@ async function executeReadWorkspaceFileMetadata({ return { file } } -export const readWorkspaceFileMetadata = { +export const readWorkspaceFileMetadata = defineAuthorizedWorkspaceFileUseCase({ operation: fileOperations.readMetadata, + resolveContext: ({ input }) => resolveActiveWorkspaceFileContext(input), execute: executeReadWorkspaceFileMetadata, -} as const +}) diff --git a/apps/sim/lib/workspace-files/application/read-workspace-file-record.test.ts b/apps/sim/lib/workspace-files/application/read-workspace-file-record.test.ts new file mode 100644 index 00000000000..4173a836a39 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/read-workspace-file-record.test.ts @@ -0,0 +1,69 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + getFile: vi.fn(), + loadContext: vi.fn(), + resolvePermission: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: () => true, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + getWorkspaceFile: mocks.getFile, + loadActiveWorkspaceFileContext: mocks.loadContext, +})) + +import { + downloadWorkspaceFileRecord, + readWorkspaceFileContentRecord, +} from '@/lib/workspace-files/application/read-workspace-file-record' + +const context = { + fileId: 'file-1', + workspaceId: 'workspace-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner', +} + +const file = { + id: 'file-1', + workspaceId: 'workspace-1', + name: 'report.pdf', +} + +describe('workspace file record reads', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.loadContext.mockResolvedValue(context) + mocks.resolvePermission.mockResolvedValue('admin') + mocks.getFile.mockResolvedValue(file) + }) + + it.each([ + [readWorkspaceFileContentRecord, 'files.read_content'], + [downloadWorkspaceFileRecord, 'files.download'], + ] as const)( + 'uses the %s operation for its canonical record read', + async (useCase, operationId) => { + await expect( + useCase.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-1' }, + }) + ).resolves.toEqual({ file }) + + expect(useCase.operation.id).toBe(operationId) + expect(mocks.loadContext).toHaveBeenCalledWith('file-1', { includeDeleted: undefined }) + expect(mocks.getFile).toHaveBeenCalledWith('workspace-1', 'file-1', { + throwOnError: true, + }) + } + ) +}) diff --git a/apps/sim/lib/workspace-files/application/read-workspace-file-record.ts b/apps/sim/lib/workspace-files/application/read-workspace-file-record.ts index 03ebd03a6a1..f9570ad679a 100644 --- a/apps/sim/lib/workspace-files/application/read-workspace-file-record.ts +++ b/apps/sim/lib/workspace-files/application/read-workspace-file-record.ts @@ -1,13 +1,13 @@ -import type { Principal } from '@sim/auth/principal' -import type { WorkspaceOperation } from '@/lib/core/application' -import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' +import type { AuthorizedWorkspaceUseCaseContext, WorkspaceOperation } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { + type ActiveWorkspaceFileContext, getWorkspaceFile, type WorkspaceFileRecord, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { loadAuthorizedWorkspaceFile } from '@/lib/workspace-files/application/load-authorized-workspace-file' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' import { fileOperations } from '@/lib/workspace-files/application/operations' +import { resolveActiveWorkspaceFileContext } from '@/lib/workspace-files/application/workspace-file-context' export interface ReadWorkspaceFileRecordInput { fileId: string @@ -18,35 +18,29 @@ export interface ReadWorkspaceFileRecordResult { file: WorkspaceFileRecord } -function executeReadWorkspaceFileRecord(operation: WorkspaceOperation) { - return async ({ - principal, - input, - }: { - principal: Principal - input: ReadWorkspaceFileRecordInput - request?: OrchestrationRequestContext - }): Promise => { - const canonical = await loadAuthorizedWorkspaceFile({ - principal, - operation, - fileId: input.fileId, - assertedWorkspaceId: input.assertedWorkspaceId, - }) - const file = await getWorkspaceFile(canonical.workspaceId, canonical.fileId, { - throwOnError: true, - }) - if (!file) throw new OrchestrationError('not_found', 'File not found') - return { file } - } +function createReadWorkspaceFileRecord(operation: O) { + return defineAuthorizedWorkspaceFileUseCase({ + operation, + resolveContext: ({ input }: { input: ReadWorkspaceFileRecordInput }) => + resolveActiveWorkspaceFileContext(input), + async execute({ + context, + }: AuthorizedWorkspaceUseCaseContext< + O, + ReadWorkspaceFileRecordInput, + ActiveWorkspaceFileContext + >): Promise { + const file = await getWorkspaceFile(context.workspaceId, context.fileId, { + throwOnError: true, + }) + if (!file) throw new OrchestrationError('not_found', 'File not found') + return { file } + }, + }) } -export const readWorkspaceFileContentRecord = { - operation: fileOperations.readContent, - execute: executeReadWorkspaceFileRecord(fileOperations.readContent), -} as const +export const readWorkspaceFileContentRecord = createReadWorkspaceFileRecord( + fileOperations.readContent +) -export const downloadWorkspaceFileRecord = { - operation: fileOperations.download, - execute: executeReadWorkspaceFileRecord(fileOperations.download), -} as const +export const downloadWorkspaceFileRecord = createReadWorkspaceFileRecord(fileOperations.download) diff --git a/apps/sim/lib/workspace-files/application/read-workspace-inline-file.test.ts b/apps/sim/lib/workspace-files/application/read-workspace-inline-file.test.ts index 5b2038bcb8b..f1012bc3c12 100644 --- a/apps/sim/lib/workspace-files/application/read-workspace-inline-file.test.ts +++ b/apps/sim/lib/workspace-files/application/read-workspace-inline-file.test.ts @@ -4,19 +4,21 @@ import { Readable } from 'node:stream' import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockLoadAuthorized, mockGetWorkspaceFile, mockGetMetadataByKey, mockDownloadFileStream } = +const { mockLoadContext, mockGetWorkspaceFile, mockGetMetadataByKey, mockDownloadFileStream } = vi.hoisted(() => ({ - mockLoadAuthorized: vi.fn(), + mockLoadContext: vi.fn(), mockGetWorkspaceFile: vi.fn(), mockGetMetadataByKey: vi.fn(), mockDownloadFileStream: vi.fn(), })) -vi.mock('@/lib/workspace-files/application/load-authorized-workspace-file', () => ({ - loadAuthorizedWorkspaceFile: mockLoadAuthorized, +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: () => true, + resolveEffectiveWorkspacePermission: vi.fn().mockResolvedValue('admin'), })) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ getWorkspaceFile: mockGetWorkspaceFile, + loadActiveWorkspaceFileContext: mockLoadContext, })) vi.mock('@/lib/uploads/server/metadata', () => ({ getFileMetadataByKey: mockGetMetadataByKey })) vi.mock('@/lib/uploads/core/storage-service', () => ({ @@ -37,7 +39,13 @@ const file = { describe('readWorkspaceInlineFile', () => { beforeEach(() => { vi.clearAllMocks() - mockLoadAuthorized.mockResolvedValue({ fileId: 'f1', workspaceId: 'ws-1' }) + mockLoadContext.mockResolvedValue({ + fileId: 'f1', + workspaceId: 'ws-1', + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner', + }) mockGetWorkspaceFile.mockResolvedValue(file) mockDownloadFileStream.mockResolvedValue(Readable.from(Buffer.from('png'))) }) @@ -48,12 +56,7 @@ describe('readWorkspaceInlineFile', () => { input: { workspaceId: 'ws-1', fileId: 'f1' }, }) - expect(mockLoadAuthorized).toHaveBeenCalledWith({ - principal, - operation: expect.objectContaining({ id: 'files.read_content' }), - fileId: 'f1', - assertedWorkspaceId: 'ws-1', - }) + expect(mockLoadContext).toHaveBeenCalledWith('f1') expect(mockDownloadFileStream).toHaveBeenCalledWith({ key: file.key, context: 'workspace', @@ -70,9 +73,7 @@ describe('readWorkspaceInlineFile', () => { }) expect(mockGetMetadataByKey).toHaveBeenCalledWith(file.key, 'workspace') - expect(mockLoadAuthorized).toHaveBeenCalledWith( - expect.objectContaining({ fileId: 'f1', assertedWorkspaceId: 'ws-1' }) - ) + expect(mockLoadContext).toHaveBeenCalledWith('f1') }) it('conceals a key belonging to another workspace before authorization', async () => { @@ -84,7 +85,7 @@ describe('readWorkspaceInlineFile', () => { input: { workspaceId: 'ws-1', key: 'workspace/ws-other/photo.png' }, }) ).rejects.toMatchObject({ code: 'not_found' }) - expect(mockLoadAuthorized).not.toHaveBeenCalled() + expect(mockLoadContext).not.toHaveBeenCalled() expect(mockDownloadFileStream).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/lib/workspace-files/application/read-workspace-inline-file.ts b/apps/sim/lib/workspace-files/application/read-workspace-inline-file.ts index 28d7b6effd4..1dba3799607 100644 --- a/apps/sim/lib/workspace-files/application/read-workspace-inline-file.ts +++ b/apps/sim/lib/workspace-files/application/read-workspace-inline-file.ts @@ -1,13 +1,15 @@ -import type { Principal } from '@sim/auth/principal' +import type { AuthorizedWorkspaceUseCaseContext } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { nodeReadableToWebStream } from '@/lib/core/utils/node-stream' import { + type ActiveWorkspaceFileContext, getWorkspaceFile, + loadActiveWorkspaceFileContext, type WorkspaceFileRecord, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { downloadFileStream } from '@/lib/uploads/core/storage-service' import { getFileMetadataByKey } from '@/lib/uploads/server/metadata' -import { loadAuthorizedWorkspaceFile } from '@/lib/workspace-files/application/load-authorized-workspace-file' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' import { fileOperations } from '@/lib/workspace-files/application/operations' export interface ReadWorkspaceInlineFileInput { @@ -22,29 +24,13 @@ export interface ReadWorkspaceInlineFileResult { } async function executeReadWorkspaceInlineFile({ - principal, - input, -}: { - principal: Principal - input: ReadWorkspaceInlineFileInput -}): Promise { - let fileId = input.fileId - if (!fileId && input.key) { - const metadata = await getFileMetadataByKey(input.key, 'workspace') - if (!metadata || metadata.workspaceId !== input.workspaceId) { - throw new OrchestrationError('not_found', 'Not found') - } - fileId = metadata.id - } - if (!fileId) throw new OrchestrationError('validation', 'Provide exactly one file reference') - - const canonical = await loadAuthorizedWorkspaceFile({ - principal, - operation: fileOperations.readContent, - fileId, - assertedWorkspaceId: input.workspaceId, - }) - const file = await getWorkspaceFile(canonical.workspaceId, canonical.fileId, { + context, +}: AuthorizedWorkspaceUseCaseContext< + typeof fileOperations.readContent, + ReadWorkspaceInlineFileInput, + ActiveWorkspaceFileContext +>): Promise { + const file = await getWorkspaceFile(context.workspaceId, context.fileId, { throwOnError: true, }) if (!file) throw new OrchestrationError('not_found', 'Not found') @@ -53,7 +39,24 @@ async function executeReadWorkspaceInlineFile({ return { file, stream: nodeReadableToWebStream(stream) } } -export const readWorkspaceInlineFile = { +export const readWorkspaceInlineFile = defineAuthorizedWorkspaceFileUseCase({ operation: fileOperations.readContent, + async resolveContext({ input }) { + let fileId = input.fileId + if (!fileId && input.key) { + const metadata = await getFileMetadataByKey(input.key, 'workspace') + if (!metadata || metadata.workspaceId !== input.workspaceId) { + throw new OrchestrationError('not_found', 'Not found') + } + fileId = metadata.id + } + if (!fileId) throw new OrchestrationError('validation', 'Provide exactly one file reference') + + const canonical = await loadActiveWorkspaceFileContext(fileId) + if (!canonical || canonical.workspaceId !== input.workspaceId) { + throw new OrchestrationError('not_found', 'Not found') + } + return canonical + }, execute: executeReadWorkspaceInlineFile, -} as const +}) diff --git a/apps/sim/lib/workspace-files/application/rename-workspace-file.test.ts b/apps/sim/lib/workspace-files/application/rename-workspace-file.test.ts index 5176dbbe87d..1cd669986f4 100644 --- a/apps/sim/lib/workspace-files/application/rename-workspace-file.test.ts +++ b/apps/sim/lib/workspace-files/application/rename-workspace-file.test.ts @@ -6,11 +6,16 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ loadContext: vi.fn(), renameStored: vi.fn(), - authorize: vi.fn(), + resolvePermission: vi.fn(), recordAudit: vi.fn(), notify: vi.fn(), })) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: () => true, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + vi.mock('@sim/audit', () => ({ AuditAction: { FILE_UPDATED: 'FILE_UPDATED' }, AuditResourceType: { FILE: 'FILE' }, @@ -19,10 +24,6 @@ vi.mock('@sim/audit', () => ({ vi.mock('@/lib/realtime/notify', () => ({ notifyWorkspaceFilesChanged: mocks.notify })) -vi.mock('@/lib/workspace-files/application/authorization', () => ({ - authorizeWorkspaceFileAccess: mocks.authorize, -})) - vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ loadActiveWorkspaceFileContext: mocks.loadContext, renameWorkspaceFile: mocks.renameStored, @@ -57,7 +58,7 @@ describe('renameWorkspaceFile application service', () => { vi.clearAllMocks() mocks.loadContext.mockResolvedValue(canonical) mocks.renameStored.mockResolvedValue(mappedFile) - mocks.authorize.mockResolvedValue(undefined) + mocks.resolvePermission.mockResolvedValue('admin') mocks.notify.mockResolvedValue(undefined) }) @@ -69,10 +70,12 @@ describe('renameWorkspaceFile application service', () => { expect(result).toEqual({ file: mappedFile }) expect(mocks.loadContext).toHaveBeenCalledWith('file-1', { includeDeleted: undefined }) - expect(mocks.authorize).toHaveBeenCalledWith( - expect.objectContaining({ kind: 'session', userId: 'user-1' }), - expect.objectContaining({ id: 'files.rename' }), - expect.objectContaining({ workspaceId: 'workspace-1', fileId: 'file-1' }) + expect(mocks.resolvePermission).toHaveBeenCalledWith( + 'user-1', + 'workspace-1', + 'organization-1', + undefined, + { forUpdate: undefined } ) expect(mocks.renameStored).toHaveBeenCalledWith('workspace-1', 'file-1', 'new.csv') expect(mocks.renameStored.mock.invocationCallOrder[0]).toBeLessThan( @@ -81,6 +84,9 @@ describe('renameWorkspaceFile application service', () => { expect(mocks.renameStored.mock.invocationCallOrder[0]).toBeLessThan( mocks.notify.mock.invocationCallOrder[0] ) + expect(mocks.recordAudit.mock.invocationCallOrder[0]).toBeLessThan( + mocks.notify.mock.invocationCallOrder[0] + ) }) it('conceals an asserted-workspace mismatch before authorization or mutation', async () => { @@ -91,7 +97,7 @@ describe('renameWorkspaceFile application service', () => { }) ).rejects.toMatchObject({ code: 'not_found', message: 'File not found' }) - expect(mocks.authorize).not.toHaveBeenCalled() + expect(mocks.resolvePermission).not.toHaveBeenCalled() expect(mocks.renameStored).not.toHaveBeenCalled() expect(mocks.recordAudit).not.toHaveBeenCalled() }) diff --git a/apps/sim/lib/workspace-files/application/rename-workspace-file.ts b/apps/sim/lib/workspace-files/application/rename-workspace-file.ts index ad103fcb127..72bb001ac39 100644 --- a/apps/sim/lib/workspace-files/application/rename-workspace-file.ts +++ b/apps/sim/lib/workspace-files/application/rename-workspace-file.ts @@ -1,14 +1,15 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { type Principal, resolvePrincipalAuditAttribution } from '@sim/auth/principal' +import { AuditAction, AuditResourceType } from '@sim/audit' import { createLogger } from '@sim/logger' -import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' +import type { AuthorizedWorkspaceUseCaseContext } from '@/lib/core/application' import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' import { + type ActiveWorkspaceFileContext, renameWorkspaceFile as renameStoredWorkspaceFile, type WorkspaceFileRecord, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { loadAuthorizedWorkspaceFile } from '@/lib/workspace-files/application/load-authorized-workspace-file' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' import { fileOperations } from '@/lib/workspace-files/application/operations' +import { resolveActiveWorkspaceFileContext } from '@/lib/workspace-files/application/workspace-file-context' const logger = createLogger('RenameWorkspaceFile') @@ -22,54 +23,36 @@ export interface RenameWorkspaceFileResult { file: WorkspaceFileRecord } -interface RenameWorkspaceFileArguments { - principal: Principal - input: RenameWorkspaceFileInput - request?: OrchestrationRequestContext -} - async function executeRenameWorkspaceFile({ principal, input, - request, -}: RenameWorkspaceFileArguments): Promise { - const canonical = await loadAuthorizedWorkspaceFile({ - principal, - operation: fileOperations.rename, - fileId: input.fileId, - assertedWorkspaceId: input.assertedWorkspaceId, - }) - - const auditAttribution = resolvePrincipalAuditAttribution(principal) - const file = await renameStoredWorkspaceFile(canonical.workspaceId, canonical.fileId, input.name) - const workspaceId = canonical.workspaceId + context, +}: AuthorizedWorkspaceUseCaseContext< + typeof fileOperations.rename, + RenameWorkspaceFileInput, + ActiveWorkspaceFileContext +>): Promise { + const file = await renameStoredWorkspaceFile(context.workspaceId, context.fileId, input.name) logger.info('Renamed workspace file', { - workspaceId, - fileId: input.fileId, + workspaceId: context.workspaceId, + fileId: context.fileId, name: file.name, principalKind: principal.kind, }) - recordAudit({ - workspaceId, - actorId: auditAttribution.actorId, - actorName: auditAttribution.actorName, - action: AuditAction.FILE_UPDATED, - resourceType: AuditResourceType.FILE, - resourceId: file.id, - resourceName: file.name, - description: `Renamed file to "${file.name}"`, - metadata: { - operation: fileOperations.rename.id, - actor: auditAttribution.actor, - }, - request, - }) - await notifyWorkspaceFilesChanged(workspaceId) return { file } } -export const renameWorkspaceFile = { +export const renameWorkspaceFile = defineAuthorizedWorkspaceFileUseCase({ operation: fileOperations.rename, + resolveContext: ({ input }) => resolveActiveWorkspaceFileContext(input), execute: executeRenameWorkspaceFile, -} as const + projectAudit: ({ result }) => ({ + action: AuditAction.FILE_UPDATED, + resourceType: AuditResourceType.FILE, + resourceId: result.file.id, + resourceName: result.file.name, + description: `Renamed file to "${result.file.name}"`, + }), + afterSuccess: ({ context }) => notifyWorkspaceFilesChanged(context.workspaceId), +}) 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 new file mode 100644 index 00000000000..cd69a123118 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/resolve-workspace-file-reference.test.ts @@ -0,0 +1,107 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const mocks = vi.hoisted(() => ({ + fetchBuffer: vi.fn(), + loadContext: vi.fn(), + resolvePermission: vi.fn(), + resolveStoredReference: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: () => true, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + fetchWorkspaceFileBuffer: mocks.fetchBuffer, + loadActiveWorkspaceFileContext: mocks.loadContext, + resolveWorkspaceFileReference: mocks.resolveStoredReference, +})) + +import { defineWorkspaceOperation } from '@/lib/core/application' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { + readWorkspaceFileReference, + resolveWorkspaceFileReference, +} from '@/lib/workspace-files/application/resolve-workspace-file-reference' + +const principal = { kind: 'session' as const, userId: 'user-1', sessionId: 'session-1' } +const file = { + id: 'file-1', + workspaceId: 'workspace-1', + name: 'source.txt', + key: 'workspace/workspace-1/source.txt', + size: 12, +} +const context = { + fileId: file.id, + workspaceId: file.workspaceId, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'billing-owner', +} + +describe('workspace file reference application service', () => { + beforeEach(() => { + vi.clearAllMocks() + mocks.resolveStoredReference.mockResolvedValue(file) + mocks.loadContext.mockResolvedValue(context) + mocks.resolvePermission.mockResolvedValue('admin') + mocks.fetchBuffer.mockResolvedValue(Buffer.from('source')) + }) + + it('uses one fixed semantic use case for an authorized reference lookup', async () => { + await expect( + resolveWorkspaceFileReference({ + principal, + operation: fileOperations.rename, + workspaceId: 'workspace-1', + reference: 'files/source.txt', + }) + ).resolves.toBe(file) + + expect(mocks.resolveStoredReference).toHaveBeenCalledTimes(1) + expect(mocks.loadContext).toHaveBeenCalledTimes(1) + expect(mocks.resolvePermission).toHaveBeenCalledTimes(1) + }) + + it('reads a referenced file with one canonical load and authorization', async () => { + await expect( + readWorkspaceFileReference({ + principal, + workspaceId: 'workspace-1', + reference: 'files/source.txt', + maxBytes: 512, + }) + ).resolves.toEqual({ file, content: Buffer.from('source') }) + + expect(mocks.resolveStoredReference).toHaveBeenCalledTimes(1) + expect(mocks.loadContext).toHaveBeenCalledTimes(1) + expect(mocks.resolvePermission).toHaveBeenCalledTimes(1) + expect(mocks.fetchBuffer).toHaveBeenCalledWith(file, { maxBytes: 512 }) + }) + + it('fails before canonical loading for an unregistered operation object', async () => { + const duplicateOperation = defineWorkspaceOperation({ + id: fileOperations.rename.id, + minimumRole: 'write', + workspaceApiKey: 'deny', + principalKinds: ['session'], + }) + + await expect( + resolveWorkspaceFileReference({ + principal, + operation: duplicateOperation, + workspaceId: 'workspace-1', + reference: 'files/source.txt', + }) + ).rejects.toThrow('No workspace file reference resolver is defined for files.rename') + + expect(mocks.resolveStoredReference).not.toHaveBeenCalled() + expect(mocks.loadContext).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 59397f47646..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 @@ -1,15 +1,14 @@ import type { Principal } from '@sim/auth/principal' -import type { WorkspaceOperation } from '@/lib/core/application' +import type { OperationUseCase, WorkspaceOperation } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { - loadActiveWorkspaceContext, + fetchWorkspaceFileBuffer, + loadActiveWorkspaceFileContext, resolveWorkspaceFileReference as resolveStoredWorkspaceFileReference, type WorkspaceFileRecord, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { authorizeWorkspaceFileAccess } from '@/lib/workspace-files/application/authorization' -import { loadAuthorizedWorkspaceFile } from '@/lib/workspace-files/application/load-authorized-workspace-file' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' import { fileOperations } from '@/lib/workspace-files/application/operations' -import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' export interface ResolveWorkspaceFileReferenceInput { principal: Principal @@ -18,6 +17,71 @@ export interface ResolveWorkspaceFileReferenceInput { reference: string } +interface WorkspaceFileReferenceInput { + workspaceId: string + reference: string +} + +interface WorkspaceFileReferenceResult { + file: WorkspaceFileRecord +} + +interface WorkspaceFileReferenceReadInput extends WorkspaceFileReferenceInput { + maxBytes: number +} + +async function resolveWorkspaceFileReferenceContext({ + input, +}: { + input: WorkspaceFileReferenceInput +}) { + const file = await resolveStoredWorkspaceFileReference(input.workspaceId, input.reference) + if (!file) throw new OrchestrationError('not_found', 'File not found') + const canonical = await loadActiveWorkspaceFileContext(file.id) + if (!canonical || canonical.workspaceId !== input.workspaceId) { + throw new OrchestrationError('not_found', 'File not found') + } + return { ...canonical, file } +} + +function defineWorkspaceFileReferenceUseCase(operation: O) { + return defineAuthorizedWorkspaceFileUseCase({ + operation, + resolveContext: resolveWorkspaceFileReferenceContext, + async execute({ context }): Promise { + return { file: context.file } + }, + }) +} + +type WorkspaceFileReferenceUseCase = OperationUseCase< + WorkspaceOperation, + WorkspaceFileReferenceInput, + WorkspaceFileReferenceResult +> + +const workspaceFileReferenceUseCases = { + [fileOperations.readContent.id]: defineWorkspaceFileReferenceUseCase(fileOperations.readContent), + [fileOperations.create.id]: defineWorkspaceFileReferenceUseCase(fileOperations.create), + [fileOperations.rename.id]: defineWorkspaceFileReferenceUseCase(fileOperations.rename), + [fileOperations.updateContent.id]: defineWorkspaceFileReferenceUseCase( + fileOperations.updateContent + ), + [fileOperations.move.id]: defineWorkspaceFileReferenceUseCase(fileOperations.move), + [fileOperations.delete.id]: defineWorkspaceFileReferenceUseCase(fileOperations.delete), + [fileOperations.updateShare.id]: defineWorkspaceFileReferenceUseCase(fileOperations.updateShare), +} satisfies Record + +function getWorkspaceFileReferenceUseCase(operation: WorkspaceOperation) { + const operationId = operation.id as keyof typeof workspaceFileReferenceUseCases + const useCase: WorkspaceFileReferenceUseCase | undefined = + workspaceFileReferenceUseCases[operationId] + if (!useCase || useCase.operation !== operation) { + throw new Error(`No workspace file reference resolver is defined for ${operation.id}`) + } + return useCase +} + /** Resolve one workspace-file reference under an explicit semantic operation policy. */ export async function resolveWorkspaceFileReference({ principal, @@ -25,21 +89,9 @@ export async function resolveWorkspaceFileReference({ workspaceId, reference, }: ResolveWorkspaceFileReferenceInput): Promise { - const workspace = await loadActiveWorkspaceContext(workspaceId) - if (!workspace) throw new OrchestrationError('not_found', 'Workspace not found') - await authorizeWorkspaceFileAccess(principal, operation, workspace) - - const file = await resolveStoredWorkspaceFileReference(workspaceId, reference) - if (!file) throw new OrchestrationError('not_found', 'File not found') - - await loadAuthorizedWorkspaceFile({ - principal, - operation, - fileId: file.id, - assertedWorkspaceId: workspaceId, - }) - - return file + const useCase = getWorkspaceFileReferenceUseCase(operation) + const result = await useCase.execute({ principal, input: { workspaceId, reference } }) + return result.file } export interface ReadWorkspaceFileReferenceInput @@ -47,6 +99,18 @@ export interface ReadWorkspaceFileReferenceInput maxBytes: number } +const readWorkspaceFileReferenceUseCase = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.readContent, + resolveContext: ({ input }: { input: WorkspaceFileReferenceReadInput }) => + resolveWorkspaceFileReferenceContext({ input }), + async execute({ input, context }): Promise<{ file: WorkspaceFileRecord; content: Buffer }> { + return { + file: context.file, + content: await fetchWorkspaceFileBuffer(context.file, { maxBytes: input.maxBytes }), + } + }, +}) + /** Resolve one trusted workspace-file reference and read it under the shared file policy. */ export async function readWorkspaceFileReference({ principal, @@ -54,14 +118,8 @@ export async function readWorkspaceFileReference({ reference, maxBytes, }: ReadWorkspaceFileReferenceInput): Promise<{ file: WorkspaceFileRecord; content: Buffer }> { - const file = await resolveWorkspaceFileReference({ - principal, - operation: fileOperations.readContent, - workspaceId, - reference, - }) - return readWorkspaceFileContent.execute({ + return readWorkspaceFileReferenceUseCase.execute({ principal, - input: { fileId: file.id, assertedWorkspaceId: workspaceId, maxBytes }, + input: { workspaceId, reference, maxBytes }, }) } diff --git a/apps/sim/lib/workspace-files/application/restore-workspace-file.test.ts b/apps/sim/lib/workspace-files/application/restore-workspace-file.test.ts index c3994eb1a93..99389ff56b8 100644 --- a/apps/sim/lib/workspace-files/application/restore-workspace-file.test.ts +++ b/apps/sim/lib/workspace-files/application/restore-workspace-file.test.ts @@ -8,7 +8,12 @@ const mocks = vi.hoisted(() => ({ restoreStored: vi.fn(), recordAudit: vi.fn(), notify: vi.fn(), - authorize: vi.fn(), + resolvePermission: vi.fn(), +})) + +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: () => true, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, })) vi.mock('@sim/audit', () => ({ @@ -21,9 +26,6 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ loadWorkspaceFileLifecycleContext: mocks.loadLifecycle, restoreWorkspaceFile: mocks.restoreStored, })) -vi.mock('@/lib/workspace-files/application/authorization', () => ({ - authorizeWorkspaceFileAccess: mocks.authorize, -})) import { restoreWorkspaceFileOperation } from '@/lib/workspace-files/application/restore-workspace-file' @@ -40,7 +42,7 @@ describe('restoreWorkspaceFileOperation', () => { beforeEach(() => { vi.clearAllMocks() mocks.loadLifecycle.mockResolvedValue(context) - mocks.authorize.mockResolvedValue(undefined) + mocks.resolvePermission.mockResolvedValue('admin') mocks.restoreStored.mockResolvedValue(undefined) mocks.notify.mockResolvedValue(undefined) }) @@ -52,11 +54,7 @@ describe('restoreWorkspaceFileOperation', () => { }) expect(result).toEqual({ restored: true }) - expect(mocks.authorize).toHaveBeenCalledWith( - expect.objectContaining({ kind: 'session' }), - expect.objectContaining({ id: 'files.restore' }), - expect.objectContaining({ workspaceId: 'workspace-1', fileId: 'file-1' }) - ) + expect(mocks.resolvePermission).toHaveBeenCalled() expect(mocks.restoreStored).toHaveBeenCalledWith('workspace-1', 'file-1') expect(mocks.recordAudit).toHaveBeenCalledWith( expect.objectContaining({ @@ -66,6 +64,9 @@ describe('restoreWorkspaceFileOperation', () => { }) ) expect(mocks.notify).toHaveBeenCalledWith('workspace-1') + expect(mocks.recordAudit.mock.invocationCallOrder[0]).toBeLessThan( + mocks.notify.mock.invocationCallOrder[0] + ) }) it('conceals an asserted-workspace mismatch before authorization', async () => { @@ -75,7 +76,7 @@ describe('restoreWorkspaceFileOperation', () => { input: { fileId: 'file-1', assertedWorkspaceId: 'workspace-2' }, }) ).rejects.toMatchObject({ code: 'not_found' }) - expect(mocks.authorize).not.toHaveBeenCalled() + expect(mocks.resolvePermission).not.toHaveBeenCalled() expect(mocks.restoreStored).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/lib/workspace-files/application/restore-workspace-file.ts b/apps/sim/lib/workspace-files/application/restore-workspace-file.ts index cb2bd06288a..918fb2c746e 100644 --- a/apps/sim/lib/workspace-files/application/restore-workspace-file.ts +++ b/apps/sim/lib/workspace-files/application/restore-workspace-file.ts @@ -1,15 +1,14 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { type Principal, resolvePrincipalAuditAttribution } from '@sim/auth/principal' +import { AuditAction, AuditResourceType } from '@sim/audit' import { createLogger } from '@sim/logger' -import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' -import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { AuthorizedWorkspaceUseCaseContext } from '@/lib/core/application' import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' import { - loadWorkspaceFileLifecycleContext, restoreWorkspaceFile, + type WorkspaceFileLifecycleContext, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { authorizeWorkspaceFileAccess } from '@/lib/workspace-files/application/authorization' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' import { fileOperations } from '@/lib/workspace-files/application/operations' +import { resolveWorkspaceFileLifecycleContext } from '@/lib/workspace-files/application/workspace-file-context' const logger = createLogger('RestoreWorkspaceFile') @@ -22,57 +21,34 @@ export interface RestoreWorkspaceFileResult { restored: true } -interface RestoreWorkspaceFileArguments { - principal: Principal - input: RestoreWorkspaceFileInput - request?: OrchestrationRequestContext -} - async function executeRestoreWorkspaceFile({ - principal, - input, - request, -}: RestoreWorkspaceFileArguments): Promise { - const canonical = await loadWorkspaceFileLifecycleContext(input.fileId) - if ( - !canonical || - (input.assertedWorkspaceId !== undefined && input.assertedWorkspaceId !== canonical.workspaceId) - ) { - throw new OrchestrationError('not_found', 'File not found') - } - - await authorizeWorkspaceFileAccess(principal, fileOperations.restore, { - workspaceId: canonical.workspaceId, - workspaceOrganizationId: canonical.workspaceOrganizationId, - allowPersonalApiKeys: canonical.allowPersonalApiKeys, - fileId: canonical.fileId, - }) - - const auditAttribution = resolvePrincipalAuditAttribution(principal) - await restoreWorkspaceFile(canonical.workspaceId, canonical.fileId) - - recordAudit({ - workspaceId: canonical.workspaceId, - actorId: auditAttribution.actorId, - actorName: auditAttribution.actorName, - action: AuditAction.FILE_RESTORED, - resourceType: AuditResourceType.FILE, - resourceId: canonical.fileId, - resourceName: canonical.fileId, - description: `Restored workspace file ${canonical.fileId}`, - metadata: { operation: fileOperations.restore.id, actor: auditAttribution.actor }, - request, - }) - await notifyWorkspaceFilesChanged(canonical.workspaceId) - logger.info('Restored workspace file', { - workspaceId: canonical.workspaceId, - fileId: canonical.fileId, - principalKind: principal.kind, - }) + context, +}: AuthorizedWorkspaceUseCaseContext< + typeof fileOperations.restore, + RestoreWorkspaceFileInput, + WorkspaceFileLifecycleContext +>): Promise { + await restoreWorkspaceFile(context.workspaceId, context.fileId) return { restored: true } } -export const restoreWorkspaceFileOperation = { +export const restoreWorkspaceFileOperation = defineAuthorizedWorkspaceFileUseCase({ operation: fileOperations.restore, + resolveContext: ({ input }) => resolveWorkspaceFileLifecycleContext(input), execute: executeRestoreWorkspaceFile, -} as const + projectAudit: ({ context }) => ({ + action: AuditAction.FILE_RESTORED, + resourceType: AuditResourceType.FILE, + resourceId: context.fileId, + resourceName: context.fileId, + description: `Restored workspace file ${context.fileId}`, + }), + async afterSuccess({ principal, context }) { + await notifyWorkspaceFilesChanged(context.workspaceId) + logger.info('Restored workspace file', { + workspaceId: context.workspaceId, + fileId: context.fileId, + principalKind: principal.kind, + }) + }, +}) diff --git a/apps/sim/lib/workspace-files/application/share-workspace-file.ts b/apps/sim/lib/workspace-files/application/share-workspace-file.ts index aa0c6c4e1ec..599d3c3ed09 100644 --- a/apps/sim/lib/workspace-files/application/share-workspace-file.ts +++ b/apps/sim/lib/workspace-files/application/share-workspace-file.ts @@ -1,8 +1,7 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { type Principal, resolvePrincipalAuditAttribution } from '@sim/auth/principal' +import { AuditAction, AuditResourceType } from '@sim/audit' +import { resolvePrincipalAttribution } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import type { ShareAuthType, ShareRecord } from '@/lib/api/contracts/public-shares' -import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' import { OrchestrationError } from '@/lib/core/orchestration/types' import { getShareForResource, @@ -10,8 +9,9 @@ import { upsertFileShare, } from '@/lib/public-shares/share-manager' import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { loadAuthorizedWorkspaceFile } from '@/lib/workspace-files/application/load-authorized-workspace-file' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' import { fileOperations } from '@/lib/workspace-files/application/operations' +import { resolveActiveWorkspaceFileContext } from '@/lib/workspace-files/application/workspace-file-context' import { PublicFileSharingNotAllowedError, validatePublicFileSharing, @@ -50,122 +50,77 @@ export class WorkspaceFileShareNoopError extends Error { } } -function requireWorkspaceFileShareUserId(principal: Principal): string { - switch (principal.kind) { - case 'session': - case 'personal_api_key': - return principal.userId - case 'delegated': - return principal.subjectUserId - case 'workspace_api_key': - throw new OrchestrationError( - 'forbidden', - 'Workspace API keys cannot change public file sharing' - ) - } -} +export const getWorkspaceFileShare = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.readShare, + resolveContext: ({ input }: { input: GetWorkspaceFileShareInput }) => + resolveActiveWorkspaceFileContext(input), + async execute({ context }): Promise { + const share = await getShareForResource('file', context.fileId) + return { share } + }, +}) -async function executeGetWorkspaceFileShare({ - principal, - input, -}: { - principal: Principal - input: GetWorkspaceFileShareInput -}): Promise { - const canonical = await loadAuthorizedWorkspaceFile({ - principal, - operation: fileOperations.readShare, - fileId: input.fileId, - assertedWorkspaceId: input.assertedWorkspaceId, - }) - const share = await getShareForResource('file', canonical.fileId) - return { share } -} +export const updateWorkspaceFileShare = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.updateShare, + async resolveContext({ input }: { input: UpdateWorkspaceFileShareInput }) { + const canonical = await resolveActiveWorkspaceFileContext(input) + const file = await getWorkspaceFile(canonical.workspaceId, canonical.fileId, { + throwOnError: true, + }) + if (!file) throw new OrchestrationError('not_found', 'File not found') + return { ...canonical, file } + }, + async execute({ principal, input, context }): Promise { + const subjectUserId = resolvePrincipalAttribution(principal).attributedUserId -async function executeUpdateWorkspaceFileShare({ - principal, - input, - request, -}: { - principal: Principal - input: UpdateWorkspaceFileShareInput - request?: OrchestrationRequestContext -}): Promise { - const canonical = await loadAuthorizedWorkspaceFile({ - principal, - operation: fileOperations.updateShare, - fileId: input.fileId, - assertedWorkspaceId: input.assertedWorkspaceId, - }) - const file = await getWorkspaceFile(canonical.workspaceId, canonical.fileId, { - throwOnError: true, - }) - if (!file) throw new OrchestrationError('not_found', 'File not found') + const existingShare = await getShareForResource('file', context.fileId) + if (input.noOpIfInactive && !input.isActive && !existingShare?.isActive) { + throw new WorkspaceFileShareNoopError() + } - const existingShare = await getShareForResource('file', canonical.fileId) - if (input.noOpIfInactive && !input.isActive && !existingShare?.isActive) { - throw new WorkspaceFileShareNoopError() - } + if (input.isActive) { + const effectiveAuthType = input.authType ?? existingShare?.authType ?? 'public' + try { + await validatePublicFileSharing(subjectUserId, context.workspaceId, effectiveAuthType) + } catch (error) { + if (error instanceof PublicFileSharingNotAllowedError) + throw new OrchestrationError('forbidden', error.message) + throw error + } + } - if (input.isActive) { - const effectiveAuthType = input.authType ?? existingShare?.authType ?? 'public' + let share: ShareRecord try { - const subjectUserId = requireWorkspaceFileShareUserId(principal) - await validatePublicFileSharing(subjectUserId, canonical.workspaceId, effectiveAuthType) + share = await upsertFileShare({ + workspaceId: context.workspaceId, + fileId: context.fileId, + userId: subjectUserId, + isActive: input.isActive, + authType: input.authType, + password: input.password, + allowedEmails: input.allowedEmails, + token: input.token, + }) } catch (error) { - if (error instanceof PublicFileSharingNotAllowedError) - throw new OrchestrationError('forbidden', error.message) + if (error instanceof ShareValidationError) { + throw new OrchestrationError('validation', error.message) + } throw error } - } + if (!share) throw new Error('Updating workspace file share returned no share') - let share: ShareRecord - try { - share = await upsertFileShare({ - workspaceId: canonical.workspaceId, - fileId: canonical.fileId, - userId: requireWorkspaceFileShareUserId(principal), - isActive: input.isActive, - authType: input.authType, - password: input.password, - allowedEmails: input.allowedEmails, - token: input.token, + logger.info(`${input.isActive ? 'Enabled' : 'Disabled'} share for workspace file`, { + workspaceId: context.workspaceId, + fileId: context.fileId, + principalKind: principal.kind, }) - } catch (error) { - if (error instanceof ShareValidationError) { - throw new OrchestrationError('validation', error.message) - } - throw error - } - if (!share) throw new Error('Updating workspace file share returned no share') - - const auditAttribution = resolvePrincipalAuditAttribution(principal) - recordAudit({ - workspaceId: canonical.workspaceId, - actorId: auditAttribution.actorId, - actorName: auditAttribution.actorName, + return { share } + }, + projectAudit: ({ input, context }) => ({ action: input.isActive ? AuditAction.FILE_SHARED : AuditAction.FILE_SHARE_DISABLED, resourceType: AuditResourceType.FILE, - resourceId: canonical.fileId, - resourceName: file.name, - description: `${input.isActive ? 'Enabled' : 'Disabled'} public share for "${file.name}"`, - metadata: { operation: fileOperations.updateShare.id, actor: auditAttribution.actor }, - request, - }) - logger.info(`${input.isActive ? 'Enabled' : 'Disabled'} share for workspace file`, { - workspaceId: canonical.workspaceId, - fileId: canonical.fileId, - principalKind: principal.kind, - }) - return { share } -} - -export const getWorkspaceFileShare = { - operation: fileOperations.readShare, - execute: executeGetWorkspaceFileShare, -} as const - -export const updateWorkspaceFileShare = { - operation: fileOperations.updateShare, - execute: executeUpdateWorkspaceFileShare, -} as const + resourceId: context.fileId, + resourceName: context.file.name, + description: `${input.isActive ? 'Enabled' : 'Disabled'} public share for "${context.file.name}"`, + }), +}) diff --git a/apps/sim/lib/workspace-files/application/update-workspace-file-content.ts b/apps/sim/lib/workspace-files/application/update-workspace-file-content.ts index 2344573109c..56d096c7a2a 100644 --- a/apps/sim/lib/workspace-files/application/update-workspace-file-content.ts +++ b/apps/sim/lib/workspace-files/application/update-workspace-file-content.ts @@ -1,11 +1,6 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { - type Principal, - resolvePrincipalAttribution, - resolvePrincipalAuditAttribution, -} from '@sim/auth/principal' +import { AuditAction, AuditResourceType } from '@sim/audit' +import { type Principal, resolvePrincipalAttribution } from '@sim/auth/principal' import { createLogger } from '@sim/logger' -import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' import { OrchestrationError } from '@/lib/core/orchestration/types' import { ContentVersionConflictError, @@ -16,8 +11,9 @@ import { EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE, type WorkspaceFileSecretProvenance, } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' -import { loadAuthorizedWorkspaceFile } from '@/lib/workspace-files/application/load-authorized-workspace-file' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' import { fileOperations } from '@/lib/workspace-files/application/operations' +import { resolveActiveWorkspaceFileContext } from '@/lib/workspace-files/application/workspace-file-context' import { MAX_WORKSPACE_FILE_CONTENT_BYTES } from '@/lib/workspace-files/orchestration' const logger = createLogger('UpdateWorkspaceFileContent') @@ -43,89 +39,20 @@ export interface UpdateWorkspaceFileContentBufferInput content: Buffer } -interface UpdateWorkspaceFileContentArguments { - principal: Principal - input: UpdateWorkspaceFileContentInput - request?: OrchestrationRequestContext -} - -interface UpdateWorkspaceFileContentBufferArguments { - principal: Principal - input: UpdateWorkspaceFileContentBufferInput - request?: OrchestrationRequestContext -} - -async function admitUpdateWorkspaceFileContent( - principal: Principal, - fileId: string -): Promise { - await loadAuthorizedWorkspaceFile({ - principal, - operation: fileOperations.updateContent, - fileId, - }) -} - -async function executeUpdateWorkspaceFileContent({ - principal, - input, - request, -}: UpdateWorkspaceFileContentArguments): Promise { - const canonical = await loadAuthorizedWorkspaceFile({ - principal, - operation: fileOperations.updateContent, - fileId: input.fileId, - assertedWorkspaceId: input.assertedWorkspaceId, - }) - const content = Buffer.from(input.content, input.encoding === 'base64' ? 'base64' : 'utf-8') - if (content.length > MAX_WORKSPACE_FILE_CONTENT_BYTES) { - throw new OrchestrationError( - 'payload_too_large', - `File size exceeds ${MAX_WORKSPACE_FILE_CONTENT_BYTES / 1024 / 1024}MB limit` - ) - } - - return updateAuthorizedWorkspaceFileContent({ principal, input, content, request, canonical }) -} - -async function executeUpdateWorkspaceFileContentBuffer({ - principal, - input, - request, -}: UpdateWorkspaceFileContentBufferArguments): Promise { - const canonical = await loadAuthorizedWorkspaceFile({ - principal, - operation: fileOperations.updateContent, - fileId: input.fileId, - assertedWorkspaceId: input.assertedWorkspaceId, - }) - return updateAuthorizedWorkspaceFileContent({ - principal, - input, - content: input.content, - request, - canonical, - }) -} - async function updateAuthorizedWorkspaceFileContent({ principal, input, content, - request, canonical, }: { principal: Principal input: Omit content: Buffer - request?: OrchestrationRequestContext - canonical: Awaited> + canonical: Awaited> }): Promise { const attribution = resolvePrincipalAttribution(principal, { workspaceBillingOwnerUserId: canonical.billedAccountUserId, }) - const auditAttribution = resolvePrincipalAuditAttribution(principal) - let file: WorkspaceFileRecord try { file = await updateStoredWorkspaceFileContent( @@ -160,33 +87,66 @@ async function updateAuthorizedWorkspaceFileContent({ size: content.length, principalKind: principal.kind, }) - recordAudit({ - workspaceId: canonical.workspaceId, - actorId: auditAttribution.actorId, - actorName: auditAttribution.actorName, + return { file } +} + +function projectUpdateWorkspaceFileContentAudit(result: UpdateWorkspaceFileContentResult) { + return { action: AuditAction.FILE_UPDATED, resourceType: AuditResourceType.FILE, - resourceId: file.id, - resourceName: file.name, - description: `Updated content of file "${file.name}"`, - metadata: { - operation: fileOperations.updateContent.id, - actor: auditAttribution.actor, - contentSize: content.length, - }, - request, - }) + resourceId: result.file.id, + resourceName: result.file.name, + description: `Updated content of file "${result.file.name}"`, + metadata: { contentSize: result.file.size }, + } as const +} - return { file } +const admitUpdateWorkspaceFileContentUseCase = defineAuthorizedWorkspaceFileUseCase({ + operation: fileOperations.updateContent, + resolveContext: ({ input }: { input: { fileId: string } }) => + resolveActiveWorkspaceFileContext(input), + async execute() {}, +}) + +export async function admitUpdateWorkspaceFileContent( + principal: Principal, + fileId: string +): Promise { + await admitUpdateWorkspaceFileContentUseCase.execute({ principal, input: { fileId } }) } -export const updateWorkspaceFileContent = { +export const updateWorkspaceFileContent = defineAuthorizedWorkspaceFileUseCase({ operation: fileOperations.updateContent, - admit: admitUpdateWorkspaceFileContent, - execute: executeUpdateWorkspaceFileContent, -} as const + resolveContext: ({ input }: { input: UpdateWorkspaceFileContentInput }) => + resolveActiveWorkspaceFileContext(input), + async execute({ principal, input, context }): Promise { + const content = Buffer.from(input.content, input.encoding === 'base64' ? 'base64' : 'utf-8') + if (content.length > MAX_WORKSPACE_FILE_CONTENT_BYTES) { + throw new OrchestrationError( + 'payload_too_large', + `File size exceeds ${MAX_WORKSPACE_FILE_CONTENT_BYTES / 1024 / 1024}MB limit` + ) + } + return updateAuthorizedWorkspaceFileContent({ + principal, + input, + content, + canonical: context, + }) + }, + projectAudit: ({ result }) => projectUpdateWorkspaceFileContentAudit(result), +}) -export const updateWorkspaceFileContentFromBuffer = { +export const updateWorkspaceFileContentFromBuffer = defineAuthorizedWorkspaceFileUseCase({ operation: fileOperations.updateContent, - execute: executeUpdateWorkspaceFileContentBuffer, -} as const + resolveContext: ({ input }: { input: UpdateWorkspaceFileContentBufferInput }) => + resolveActiveWorkspaceFileContext(input), + execute: ({ principal, input, context }) => + updateAuthorizedWorkspaceFileContent({ + principal, + input, + content: input.content, + canonical: context, + }), + projectAudit: ({ result }) => projectUpdateWorkspaceFileContentAudit(result), +}) diff --git a/apps/sim/lib/workspace-files/application/update-workspace-file-dimensions.test.ts b/apps/sim/lib/workspace-files/application/update-workspace-file-dimensions.test.ts index cdb76174a0f..5800c0b42c1 100644 --- a/apps/sim/lib/workspace-files/application/update-workspace-file-dimensions.test.ts +++ b/apps/sim/lib/workspace-files/application/update-workspace-file-dimensions.test.ts @@ -4,14 +4,17 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' const mocks = vi.hoisted(() => ({ - loadAuthorized: vi.fn(), + loadContext: vi.fn(), updateDimensions: vi.fn(), + resolvePermission: vi.fn(), })) -vi.mock('@/lib/workspace-files/application/load-authorized-workspace-file', () => ({ - loadAuthorizedWorkspaceFile: mocks.loadAuthorized, +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: () => true, + resolveEffectiveWorkspacePermission: mocks.resolvePermission, })) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + loadActiveWorkspaceFileContext: mocks.loadContext, updateWorkspaceFileDimensions: mocks.updateDimensions, })) @@ -20,13 +23,14 @@ import { updateWorkspaceFileDimensionsOperation } from '@/lib/workspace-files/ap describe('updateWorkspaceFileDimensionsOperation', () => { beforeEach(() => { vi.clearAllMocks() - mocks.loadAuthorized.mockResolvedValue({ + mocks.loadContext.mockResolvedValue({ fileId: 'file-1', workspaceId: 'workspace-1', workspaceOrganizationId: null, allowPersonalApiKeys: true, billedAccountUserId: 'billing-owner', }) + mocks.resolvePermission.mockResolvedValue('admin') mocks.updateDimensions.mockResolvedValue(false) }) @@ -43,11 +47,7 @@ describe('updateWorkspaceFileDimensionsOperation', () => { }) expect(result).toEqual({ success: false }) - expect(mocks.loadAuthorized).toHaveBeenCalledWith( - expect.objectContaining({ - operation: expect.objectContaining({ id: 'files.update_metadata' }), - }) - ) + expect(mocks.loadContext).toHaveBeenCalledWith('file-1', { includeDeleted: undefined }) expect(mocks.updateDimensions).toHaveBeenCalledWith('workspace-1', 'file-1', { key: 'workspace/workspace-1/current-key', width: 800, diff --git a/apps/sim/lib/workspace-files/application/update-workspace-file-dimensions.ts b/apps/sim/lib/workspace-files/application/update-workspace-file-dimensions.ts index 5cc117049bc..aff7c3d22e1 100644 --- a/apps/sim/lib/workspace-files/application/update-workspace-file-dimensions.ts +++ b/apps/sim/lib/workspace-files/application/update-workspace-file-dimensions.ts @@ -1,8 +1,11 @@ -import type { Principal } from '@sim/auth/principal' -import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' -import { updateWorkspaceFileDimensions } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { loadAuthorizedWorkspaceFile } from '@/lib/workspace-files/application/load-authorized-workspace-file' +import type { AuthorizedWorkspaceUseCaseContext } from '@/lib/core/application' +import { + type ActiveWorkspaceFileContext, + updateWorkspaceFileDimensions, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' import { fileOperations } from '@/lib/workspace-files/application/operations' +import { resolveActiveWorkspaceFileContext } from '@/lib/workspace-files/application/workspace-file-context' export interface UpdateWorkspaceFileDimensionsInput { fileId: string @@ -17,20 +20,14 @@ export interface UpdateWorkspaceFileDimensionsResult { } async function executeUpdateWorkspaceFileDimensions({ - principal, input, -}: { - principal: Principal - input: UpdateWorkspaceFileDimensionsInput - request?: OrchestrationRequestContext -}): Promise { - const canonical = await loadAuthorizedWorkspaceFile({ - principal, - operation: fileOperations.updateMetadata, - fileId: input.fileId, - assertedWorkspaceId: input.assertedWorkspaceId, - }) - const success = await updateWorkspaceFileDimensions(canonical.workspaceId, canonical.fileId, { + context, +}: AuthorizedWorkspaceUseCaseContext< + typeof fileOperations.updateMetadata, + UpdateWorkspaceFileDimensionsInput, + ActiveWorkspaceFileContext +>): Promise { + const success = await updateWorkspaceFileDimensions(context.workspaceId, context.fileId, { key: input.key, width: input.width, height: input.height, @@ -38,7 +35,8 @@ async function executeUpdateWorkspaceFileDimensions({ return { success } } -export const updateWorkspaceFileDimensionsOperation = { +export const updateWorkspaceFileDimensionsOperation = defineAuthorizedWorkspaceFileUseCase({ operation: fileOperations.updateMetadata, + resolveContext: ({ input }) => resolveActiveWorkspaceFileContext(input), execute: executeUpdateWorkspaceFileDimensions, -} as const +}) diff --git a/apps/sim/lib/workspace-files/application/workspace-file-context.ts b/apps/sim/lib/workspace-files/application/workspace-file-context.ts new file mode 100644 index 00000000000..4dba54c77e3 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/workspace-file-context.ts @@ -0,0 +1,41 @@ +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + type ActiveWorkspaceFileContext, + loadActiveWorkspaceFileContext, + loadWorkspaceFileLifecycleContext, + type WorkspaceFileLifecycleContext, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' + +export interface WorkspaceFileContextInput { + fileId: string + assertedWorkspaceId?: string + includeDeleted?: boolean +} + +export async function resolveActiveWorkspaceFileContext( + input: WorkspaceFileContextInput +): Promise { + const canonical = await loadActiveWorkspaceFileContext(input.fileId, { + includeDeleted: input.includeDeleted, + }) + if ( + !canonical || + (input.assertedWorkspaceId !== undefined && input.assertedWorkspaceId !== canonical.workspaceId) + ) { + throw new OrchestrationError('not_found', 'File not found') + } + return canonical +} + +export async function resolveWorkspaceFileLifecycleContext( + input: WorkspaceFileContextInput +): Promise { + const canonical = await loadWorkspaceFileLifecycleContext(input.fileId) + if ( + !canonical || + (input.assertedWorkspaceId !== undefined && input.assertedWorkspaceId !== canonical.workspaceId) + ) { + throw new OrchestrationError('not_found', 'File not found') + } + return canonical +} diff --git a/apps/sim/lib/workspace-files/application/workspace-file-folders.test.ts b/apps/sim/lib/workspace-files/application/workspace-file-folders.test.ts index 3c1a84a8e8e..0608deaedbe 100644 --- a/apps/sim/lib/workspace-files/application/workspace-file-folders.test.ts +++ b/apps/sim/lib/workspace-files/application/workspace-file-folders.test.ts @@ -3,24 +3,50 @@ */ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { mockAuthorize, mockCreate, mockRelocate, mockAudit, mockNotify } = vi.hoisted(() => ({ - mockAuthorize: vi.fn(), +const { + events, + mockLoadContext, + mockResolvePermission, + mockAssertItems, + mockArchive, + mockCreate, + mockRelocate, + mockRestore, + mockAudit, + mockNotify, +} = vi.hoisted(() => ({ + events: [] as string[], + mockLoadContext: vi.fn(), + mockResolvePermission: vi.fn(), + mockAssertItems: vi.fn(), + mockArchive: vi.fn(), mockCreate: vi.fn(), mockRelocate: vi.fn(), + mockRestore: vi.fn(), mockAudit: vi.fn(), mockNotify: vi.fn(), })) -vi.mock('@/lib/workspace-files/application/workspace-operation-context', () => ({ - authorizeWorkspaceFileOperation: mockAuthorize, -})) vi.mock('@/lib/uploads/contexts/workspace', () => ({ - assertWorkspaceFileItemsBelongToWorkspace: mockAuthorize, + assertWorkspaceFileItemsBelongToWorkspace: mockAssertItems, + bulkArchiveWorkspaceFileItems: mockArchive, createWorkspaceFileFolderAtPath: mockCreate, + loadWorkspaceFileOperationContext: mockLoadContext, relocateWorkspaceFileFolderByPath: mockRelocate, + restoreWorkspaceFileFolder: mockRestore, +})) +vi.mock('@sim/platform-authz/workspace', () => ({ + permissionSatisfies: (actual: string | null, required: string) => + actual === 'admin' || actual === required || (actual === 'write' && required === 'read'), + resolveEffectiveWorkspacePermission: mockResolvePermission, })) vi.mock('@sim/audit', () => ({ - AuditAction: { FOLDER_CREATED: 'folder.created', FOLDER_MOVED: 'folder.moved' }, + AuditAction: { + FOLDER_CREATED: 'folder.created', + FOLDER_DELETED: 'folder.deleted', + FOLDER_MOVED: 'folder.moved', + FOLDER_RESTORED: 'folder.restored', + }, AuditResourceType: { FOLDER: 'folder' }, recordAudit: mockAudit, })) @@ -28,6 +54,8 @@ vi.mock('@/lib/realtime/notify', () => ({ notifyWorkspaceFilesChanged: mockNotif import { createWorkspaceFileFolderOperation, + deleteWorkspaceFileFolderOperation, + restoreWorkspaceFileFolderOperation, updateWorkspaceFileFolderOperation, } from '@/lib/workspace-files/application/workspace-file-folders' @@ -46,25 +74,36 @@ const folder = { describe('workspace file folder operations', () => { beforeEach(() => { vi.clearAllMocks() - mockAuthorize.mockResolvedValue({ - context: { + events.length = 0 + mockLoadContext.mockImplementation(async () => { + events.push('resolve') + return { workspaceId: 'ws-1', workspaceOrganizationId: null, allowPersonalApiKeys: true, billedAccountUserId: 'owner-1', - }, - attribution: { attributedUserId: 'user-1', actor: { kind: 'session', userId: 'user-1' } }, + } + }) + mockResolvePermission.mockImplementation(async () => { + events.push('authorize') + return 'write' }) + mockAssertItems.mockResolvedValue(undefined) + mockArchive.mockResolvedValue({ files: 0, folders: 1, fileIds: [], folderIds: ['folder-1'] }) }) it('creates a canonical path folder through the manager primitive', async () => { - mockCreate.mockResolvedValue({ folder, path: '/Reports' }) + mockCreate.mockImplementation(async () => { + events.push('execute') + return { folder, path: '/Reports' } + }) const result = await createWorkspaceFileFolderOperation.execute({ principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, input: { workspaceId: 'ws-1', path: '/Reports' }, }) expect(result.folder.path).toBe('/Reports') + expect(events).toEqual(['resolve', 'authorize', 'execute']) expect(mockCreate).toHaveBeenCalledWith({ workspaceId: 'ws-1', userId: 'user-1', @@ -94,4 +133,42 @@ describe('workspace file folder operations', () => { expect(mockAudit).toHaveBeenCalledOnce() expect(mockNotify).toHaveBeenCalledOnce() }) + + it('does not audit or notify when a folder archive updates no rows', async () => { + mockArchive.mockResolvedValue({ files: 0, folders: 0, fileIds: [], folderIds: [] }) + + await expect( + deleteWorkspaceFileFolderOperation.execute({ + principal: { kind: 'session', userId: 'user-1', sessionId: 'session-1' }, + input: { workspaceId: 'ws-1', folderId: 'folder-1' }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + + expect(mockAudit).not.toHaveBeenCalled() + expect(mockNotify).not.toHaveBeenCalled() + }) + + it('does not authorize a folder restore as though its ID were a delegated file scope', async () => { + const principal = { + kind: 'delegated' as const, + serviceId: 'copilot' as const, + subjectUserId: 'user-1', + workspaceId: 'ws-1', + delegationId: 'delegation-1', + audience: 'sim:workspace-files', + issuedAt: new Date('2026-01-01T00:00:00Z'), + expiresAt: new Date('2099-01-01T00:00:00Z'), + resourceScope: { fileId: 'folder-1' }, + } + await expect( + restoreWorkspaceFileFolderOperation.execute({ + principal, + input: { workspaceId: 'ws-1', folderId: 'folder-1' }, + }) + ).rejects.toThrow('Delegated workspace access is no longer valid') + + expect(mockLoadContext).toHaveBeenCalledWith('ws-1') + expect(mockResolvePermission).not.toHaveBeenCalled() + expect(mockRestore).not.toHaveBeenCalled() + }) }) diff --git a/apps/sim/lib/workspace-files/application/workspace-file-folders.ts b/apps/sim/lib/workspace-files/application/workspace-file-folders.ts index f0e95234245..c8fb8f9d2ff 100644 --- a/apps/sim/lib/workspace-files/application/workspace-file-folders.ts +++ b/apps/sim/lib/workspace-files/application/workspace-file-folders.ts @@ -1,14 +1,7 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' -import { - type Principal, - resolvePrincipalAttribution, - resolvePrincipalAuditAttribution, -} from '@sim/auth/principal' +import { AuditAction, AuditResourceType } from '@sim/audit' +import { resolvePrincipalAttribution } from '@sim/auth/principal' import { createLogger } from '@sim/logger' -import { - OrchestrationError, - type OrchestrationRequestContext, -} from '@/lib/core/orchestration/types' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' import { assertWorkspaceFileItemsBelongToWorkspace, @@ -17,14 +10,15 @@ import { createWorkspaceFileFolderAtPath, deleteWorkspaceFileFolderByPath, listWorkspaceFileFolders, + loadWorkspaceFileOperationContext, relocateWorkspaceFileFolderByPath, restoreWorkspaceFileFolder, updateWorkspaceFileFolder, type WorkspaceFileArchiveResult, type WorkspaceFileFolderRecord, } from '@/lib/uploads/contexts/workspace' +import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' import { fileOperations } from '@/lib/workspace-files/application/operations' -import { authorizeWorkspaceFileOperation } from '@/lib/workspace-files/application/workspace-operation-context' const logger = createLogger('WorkspaceFileFolders') @@ -88,17 +82,21 @@ export interface RestoreWorkspaceFileFolderResult { restoredItems: WorkspaceFileArchiveResult } +async function resolveFolderContext({ input }: { input: { workspaceId: string } }) { + const context = await loadWorkspaceFileOperationContext(input.workspaceId) + if (!context) throw new OrchestrationError('not_found', 'Workspace not found') + return context +} + +type FolderOperationContext = Awaited> + async function executeListWorkspaceFileFolders(args: { - principal: Principal input: ListWorkspaceFileFoldersInput - request?: OrchestrationRequestContext + context: FolderOperationContext }): Promise { - await authorizeWorkspaceFileOperation( - args.principal, - fileOperations.listFolders, - args.input.workspaceId - ) - let folders = await listWorkspaceFileFolders(args.input.workspaceId, { scope: args.input.scope }) + let folders = await listWorkspaceFileFolders(args.context.workspaceId, { + scope: args.input.scope, + }) if (args.input.parentPath !== undefined) { const parentPath = args.input.parentPath === '/' ? '' : args.input.parentPath.replace(/^\//, '') folders = folders.filter((folder) => { @@ -124,69 +122,43 @@ async function executeListWorkspaceFileFolders(args: { } async function executeCreateWorkspaceFileFolder(args: { - principal: Principal + principal: Parameters[0] input: CreateWorkspaceFileFolderInput - request?: OrchestrationRequestContext + context: FolderOperationContext }): Promise { - const { context } = await authorizeWorkspaceFileOperation( - args.principal, - fileOperations.createFolder, - args.input.workspaceId - ) const attribution = resolvePrincipalAttribution(args.principal, { - workspaceBillingOwnerUserId: context.billedAccountUserId, + workspaceBillingOwnerUserId: args.context.billedAccountUserId, }) - const auditAttribution = resolvePrincipalAuditAttribution(args.principal) const result = args.input.path !== undefined ? await createWorkspaceFileFolderAtPath({ - workspaceId: context.workspaceId, + workspaceId: args.context.workspaceId, userId: attribution.attributedUserId, path: args.input.path, }) : { folder: await createWorkspaceFileFolder({ - workspaceId: context.workspaceId, + workspaceId: args.context.workspaceId, userId: attribution.attributedUserId, name: args.input.name ?? '', parentId: args.input.parentId, }), } const folder = 'path' in result ? { ...result.folder, path: result.path } : result.folder - recordAudit({ - workspaceId: context.workspaceId, - actorId: auditAttribution.actorId, - actorName: auditAttribution.actorName, - action: AuditAction.FOLDER_CREATED, - resourceType: AuditResourceType.FOLDER, - resourceId: folder.id, - resourceName: folder.name, - description: `Created file folder "${folder.name}"`, - metadata: { operation: fileOperations.createFolder.id, actor: auditAttribution.actor }, - request: args.request, - }) - await notifyWorkspaceFilesChanged(context.workspaceId) return { folder } } async function executeUpdateWorkspaceFileFolder(args: { - principal: Principal input: UpdateWorkspaceFileFolderInput - request?: OrchestrationRequestContext + context: FolderOperationContext }): Promise { - const { context } = await authorizeWorkspaceFileOperation( - args.principal, - fileOperations.updateFolder, - args.input.workspaceId - ) - const auditAttribution = resolvePrincipalAuditAttribution(args.principal) let folder: WorkspaceFileFolderRecord if (args.input.path !== undefined || args.input.destinationPath !== undefined) { if (!args.input.path || !args.input.destinationPath) { throw new OrchestrationError('validation', 'path and destinationPath are required') } const result = await relocateWorkspaceFileFolderByPath({ - workspaceId: context.workspaceId, + workspaceId: args.context.workspaceId, path: args.input.path, destinationPath: args.input.destinationPath, }) @@ -194,136 +166,134 @@ async function executeUpdateWorkspaceFileFolder(args: { } else { if (!args.input.folderId) throw new OrchestrationError('validation', 'Folder ID is required') folder = await updateWorkspaceFileFolder({ - workspaceId: context.workspaceId, + workspaceId: args.context.workspaceId, folderId: args.input.folderId, name: args.input.name, parentId: args.input.parentId, sortOrder: args.input.sortOrder, }) } - recordAudit({ - workspaceId: context.workspaceId, - actorId: auditAttribution.actorId, - actorName: auditAttribution.actorName, - action: args.input.path !== undefined ? AuditAction.FOLDER_MOVED : AuditAction.FOLDER_UPDATED, - resourceType: AuditResourceType.FOLDER, - resourceId: folder.id, - resourceName: folder.name, - description: `Updated file folder "${folder.name}"`, - metadata: { operation: fileOperations.updateFolder.id, actor: auditAttribution.actor }, - request: args.request, - }) - await notifyWorkspaceFilesChanged(context.workspaceId) logger.info('Updated workspace file folder', { - workspaceId: context.workspaceId, + workspaceId: args.context.workspaceId, folderId: folder.id, }) return { folder } } async function executeDeleteWorkspaceFileFolder(args: { - principal: Principal input: DeleteWorkspaceFileFolderInput - request?: OrchestrationRequestContext + context: FolderOperationContext }): Promise { - const { context } = await authorizeWorkspaceFileOperation( - args.principal, - fileOperations.deleteFolder, - args.input.workspaceId - ) - const auditAttribution = resolvePrincipalAuditAttribution(args.principal) let deletedItems: WorkspaceFileArchiveResult if (args.input.path !== undefined) { deletedItems = await deleteWorkspaceFileFolderByPath({ - workspaceId: context.workspaceId, + workspaceId: args.context.workspaceId, path: args.input.path, recursive: args.input.recursive ?? false, }) } else { if (!args.input.folderId) throw new OrchestrationError('validation', 'Folder ID is required') await assertWorkspaceFileItemsBelongToWorkspace({ - workspaceId: context.workspaceId, + workspaceId: args.context.workspaceId, folderIds: [args.input.folderId], }) - deletedItems = await bulkArchiveWorkspaceFileItems({ - workspaceId: context.workspaceId, + const archived = await bulkArchiveWorkspaceFileItems({ + workspaceId: args.context.workspaceId, folderIds: [args.input.folderId], }) + deletedItems = { files: archived.fileIds.length, folders: archived.folderIds.length } + } + if (deletedItems.files === 0 && deletedItems.folders === 0) { + throw new OrchestrationError('not_found', 'Folder not found') } - recordAudit({ - workspaceId: context.workspaceId, - actorId: auditAttribution.actorId, - actorName: auditAttribution.actorName, - action: AuditAction.FOLDER_DELETED, - resourceType: AuditResourceType.FOLDER, - resourceId: args.input.folderId, - description: 'Deleted file folder', - metadata: { - operation: fileOperations.deleteFolder.id, - actor: auditAttribution.actor, - path: args.input.path, - deletedItems, - }, - request: args.request, - }) - await notifyWorkspaceFilesChanged(context.workspaceId) return { deletedItems, path: args.input.path } } async function executeRestoreWorkspaceFileFolder(args: { - principal: Principal input: RestoreWorkspaceFileFolderInput - request?: OrchestrationRequestContext + context: FolderOperationContext }): Promise { - const { context } = await authorizeWorkspaceFileOperation( - args.principal, - fileOperations.restoreFolder, - args.input.workspaceId, - args.input.folderId - ) - const auditAttribution = resolvePrincipalAuditAttribution(args.principal) - const result = await restoreWorkspaceFileFolder(context.workspaceId, args.input.folderId) - recordAudit({ - workspaceId: context.workspaceId, - actorId: auditAttribution.actorId, - actorName: auditAttribution.actorName, - action: AuditAction.FOLDER_RESTORED, - resourceType: AuditResourceType.FOLDER, - resourceId: args.input.folderId, - resourceName: result.folder.name, - description: `Restored file folder "${result.folder.name}"`, - metadata: { - operation: fileOperations.restoreFolder.id, - actor: auditAttribution.actor, - restoredItems: result.restoredItems, - }, - request: args.request, - }) - await notifyWorkspaceFilesChanged(context.workspaceId) - return result + return restoreWorkspaceFileFolder(args.context.workspaceId, args.input.folderId) } -export const listWorkspaceFileFoldersOperation = { +export const listWorkspaceFileFoldersOperation = defineAuthorizedWorkspaceFileUseCase({ operation: fileOperations.listFolders, + resolveContext: (args: { input: ListWorkspaceFileFoldersInput }) => resolveFolderContext(args), execute: executeListWorkspaceFileFolders, -} as const +}) -export const createWorkspaceFileFolderOperation = { +export const createWorkspaceFileFolderOperation = defineAuthorizedWorkspaceFileUseCase({ operation: fileOperations.createFolder, + resolveContext: (args: { input: CreateWorkspaceFileFolderInput }) => resolveFolderContext(args), execute: executeCreateWorkspaceFileFolder, -} as const + projectAudit({ result }) { + return { + action: AuditAction.FOLDER_CREATED, + resourceType: AuditResourceType.FOLDER, + resourceId: result.folder.id, + resourceName: result.folder.name, + description: `Created file folder "${result.folder.name}"`, + } + }, + async afterSuccess({ context }) { + await notifyWorkspaceFilesChanged(context.workspaceId) + }, +}) -export const updateWorkspaceFileFolderOperation = { +export const updateWorkspaceFileFolderOperation = defineAuthorizedWorkspaceFileUseCase({ operation: fileOperations.updateFolder, + resolveContext: (args: { input: UpdateWorkspaceFileFolderInput }) => resolveFolderContext(args), execute: executeUpdateWorkspaceFileFolder, -} as const + projectAudit({ input, result }) { + return { + action: input.path !== undefined ? AuditAction.FOLDER_MOVED : AuditAction.FOLDER_UPDATED, + resourceType: AuditResourceType.FOLDER, + resourceId: result.folder.id, + resourceName: result.folder.name, + description: `Updated file folder "${result.folder.name}"`, + } + }, + async afterSuccess({ context }) { + await notifyWorkspaceFilesChanged(context.workspaceId) + }, +}) -export const deleteWorkspaceFileFolderOperation = { +export const deleteWorkspaceFileFolderOperation = defineAuthorizedWorkspaceFileUseCase({ operation: fileOperations.deleteFolder, + resolveContext: (args: { input: DeleteWorkspaceFileFolderInput }) => resolveFolderContext(args), execute: executeDeleteWorkspaceFileFolder, -} as const + projectAudit({ input, result }) { + return { + action: AuditAction.FOLDER_DELETED, + resourceType: AuditResourceType.FOLDER, + resourceId: input.folderId, + description: 'Deleted file folder', + metadata: { + path: input.path, + deletedItems: result.deletedItems, + }, + } + }, + async afterSuccess({ context }) { + await notifyWorkspaceFilesChanged(context.workspaceId) + }, +}) -export const restoreWorkspaceFileFolderOperation = { +export const restoreWorkspaceFileFolderOperation = defineAuthorizedWorkspaceFileUseCase({ operation: fileOperations.restoreFolder, + resolveContext: (args: { input: RestoreWorkspaceFileFolderInput }) => resolveFolderContext(args), execute: executeRestoreWorkspaceFileFolder, -} as const + projectAudit({ input, result }) { + return { + action: AuditAction.FOLDER_RESTORED, + resourceType: AuditResourceType.FOLDER, + resourceId: input.folderId, + resourceName: result.folder.name, + description: `Restored file folder "${result.folder.name}"`, + metadata: { restoredItems: result.restoredItems }, + } + }, + async afterSuccess({ context }) { + await notifyWorkspaceFilesChanged(context.workspaceId) + }, +}) diff --git a/apps/sim/lib/workspace-files/application/write-workspace-file-by-path.ts b/apps/sim/lib/workspace-files/application/write-workspace-file-by-path.ts index 5d9c5bc9521..51601a3a33c 100644 --- a/apps/sim/lib/workspace-files/application/write-workspace-file-by-path.ts +++ b/apps/sim/lib/workspace-files/application/write-workspace-file-by-path.ts @@ -1,9 +1,10 @@ -import type { Principal } from '@sim/auth/principal' +import { type Principal, resolvePrincipalAttribution } from '@sim/auth/principal' import { OrchestrationError } from '@/lib/core/orchestration/types' import { ensureWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { loadActiveWorkspaceContext } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import type { WorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' import { + admitCreateWorkspaceFile, createWorkspaceFile, createWorkspaceFileFromBuffer, } from '@/lib/workspace-files/application/create-workspace-file' @@ -80,7 +81,7 @@ async function executeCreate({ input: WriteWorkspaceFileByPathInput }): Promise { const parsed = parseWorkspaceFileCreatePath(input.path) - await createWorkspaceFile.admit(principal, input.workspaceId) + await admitCreateWorkspaceFile(principal, input.workspaceId) const folderUserId = await resolveFolderAttributionUserId(principal, input.workspaceId) @@ -142,7 +143,7 @@ async function executeCreateBuffer({ input: WriteWorkspaceFileBufferByPathInput }): Promise { const parsed = parseWorkspaceFileCreatePath(input.path) - await createWorkspaceFile.admit(principal, input.workspaceId) + await admitCreateWorkspaceFile(principal, input.workspaceId) const folderUserId = await resolveFolderAttributionUserId(principal, input.workspaceId) const folderId = await ensureWorkspaceFileFolderPath({ workspaceId: input.workspaceId, @@ -196,11 +197,13 @@ async function resolveFolderAttributionUserId( principal: Principal, workspaceId: string ): Promise { - if (principal.kind === 'session' || principal.kind === 'personal_api_key') return principal.userId - if (principal.kind === 'delegated') return principal.subjectUserId - const workspace = await loadActiveWorkspaceContext(workspaceId) - if (!workspace) throw new OrchestrationError('not_found', 'Workspace not found') - return workspace.billedAccountUserId + let workspaceBillingOwnerUserId: string | undefined + if (principal.kind === 'workspace_api_key') { + const workspace = await loadActiveWorkspaceContext(workspaceId) + if (!workspace) throw new OrchestrationError('not_found', 'Workspace not found') + workspaceBillingOwnerUserId = workspace.billedAccountUserId + } + return resolvePrincipalAttribution(principal, { workspaceBillingOwnerUserId }).attributedUserId } export const createWorkspaceFileByPath = { From a9e8135b2b75de25458d0a146c53ef167fbda39b Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 8 Aug 2026 01:42:24 -0700 Subject: [PATCH 7/9] refactor(api): share workspace file name validation --- apps/sim/lib/api/contracts/primitives.test.ts | 19 ++++++++++++++++ apps/sim/lib/api/contracts/primitives.ts | 14 ++++++++++++ apps/sim/lib/api/contracts/v2/files.ts | 22 ++++--------------- apps/sim/lib/api/contracts/workspace-files.ts | 11 +--------- 4 files changed, 38 insertions(+), 28 deletions(-) diff --git a/apps/sim/lib/api/contracts/primitives.test.ts b/apps/sim/lib/api/contracts/primitives.test.ts index 0a5534d50e9..4e8a605a98f 100644 --- a/apps/sim/lib/api/contracts/primitives.test.ts +++ b/apps/sim/lib/api/contracts/primitives.test.ts @@ -12,9 +12,28 @@ import { resolvedSecretTraceProvenanceSchema, workflowIdSchema, workspaceFileIdSchema, + workspaceFileNameSchema, workspaceIdSchema, } from '@/lib/api/contracts/primitives' +describe('workspaceFileNameSchema', () => { + it('trims and accepts one bounded file name', () => { + expect(workspaceFileNameSchema.parse(' report.pdf ')).toBe('report.pdf') + expect(workspaceFileNameSchema.safeParse('a'.repeat(255)).success).toBe(true) + }) + + it.each([undefined, '', ' ', '.', '..', 'folder/report.pdf', 'folder\\report.pdf'])( + 'rejects invalid file name %j', + (name) => { + expect(workspaceFileNameSchema.safeParse(name).success).toBe(false) + } + ) + + it('rejects names longer than 255 characters', () => { + expect(workspaceFileNameSchema.safeParse('a'.repeat(256)).success).toBe(false) + }) +}) + describe('isCanonicalBase64', () => { it.each(['', 'TQ==', 'TWE=', 'TWFu', 'AAEC/w=='])('accepts canonical base64 %j', (value) => { expect(isCanonicalBase64(value)).toBe(true) diff --git a/apps/sim/lib/api/contracts/primitives.ts b/apps/sim/lib/api/contracts/primitives.ts index 97c7e814109..fa96521db31 100644 --- a/apps/sim/lib/api/contracts/primitives.ts +++ b/apps/sim/lib/api/contracts/primitives.ts @@ -199,6 +199,20 @@ export function requiredFieldSchema(message: string) { /** Non-empty `workspaceId` field with a stable, human-readable message. */ export const workspaceIdSchema = requiredFieldSchema('Workspace ID is required') +/** + * A single workspace-file name, not a path. Folder placement is carried by a + * separate folder id or path field, so separators and dot segments are invalid. + */ +export const workspaceFileNameSchema = z + .string({ error: 'Name is required' }) + .trim() + .min(1, 'Name is required') + .max(255, 'Name is too long') + .refine( + (name) => name !== '.' && name !== '..' && !name.includes('/') && !name.includes('\\'), + 'Name cannot contain path separators or dot segments' + ) + /** Non-empty `organizationId` field with a stable, human-readable message. */ export const organizationIdSchema = requiredFieldSchema('Organization ID is required') diff --git a/apps/sim/lib/api/contracts/v2/files.ts b/apps/sim/lib/api/contracts/v2/files.ts index 7c30dd44a57..607f582d695 100644 --- a/apps/sim/lib/api/contracts/v2/files.ts +++ b/apps/sim/lib/api/contracts/v2/files.ts @@ -2,6 +2,7 @@ import { z } from 'zod' import { isCanonicalBase64, workspaceFileIdSchema, + workspaceFileNameSchema, workspaceIdSchema, } from '@/lib/api/contracts/primitives' import { shareAuthTypeSchema, shareRecordSchema } from '@/lib/api/contracts/public-shares' @@ -66,7 +67,7 @@ export type V2FileUploadParams = z.output export const v2CreateFileUploadBodySchema = z .object({ workspaceId: workspaceIdSchema, - name: z.string().trim().min(1, 'name is required').max(255, 'name is too long'), + name: workspaceFileNameSchema, contentType: z.string().trim().min(1, 'contentType is required').max(255), size: z.number().int().nonnegative().max(MAX_WORKSPACE_FILE_SIZE), folderPath: v2FolderPathInputSchema.optional(), @@ -111,25 +112,10 @@ export const v2FileParamsSchema = z.object({ export type V2FileParams = z.output -/** - * A file-folder name becomes a path segment, so path separators and dot - * segments are rejected rather than normalized. Mirrors - * `normalizeWorkspaceFileItemName`, which enforces the same rule in the manager. - */ -const v2FileItemNameSchema = z - .string() - .trim() - .min(1, 'name is required') - .max(255, 'name is too long') - .refine( - (name) => name !== '.' && name !== '..' && !name.includes('/') && !name.includes('\\'), - 'name cannot contain path separators or dot segments' - ) - export const v2CreateFileBodySchema = z .object({ workspaceId: workspaceIdSchema, - name: v2FileItemNameSchema, + name: workspaceFileNameSchema, contentType: z .string() .trim() @@ -195,7 +181,7 @@ export type V2FileWorkspaceQuery = z.output export const v2RenameFileBodySchema = z .object({ workspaceId: workspaceIdSchema, - name: v2FileItemNameSchema, + name: workspaceFileNameSchema, }) .strict() diff --git a/apps/sim/lib/api/contracts/workspace-files.ts b/apps/sim/lib/api/contracts/workspace-files.ts index 8a7f4792d35..3540f4b4db0 100644 --- a/apps/sim/lib/api/contracts/workspace-files.ts +++ b/apps/sim/lib/api/contracts/workspace-files.ts @@ -3,6 +3,7 @@ import { folderIdSchema, inlineFileRefQuerySchema, isCanonicalBase64, + workspaceFileNameSchema, workspaceIdSchema, } from '@/lib/api/contracts/primitives' import { shareRecordSchema } from '@/lib/api/contracts/public-shares' @@ -43,16 +44,6 @@ export const getInlineWorkspaceFileContract = defineRouteContract({ }, }) -export const workspaceFileNameSchema = z - .string({ error: 'Name is required' }) - .trim() - .min(1, 'Name is required') - .max(255, 'Name is too long') - .refine( - (name) => name !== '.' && name !== '..' && !name.includes('/') && !name.includes('\\'), - 'Name cannot contain path separators or dot segments' - ) - export const renameWorkspaceFileBodySchema = z.object({ name: workspaceFileNameSchema, }) From 3700dfffbe30c814d3a9b89249d349f81b68ea81 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 8 Aug 2026 01:42:32 -0700 Subject: [PATCH 8/9] refactor(files): centralize copilot application calls --- .../application/execute-file-use-case.test.ts | 87 ++++++++++++ .../application/execute-file-use-case.ts | 45 +++++++ .../lib/copilot/auth/file-delegation.test.ts | 51 ++++--- apps/sim/lib/copilot/auth/file-delegation.ts | 53 +++++++- apps/sim/lib/copilot/chat/process-contents.ts | 14 +- .../sim/lib/copilot/chat/workspace-context.ts | 8 +- .../request/go/file-preview-adapter.ts | 8 +- .../lib/copilot/request/tools/files.test.ts | 4 +- apps/sim/lib/copilot/request/tools/files.ts | 7 +- .../handlers/deployment/custom-block.test.ts | 5 +- .../tools/handlers/deployment/custom-block.ts | 21 +-- .../tools/handlers/function-execute.ts | 4 +- .../tools/handlers/materialize-file.ts | 4 +- .../copilot/tools/handlers/resources.test.ts | 77 ++++++++--- .../lib/copilot/tools/handlers/resources.ts | 18 +-- .../copilot/tools/handlers/vfs-mutate.test.ts | 8 +- .../lib/copilot/tools/handlers/vfs-mutate.ts | 77 ++++++----- apps/sim/lib/copilot/tools/handlers/vfs.ts | 4 +- .../tools/handlers/workflow/queries.ts | 8 +- .../copilot/tools/server/files/create-file.ts | 45 +++---- .../files/download-to-workspace-file.ts | 11 +- .../tools/server/files/edit-content.ts | 15 ++- .../server/files/file-folder-application.ts | 21 +-- .../tools/server/files/file-folders.ts | 44 +++--- .../tools/server/files/file-preview.ts | 12 +- .../copilot/tools/server/files/rename-file.ts | 33 ++--- .../copilot/tools/server/files/share-file.ts | 34 ++--- .../tools/server/files/workspace-file.ts | 126 ++++++++++-------- .../tools/server/image/generate-image.ts | 37 ++--- .../tools/server/knowledge/knowledge-base.ts | 4 +- .../lib/copilot/tools/server/media/ffmpeg.ts | 36 ++--- .../tools/server/media/generate-audio.ts | 37 ++--- .../tools/server/media/generate-video.ts | 37 ++--- .../server/media/model-boundaries.test.ts | 2 +- .../tools/server/table/user-table.test.ts | 2 +- .../copilot/tools/server/table/user-table.ts | 8 +- apps/sim/lib/copilot/vfs/resource-writer.ts | 19 +++ 37 files changed, 648 insertions(+), 378 deletions(-) create mode 100644 apps/sim/lib/copilot/application/execute-file-use-case.test.ts create mode 100644 apps/sim/lib/copilot/application/execute-file-use-case.ts diff --git a/apps/sim/lib/copilot/application/execute-file-use-case.test.ts b/apps/sim/lib/copilot/application/execute-file-use-case.test.ts new file mode 100644 index 00000000000..7e8d87fead1 --- /dev/null +++ b/apps/sim/lib/copilot/application/execute-file-use-case.test.ts @@ -0,0 +1,87 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' + +const { resolveWorkspaceFileReference } = vi.hoisted(() => ({ + resolveWorkspaceFileReference: vi.fn(), +})) + +vi.mock('@/lib/workspace-files/application/resolve-workspace-file-reference', () => ({ + resolveWorkspaceFileReference, +})) + +import { + executeCopilotFileUseCase, + resolveCopilotWorkspaceFileReference, +} from '@/lib/copilot/application/execute-file-use-case' +import { fileOperations } from '@/lib/workspace-files/application/operations' + +const trustedContext = { + userId: 'user-1', + workspaceId: 'workspace-1', + chatId: 'chat-1', + executionId: 'execution-1', + toolCallId: 'tool-call-1', + copilotToolExecution: true, +} + +describe('executeCopilotFileUseCase', () => { + it('normalizes a file-scoped principal and calls the application use case', async () => { + const execute = vi.fn().mockResolvedValue({ fileId: 'file-1' }) + const useCase = { operation: fileOperations.rename, execute } + + await expect( + executeCopilotFileUseCase( + trustedContext, + useCase, + { fileId: 'file-1', name: 'renamed.txt' }, + { fileId: 'file-1' } + ) + ).resolves.toEqual({ fileId: 'file-1' }) + expect(execute).toHaveBeenCalledWith({ + principal: expect.objectContaining({ + kind: 'delegated', + serviceId: 'copilot', + subjectUserId: 'user-1', + workspaceId: 'workspace-1', + delegationId: 'copilot-tool:tool-call-1', + resourceScope: expect.objectContaining({ fileId: 'file-1' }), + }), + input: { fileId: 'file-1', name: 'renamed.txt' }, + }) + }) + + it('fails before application execution for an untrusted context', () => { + const execute = vi.fn() + const useCase = { operation: fileOperations.readMetadata, execute } + + expect(() => + executeCopilotFileUseCase({ ...trustedContext, copilotToolExecution: false }, useCase, { + fileId: 'file-1', + }) + ).toThrow('trusted Copilot execution context') + expect(execute).not.toHaveBeenCalled() + }) + + it('normalizes path reference resolution through the same boundary', async () => { + resolveWorkspaceFileReference.mockResolvedValue({ id: 'file-1' }) + + await expect( + resolveCopilotWorkspaceFileReference(trustedContext, fileOperations.readContent, { + workspaceId: 'workspace-1', + reference: 'files/report.txt', + }) + ).resolves.toEqual({ id: 'file-1' }) + expect(resolveWorkspaceFileReference).toHaveBeenCalledWith({ + principal: expect.objectContaining({ + kind: 'delegated', + workspaceId: 'workspace-1', + delegationId: 'copilot-tool:tool-call-1', + }), + operation: fileOperations.readContent, + workspaceId: 'workspace-1', + reference: 'files/report.txt', + }) + }) +}) diff --git a/apps/sim/lib/copilot/application/execute-file-use-case.ts b/apps/sim/lib/copilot/application/execute-file-use-case.ts new file mode 100644 index 00000000000..2532fe7c370 --- /dev/null +++ b/apps/sim/lib/copilot/application/execute-file-use-case.ts @@ -0,0 +1,45 @@ +import { + type CopilotFileDelegationContext, + resolveCopilotFilePrincipal, +} from '@/lib/copilot/auth/file-delegation' +import type { OperationUseCase } from '@/lib/core/application' +import { type FileOperation, fileOperations } from '@/lib/workspace-files/application/operations' +import { resolveWorkspaceFileReference } from '@/lib/workspace-files/application/resolve-workspace-file-reference' + +const registeredFileOperationIds = new Set( + Object.values(fileOperations).map((operation) => operation.id) +) + +interface ExecuteCopilotFileUseCaseOptions { + fileId?: string +} + +/** Normalizes trusted Copilot authentication before entering a file application use case. */ +export function executeCopilotFileUseCase( + context: CopilotFileDelegationContext | undefined, + useCase: OperationUseCase, + input: I, + options: ExecuteCopilotFileUseCaseOptions = {} +): Promise { + if (!registeredFileOperationIds.has(useCase.operation.id)) { + throw new Error(`Unregistered Copilot file operation: ${useCase.operation.id}`) + } + + return useCase.execute({ + principal: resolveCopilotFilePrincipal(context, options.fileId), + input, + }) +} + +/** Resolves a model-supplied VFS reference under a trusted Copilot delegation. */ +export function resolveCopilotWorkspaceFileReference( + context: CopilotFileDelegationContext | undefined, + operation: FileOperation, + input: { workspaceId: string; reference: string } +) { + return resolveWorkspaceFileReference({ + principal: resolveCopilotFilePrincipal(context), + operation, + ...input, + }) +} diff --git a/apps/sim/lib/copilot/auth/file-delegation.test.ts b/apps/sim/lib/copilot/auth/file-delegation.test.ts index 6ee85b7dba1..7ced2fecb1d 100644 --- a/apps/sim/lib/copilot/auth/file-delegation.test.ts +++ b/apps/sim/lib/copilot/auth/file-delegation.test.ts @@ -3,8 +3,10 @@ */ import { describe, expect, it } from 'vitest' import { - createCopilotFilePrincipal, + createCopilotChatFilePrincipal, + createCopilotWorkspaceContextFilePrincipal, messageForCopilotFileError, + resolveCopilotFilePrincipal, } from '@/lib/copilot/auth/file-delegation' import { OrchestrationError } from '@/lib/core/orchestration/types' @@ -19,7 +21,7 @@ const trustedContext = { describe('Copilot file delegation', () => { it('creates a short-lived principal scoped to the trusted workspace and file', () => { - const principal = createCopilotFilePrincipal(trustedContext, 'workspace-1', 'file-1') + const principal = resolveCopilotFilePrincipal(trustedContext, 'file-1') expect(principal).toMatchObject({ kind: 'delegated', @@ -38,7 +40,7 @@ describe('Copilot file delegation', () => { }) it('creates a workspace-scoped principal for file creation', () => { - const principal = createCopilotFilePrincipal(trustedContext, 'workspace-1') + const principal = resolveCopilotFilePrincipal(trustedContext) expect(principal.resourceScope).toEqual({ chatId: 'chat-1', @@ -48,25 +50,40 @@ describe('Copilot file delegation', () => { it('rejects contexts that were not issued by the Copilot execution pipeline', () => { expect(() => - createCopilotFilePrincipal( - { ...trustedContext, copilotToolExecution: false }, - 'workspace-1', - 'file-1' - ) + resolveCopilotFilePrincipal({ ...trustedContext, copilotToolExecution: false }, 'file-1') ).toThrow('trusted Copilot execution context') expect(() => - createCopilotFilePrincipal( - { ...trustedContext, toolCallId: undefined }, - 'workspace-1', - 'file-1' - ) + resolveCopilotFilePrincipal({ ...trustedContext, toolCallId: undefined }, 'file-1') ).toThrow('tool call ID') }) - it('rejects a workspace that differs from the server context', () => { - expect(() => createCopilotFilePrincipal(trustedContext, 'workspace-2', 'file-1')).toThrow( - 'workspace does not match' - ) + it('rejects a missing execution workspace', () => { + expect(() => + resolveCopilotFilePrincipal({ ...trustedContext, workspaceId: undefined }, 'file-1') + ).toThrow('workspace ID') + }) + + it('normalizes chat and workspace-index identities without caller-built delegation fields', () => { + expect( + createCopilotChatFilePrincipal({ + userId: 'user-1', + workspaceId: 'workspace-1', + chatId: 'chat-1', + }) + ).toMatchObject({ + delegationId: 'copilot-chat:chat-1', + resourceScope: { chatId: 'chat-1' }, + }) + expect( + createCopilotWorkspaceContextFilePrincipal({ + userId: 'user-1', + workspaceId: 'workspace-1', + executionId: 'execution-1', + }) + ).toMatchObject({ + delegationId: 'copilot-workspace-context:execution-1', + resourceScope: { executionId: 'execution-1' }, + }) }) it('projects only typed domain messages to Copilot', () => { diff --git a/apps/sim/lib/copilot/auth/file-delegation.ts b/apps/sim/lib/copilot/auth/file-delegation.ts index ff482c2c044..8f673083aae 100644 --- a/apps/sim/lib/copilot/auth/file-delegation.ts +++ b/apps/sim/lib/copilot/auth/file-delegation.ts @@ -11,25 +11,39 @@ export interface CopilotFileDelegationContext { copilotToolExecution?: boolean } -export function createCopilotFilePrincipal( - context: CopilotFileDelegationContext, - workspaceId: string, +export interface CopilotChatFileDelegationContext { + userId: string + workspaceId: string + chatId?: string +} + +export interface CopilotWorkspaceContextFileDelegationContext + extends CopilotChatFileDelegationContext { + executionId?: string +} + +/** Normalizes a trusted Copilot tool context into the shared file principal. */ +export function resolveCopilotFilePrincipal( + context: CopilotFileDelegationContext | undefined, fileId?: string ): DelegatedPrincipal { + if (!context) { + throw new Error('File delegation requires a Copilot execution context') + } if (!context.copilotToolExecution) { throw new Error('File delegation requires a trusted Copilot execution context') } if (!context.toolCallId) { throw new Error('File delegation requires a tool call ID') } - if (context.workspaceId !== workspaceId) { - throw new Error('File delegation workspace does not match the execution context') + if (!context.workspaceId) { + throw new Error('File delegation requires a workspace ID') } return createWorkspaceFileDelegatedPrincipal({ serviceId: 'copilot', subjectUserId: context.userId, - workspaceId, + workspaceId: context.workspaceId, delegationId: `copilot-tool:${context.toolCallId}`, fileId, chatId: context.chatId, @@ -37,6 +51,33 @@ export function createCopilotFilePrincipal( }) } +/** Creates the principal used while resolving user-supplied chat file context. */ +export function createCopilotChatFilePrincipal( + context: CopilotChatFileDelegationContext +): DelegatedPrincipal { + return createWorkspaceFileDelegatedPrincipal({ + serviceId: 'copilot', + subjectUserId: context.userId, + workspaceId: context.workspaceId, + delegationId: `copilot-chat:${context.chatId ?? context.workspaceId}`, + chatId: context.chatId, + }) +} + +/** Creates the principal used while materializing the Copilot workspace index. */ +export function createCopilotWorkspaceContextFilePrincipal( + context: CopilotWorkspaceContextFileDelegationContext +): DelegatedPrincipal { + return createWorkspaceFileDelegatedPrincipal({ + serviceId: 'copilot', + subjectUserId: context.userId, + workspaceId: context.workspaceId, + delegationId: `copilot-workspace-context:${context.chatId ?? context.executionId ?? context.workspaceId}`, + chatId: context.chatId, + executionId: context.executionId, + }) +} + export function messageForCopilotFileError( error: unknown, fallback = 'File operation failed' diff --git a/apps/sim/lib/copilot/chat/process-contents.ts b/apps/sim/lib/copilot/chat/process-contents.ts index a194b8e7d48..7dd0e3ac386 100644 --- a/apps/sim/lib/copilot/chat/process-contents.ts +++ b/apps/sim/lib/copilot/chat/process-contents.ts @@ -6,6 +6,7 @@ import { getActiveWorkflowRecord, } from '@sim/platform-authz/workflow' import { and, eq, isNull } from 'drizzle-orm' +import { createCopilotChatFilePrincipal } from '@/lib/copilot/auth/file-delegation' import { getBlockVisibilityForCopilot } from '@/lib/copilot/block-visibility' import { MAX_TABLE_SELECTION_CONTENT_LENGTH, @@ -39,7 +40,6 @@ import type { ColumnDefinition } from '@/lib/table/types' import { getWorkspaceFileFolderPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { getSkillById } from '@/lib/workflows/skills/operations' import { listFolders } from '@/lib/workflows/utils' -import { createWorkspaceFileDelegatedPrincipal } from '@/lib/workspace-files/application/delegated-principal' import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' import { checkKnowledgeBaseAccess } from '@/app/api/knowledge/utils' import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' @@ -887,11 +887,9 @@ async function resolveFileResource( userId: string, chatId?: string ): Promise { - const principal = createWorkspaceFileDelegatedPrincipal({ - serviceId: 'copilot', - subjectUserId: userId, + const principal = createCopilotChatFilePrincipal({ + userId, workspaceId, - delegationId: `copilot-chat:${chatId ?? workspaceId}`, chatId, }) const { file: record } = await readWorkspaceFileMetadata.execute({ @@ -938,11 +936,9 @@ async function resolveFileSelectionResource( chatId?: string ): Promise { if (!userId) throw new Error('File selection context requires a user ID') - const principal = createWorkspaceFileDelegatedPrincipal({ - serviceId: 'copilot', - subjectUserId: userId, + const principal = createCopilotChatFilePrincipal({ + userId, workspaceId, - delegationId: `copilot-chat:${chatId ?? workspaceId}`, chatId, }) const { file: record } = await readWorkspaceFileMetadata.execute({ diff --git a/apps/sim/lib/copilot/chat/workspace-context.ts b/apps/sim/lib/copilot/chat/workspace-context.ts index b0e4f649c48..3652b14547f 100644 --- a/apps/sim/lib/copilot/chat/workspace-context.ts +++ b/apps/sim/lib/copilot/chat/workspace-context.ts @@ -11,6 +11,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { and, eq, inArray, isNull } from 'drizzle-orm' import { hasWorkspaceSandboxAccess } from '@/lib/billing/core/subscription' +import { createCopilotWorkspaceContextFilePrincipal } from '@/lib/copilot/auth/file-delegation' import type { VfsSnapshotV1, VfsSnapshotV1Workflow } from '@/lib/copilot/generated/vfs-snapshot-v1' import { filterSecretNamesByMountPolicy, @@ -26,7 +27,6 @@ import { listWorkspaceSandboxes } from '@/lib/execution/remote-sandbox/workspace import { listCustomBlockSummariesForWorkspace } from '@/lib/workflows/custom-blocks/operations' import { listCustomTools } from '@/lib/workflows/custom-tools/operations' import { listSkillsForUser } from '@/lib/workflows/skills/operations' -import { createWorkspaceFileDelegatedPrincipal } from '@/lib/workspace-files/application/delegated-principal' import { listAllWorkspaceFiles } from '@/lib/workspace-files/application/list-workspace-files' import { assertActiveWorkspaceAccess, @@ -412,11 +412,9 @@ async function buildWorkspaceMdData( listAllWorkspaceFiles .execute({ - principal: createWorkspaceFileDelegatedPrincipal({ - serviceId: 'copilot', - subjectUserId: userId, + principal: createCopilotWorkspaceContextFilePrincipal({ + userId, workspaceId, - delegationId: `copilot-workspace-context:${options?.chatId ?? options?.executionId ?? workspaceId}`, chatId: options?.chatId, executionId: options?.executionId, }), diff --git a/apps/sim/lib/copilot/request/go/file-preview-adapter.ts b/apps/sim/lib/copilot/request/go/file-preview-adapter.ts index 8a9615ffe52..feaa0822416 100644 --- a/apps/sim/lib/copilot/request/go/file-preview-adapter.ts +++ b/apps/sim/lib/copilot/request/go/file-preview-adapter.ts @@ -1,6 +1,6 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { createCopilotFilePrincipal } from '@/lib/copilot/auth/file-delegation' +import { executeCopilotFileUseCase } from '@/lib/copilot/application/execute-file-use-case' import { MothershipStreamV1EventType } from '@/lib/copilot/generated/mothership-stream-v1' import { createFilePreviewSession, @@ -78,9 +78,9 @@ async function resolvePreviewTarget(args: { throw new Error('Workspace file preview requires a trusted Copilot execution context') } - const { files } = await listAllWorkspaceFiles.execute({ - principal: createCopilotFilePrincipal(args.context, args.workspaceId), - input: { workspaceId: args.workspaceId, scope: 'active' }, + const { files } = await executeCopilotFileUseCase(args.context, listAllWorkspaceFiles, { + workspaceId: args.workspaceId, + scope: 'active', }) const file = findWorkspaceFileRecord(files, args.target.path) if (!file) { diff --git a/apps/sim/lib/copilot/request/tools/files.test.ts b/apps/sim/lib/copilot/request/tools/files.test.ts index 16fbe8c6702..facdd2b6f41 100644 --- a/apps/sim/lib/copilot/request/tools/files.test.ts +++ b/apps/sim/lib/copilot/request/tools/files.test.ts @@ -8,7 +8,7 @@ const { mockWriteWorkspaceFileByPath } = vi.hoisted(() => ({ })) vi.mock('@/lib/copilot/vfs/resource-writer', () => ({ - writeWorkspaceFileByPath: mockWriteWorkspaceFileByPath, + writeCopilotWorkspaceFileByPath: mockWriteWorkspaceFileByPath, })) vi.mock('@/lib/copilot/request/otel', () => ({ @@ -197,7 +197,7 @@ describe('maybeWriteOutputToFile', () => { ) expect(result.success).toBe(true) - const persisted = mockWriteWorkspaceFileByPath.mock.calls[0][0].buffer.toString('utf8') + const persisted = mockWriteWorkspaceFileByPath.mock.calls[0][1].buffer.toString('utf8') expect(JSON.parse(persisted)).toEqual({ token: '{{OUTPUT_SECRET}}', publicLabel: 'true', diff --git a/apps/sim/lib/copilot/request/tools/files.ts b/apps/sim/lib/copilot/request/tools/files.ts index bda8e4672d7..d093342703d 100644 --- a/apps/sim/lib/copilot/request/tools/files.ts +++ b/apps/sim/lib/copilot/request/tools/files.ts @@ -1,6 +1,5 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { createCopilotFilePrincipal } from '@/lib/copilot/auth/file-delegation' import { FunctionExecute, UserTable } from '@/lib/copilot/generated/tool-catalog-v1' import { CopilotOutputFileOutcome } from '@/lib/copilot/generated/trace-attribute-values-v1' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' @@ -14,7 +13,7 @@ import { } from '@/lib/copilot/request/tools/resolved-secret-result' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' -import { writeWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer' +import { writeCopilotWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer' const logger = createLogger('CopilotToolResultFiles') @@ -272,7 +271,6 @@ export async function maybeWriteOutputToFile( }, async (span) => { try { - const principal = createCopilotFilePrincipal(context, context.workspaceId!) const writtenFiles = [] for (const outputFile of outputFiles) { const fileName = normalizeOutputWorkspaceFileName( @@ -287,9 +285,8 @@ export async function maybeWriteOutputToFile( throw new Error('Request aborted before tool mutation could be applied') } - const written = await writeWorkspaceFileByPath({ + const written = await writeCopilotWorkspaceFileByPath(context, { workspaceId: context.workspaceId!, - principal, target: { path: outputFile.path, mode: outputFile.mode ?? 'create', diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts index a367c9f3f29..916149e9b04 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.test.ts @@ -55,7 +55,10 @@ vi.mock('@/lib/workspace-files/application/resolve-workspace-file-reference', () resolveWorkspaceFileReference: resolveWorkspaceFileReferenceMock, })) vi.mock('@/lib/workspace-files/application/read-workspace-file-content', () => ({ - readWorkspaceFileContent: { execute: readWorkspaceFileContentMock }, + readWorkspaceFileContent: { + operation: { id: 'files.read_content' }, + execute: readWorkspaceFileContentMock, + }, })) vi.mock('@/lib/uploads/core/storage-service', () => ({ diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts index ede032ef3d2..ca51f6bafcd 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/custom-block.ts @@ -3,7 +3,10 @@ import { toError } from '@sim/utils/errors' import { generateShortId } from '@sim/utils/id' import { isAllowedCustomBlockIconUrl } from '@/lib/api/contracts/custom-blocks' import { isOrganizationOnEnterprisePlan } from '@/lib/billing' -import { createCopilotFilePrincipal } from '@/lib/copilot/auth/file-delegation' +import { + executeCopilotFileUseCase, + resolveCopilotWorkspaceFileReference, +} from '@/lib/copilot/application/execute-file-use-case' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { canonicalizeVfsPath } from '@/lib/copilot/vfs/path-utils' import { isFeatureEnabled } from '@/lib/core/config/feature-flags' @@ -19,7 +22,6 @@ import { } from '@/lib/workflows/custom-blocks/operations' 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 { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' import { ensureWorkflowAccess } from '../access' import type { DeployCustomBlockParams } from '../param-types' @@ -53,10 +55,7 @@ async function resolveIconUrl( } const canonical = canonicalizeVfsPath(value) - const principal = createCopilotFilePrincipal(context, workspaceId) - const record = await resolveWorkspaceFileReference({ - principal, - operation: fileOperations.readContent, + const record = await resolveCopilotWorkspaceFileReference(context, fileOperations.readContent, { workspaceId, reference: canonical, }).catch(() => { @@ -71,10 +70,12 @@ async function resolveIconUrl( throw new CustomBlockValidationError('Icon file must be 5MB or smaller') } - const { content: buffer } = await readWorkspaceFileContent.execute({ - principal, - input: { fileId: record.id, assertedWorkspaceId: workspaceId, maxBytes: MAX_ICON_BYTES }, - }) + const { content: buffer } = await executeCopilotFileUseCase( + context, + readWorkspaceFileContent, + { fileId: record.id, assertedWorkspaceId: workspaceId, maxBytes: MAX_ICON_BYTES }, + { fileId: record.id } + ) const safeFileName = record.name.replace(/[^a-zA-Z0-9.-]/g, '_') const uploaded = await uploadFile({ file: buffer, diff --git a/apps/sim/lib/copilot/tools/handlers/function-execute.ts b/apps/sim/lib/copilot/tools/handlers/function-execute.ts index 4a8c8bcd551..8b07b401248 100644 --- a/apps/sim/lib/copilot/tools/handlers/function-execute.ts +++ b/apps/sim/lib/copilot/tools/handlers/function-execute.ts @@ -2,7 +2,7 @@ import type { Principal } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { omit } from '@sim/utils/object' import { hasWorkspaceSandboxAccess } from '@/lib/billing/core/subscription' -import { createCopilotFilePrincipal } from '@/lib/copilot/auth/file-delegation' +import { resolveCopilotFilePrincipal } from '@/lib/copilot/auth/file-delegation' import { applySecretMountPolicy } from '@/lib/copilot/secret-mount-policy' import type { ToolExecutionContext, ToolExecutionResult } from '@/lib/copilot/tool-executor/types' import { @@ -676,7 +676,7 @@ export async function executeFunctionExecute( secretActorUserId ?? context.userId, mountedRegistry, inputFiles.length > 0 || inputDirectories.length > 0 - ? createCopilotFilePrincipal(context, context.workspaceId) + ? resolveCopilotFilePrincipal(context) : undefined ) if (resolved.length > 0) { diff --git a/apps/sim/lib/copilot/tools/handlers/materialize-file.ts b/apps/sim/lib/copilot/tools/handlers/materialize-file.ts index c188bc90c28..25dc2a51685 100644 --- a/apps/sim/lib/copilot/tools/handlers/materialize-file.ts +++ b/apps/sim/lib/copilot/tools/handlers/materialize-file.ts @@ -17,7 +17,7 @@ import { maybeNotifyStorageLimitForBillingContext, resolveStorageBillingContext, } from '@/lib/billing/storage' -import { createCopilotFilePrincipal } from '@/lib/copilot/auth/file-delegation' +import { resolveCopilotFilePrincipal } from '@/lib/copilot/auth/file-delegation' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' import { findMothershipUploadRowByChatAndName } from '@/lib/copilot/tools/handlers/upload-file-reader' @@ -553,7 +553,7 @@ export async function executeMaterializeFile( return { success: false, error: 'No workspace context available for materialize_file' } } - const principal = createCopilotFilePrincipal(context, context.workspaceId) + const principal = resolveCopilotFilePrincipal(context) const operation = (params.operation as string | undefined) || 'save' // save (promote upload → workspace file), import (JSON → workflow), and extract diff --git a/apps/sim/lib/copilot/tools/handlers/resources.test.ts b/apps/sim/lib/copilot/tools/handlers/resources.test.ts index 8e69e1dce8f..f470d47e6da 100644 --- a/apps/sim/lib/copilot/tools/handlers/resources.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/resources.test.ts @@ -4,14 +4,29 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -const { getWorkspaceFileMock, resolveWorkspaceFileReferenceMock } = vi.hoisted(() => ({ - getWorkspaceFileMock: vi.fn(), - resolveWorkspaceFileReferenceMock: vi.fn(), +const { listAllWorkspaceFilesMock, readWorkspaceFileMetadataMock } = vi.hoisted(() => ({ + listAllWorkspaceFilesMock: vi.fn(), + readWorkspaceFileMetadataMock: vi.fn(), })) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ - getWorkspaceFile: getWorkspaceFileMock, - resolveWorkspaceFileReference: resolveWorkspaceFileReferenceMock, + findWorkspaceFileRecord: ( + files: Array<{ id: string; name: string; folderPath: string | null }> + ) => files[0] ?? null, +})) + +vi.mock('@/lib/workspace-files/application/list-workspace-files', () => ({ + listAllWorkspaceFiles: { + operation: { id: 'files.list' }, + execute: listAllWorkspaceFilesMock, + }, +})) + +vi.mock('@/lib/workspace-files/application/read-workspace-file-metadata', () => ({ + readWorkspaceFileMetadata: { + operation: { id: 'files.read_metadata' }, + execute: readWorkspaceFileMetadataMock, + }, })) vi.mock('@/lib/workflows/utils', () => ({ @@ -38,20 +53,35 @@ describe('executeOpenResource', () => { }) it('opens workspace files with canonical non-UUID file ids', async () => { - getWorkspaceFileMock.mockResolvedValue({ - id: 'wf_qL_cfff-FskMsXtOdm599', - name: 'MAC_Brand_Guidelines_May_2021 (1).docx', - folderPath: null, + readWorkspaceFileMetadataMock.mockResolvedValue({ + file: { + id: 'wf_qL_cfff-FskMsXtOdm599', + name: 'MAC_Brand_Guidelines_May_2021 (1).docx', + folderPath: null, + }, }) const result = await executeOpenResource( { resources: [{ type: 'file', id: 'wf_qL_cfff-FskMsXtOdm599' }], }, - { userId: 'user-1', workflowId: 'workflow-1', workspaceId: 'workspace-1' } + { + userId: 'user-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + toolCallId: 'tool-1', + copilotToolExecution: true, + } ) - expect(getWorkspaceFileMock).toHaveBeenCalledWith('workspace-1', 'wf_qL_cfff-FskMsXtOdm599') + expect(readWorkspaceFileMetadataMock).toHaveBeenCalledWith( + expect.objectContaining({ + input: { + fileId: 'wf_qL_cfff-FskMsXtOdm599', + assertedWorkspaceId: 'workspace-1', + }, + }) + ) expect(result).toMatchObject({ success: true, output: { opened: 1, errors: [] }, @@ -67,22 +97,31 @@ describe('executeOpenResource', () => { }) it('opens workspace files by canonical VFS path', async () => { - resolveWorkspaceFileReferenceMock.mockResolvedValue({ - id: 'wf_qL_cfff-FskMsXtOdm599', - name: 'MAC_Brand_Guidelines_May_2021 (1).docx', - folderPath: 'Docs', + listAllWorkspaceFilesMock.mockResolvedValue({ + files: [ + { + id: 'wf_qL_cfff-FskMsXtOdm599', + name: 'MAC_Brand_Guidelines_May_2021 (1).docx', + folderPath: 'Docs', + }, + ], }) const result = await executeOpenResource( { resources: [{ type: 'file', path: 'files/Docs/MAC_Brand_Guidelines.docx' }], }, - { userId: 'user-1', workflowId: 'workflow-1', workspaceId: 'workspace-1' } + { + userId: 'user-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + toolCallId: 'tool-1', + copilotToolExecution: true, + } ) - expect(resolveWorkspaceFileReferenceMock).toHaveBeenCalledWith( - 'workspace-1', - 'files/Docs/MAC_Brand_Guidelines.docx' + expect(listAllWorkspaceFilesMock).toHaveBeenCalledWith( + expect.objectContaining({ input: { workspaceId: 'workspace-1', scope: 'active' } }) ) expect(result).toMatchObject({ success: true, diff --git a/apps/sim/lib/copilot/tools/handlers/resources.ts b/apps/sim/lib/copilot/tools/handlers/resources.ts index b87bd2621c4..6a5e5556d4b 100644 --- a/apps/sim/lib/copilot/tools/handlers/resources.ts +++ b/apps/sim/lib/copilot/tools/handlers/resources.ts @@ -1,4 +1,4 @@ -import { createCopilotFilePrincipal } from '@/lib/copilot/auth/file-delegation' +import { executeCopilotFileUseCase } from '@/lib/copilot/application/execute-file-use-case' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { type MothershipResource, MothershipResourceType } from '@/lib/copilot/resources/types' import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' @@ -30,17 +30,19 @@ async function resolveResource( const fileRef = item.path || item.id || '' let record: WorkspaceFileRecord | null if (item.path) { - const { files } = await listAllWorkspaceFiles.execute({ - principal: createCopilotFilePrincipal(context, context.workspaceId), - input: { workspaceId: context.workspaceId, scope: 'active' }, + const { files } = await executeCopilotFileUseCase(context, listAllWorkspaceFiles, { + workspaceId: context.workspaceId, + scope: 'active', }) record = findWorkspaceFileRecord(files, item.path) } else if (item.id) { record = ( - await readWorkspaceFileMetadata.execute({ - principal: createCopilotFilePrincipal(context, context.workspaceId, item.id), - input: { fileId: item.id, assertedWorkspaceId: context.workspaceId }, - }) + await executeCopilotFileUseCase( + context, + readWorkspaceFileMetadata, + { fileId: item.id, assertedWorkspaceId: context.workspaceId }, + { fileId: item.id } + ) ).file } else { record = null diff --git a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts index 114dc66f10c..9e3665e809a 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.test.ts @@ -60,7 +60,7 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-folder-manager', () => })) vi.mock('@/lib/copilot/tools/server/files/file-folder-application', () => ({ - copilotFilePrincipal: vi.fn((context, workspaceId, fileId) => ({ + resolveCopilotFilePrincipal: vi.fn((context, workspaceId, fileId) => ({ kind: 'delegated', serviceId: 'copilot', subjectUserId: context.userId, @@ -85,7 +85,13 @@ vi.mock('@/lib/workspace-files/application/move-workspace-file-items', () => ({ vi.mock('@/lib/workspace-files/application/operations', () => ({ fileOperations: { move: { id: 'files.move', minimumRole: 'write', workspaceApiKey: 'allow' }, + rename: { id: 'files.rename', minimumRole: 'write', workspaceApiKey: 'allow' }, delete: { id: 'files.delete', minimumRole: 'write', workspaceApiKey: 'allow' }, + updateFolder: { + id: 'files.folders.update', + minimumRole: 'write', + workspaceApiKey: 'allow', + }, }, })) diff --git a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts index 6c332d24ce0..d0a4779348c 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs-mutate.ts @@ -4,13 +4,13 @@ import { assertFolderMutable, assertWorkflowMutable } from '@sim/platform-authz/ import { toError } from '@sim/utils/errors' import { eq } from 'drizzle-orm' import { - createCopilotFilePrincipal, - messageForCopilotFileError, -} from '@/lib/copilot/auth/file-delegation' + executeCopilotFileUseCase, + resolveCopilotWorkspaceFileReference, +} from '@/lib/copilot/application/execute-file-use-case' +import { messageForCopilotFileError } from '@/lib/copilot/auth/file-delegation' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { ensureWorkflowAccess, ensureWorkspaceAccess } from '@/lib/copilot/tools/handlers/access' import { - copilotFilePrincipal, ensureCopilotFileFolderPath, requireCopilotWorkspace, } from '@/lib/copilot/tools/server/files/file-folder-application' @@ -41,7 +41,6 @@ import { deleteWorkspaceFileOperation } from '@/lib/workspace-files/application/ import { moveWorkspaceFileItemsOperation } from '@/lib/workspace-files/application/move-workspace-file-items' import { fileOperations } from '@/lib/workspace-files/application/operations' import { renameWorkspaceFile } from '@/lib/workspace-files/application/rename-workspace-file' -import { resolveWorkspaceFileReference } from '@/lib/workspace-files/application/resolve-workspace-file-reference' import { updateWorkspaceFileFolderOperation } from '@/lib/workspace-files/application/workspace-file-folders' import { checkKnowledgeBaseWriteAccess } from '@/app/api/knowledge/utils' @@ -357,9 +356,7 @@ async function resolveFileAtExactPath( context: ExecutionContext ): Promise { try { - return await resolveWorkspaceFileReference({ - principal: copilotFilePrincipal(context, workspaceId), - operation: fileOperations.move, + return await resolveCopilotWorkspaceFileReference(context, fileOperations.move, { workspaceId, reference: `files/${encodeVfsPathSegments(segments)}`, }) @@ -427,14 +424,16 @@ async function mutateWorkspaceFiles( const targetFolderId = await dest.ensureFolderId() if (targetFolderId === ref.file.folderId) { try { - const result = await renameWorkspaceFile.execute({ - principal: createCopilotFilePrincipal(context, workspaceId, ref.file.id), - input: { + const result = await executeCopilotFileUseCase( + context, + renameWorkspaceFile, + { fileId: ref.file.id, assertedWorkspaceId: workspaceId, name: targetName, }, - }) + { fileId: ref.file.id } + ) outcomes.push({ from: ref.source, to: `files/${encodeVfsPathSegments([...dest.folderSegments, result.file.name])}`, @@ -451,21 +450,24 @@ async function mutateWorkspaceFiles( continue } try { - const principal = copilotFilePrincipal(context, workspaceId, ref.file.id) - await moveWorkspaceFileItemsOperation.execute({ - principal, - input: { workspaceId, fileIds: [ref.file.id], targetFolderId }, - }) + await executeCopilotFileUseCase( + context, + moveWorkspaceFileItemsOperation, + { workspaceId, fileIds: [ref.file.id], targetFolderId }, + { fileId: ref.file.id } + ) let finalName = ref.file.name if (targetName !== ref.file.name) { - const renamed = await renameWorkspaceFile.execute({ - principal, - input: { + const renamed = await executeCopilotFileUseCase( + context, + renameWorkspaceFile, + { fileId: ref.file.id, assertedWorkspaceId: workspaceId, name: targetName, }, - }) + { fileId: ref.file.id } + ) finalName = renamed.file.name } outcomes.push({ @@ -495,14 +497,11 @@ async function mutateWorkspaceFiles( continue } try { - const result = await updateWorkspaceFileFolderOperation.execute({ - principal: copilotFilePrincipal(context, workspaceId), - input: { - workspaceId, - folderId: ref.folderId, - name: dest.dirMode ? undefined : dest.leafName, - parentId: targetFolderId, - }, + const result = await executeCopilotFileUseCase(context, updateWorkspaceFileFolderOperation, { + workspaceId, + folderId: ref.folderId, + name: dest.dirMode ? undefined : dest.leafName, + parentId: targetFolderId, }) outcomes.push({ from: ref.source, @@ -966,9 +965,7 @@ async function removeWorkspaceFilePath( ): Promise { let file: WorkspaceFileRecord | undefined try { - file = await resolveWorkspaceFileReference({ - principal: copilotFilePrincipal(context, workspaceId), - operation: fileOperations.delete, + file = await resolveCopilotWorkspaceFileReference(context, fileOperations.delete, { workspaceId, reference: path, }) @@ -977,10 +974,12 @@ async function removeWorkspaceFilePath( if (classified?.code !== 'not_found') throw error } if (file) { - await deleteWorkspaceFileOperation.execute({ - principal: copilotFilePrincipal(context, workspaceId, file.id), - input: { fileId: file.id, assertedWorkspaceId: workspaceId }, - }) + await executeCopilotFileUseCase( + context, + deleteWorkspaceFileOperation, + { fileId: file.id, assertedWorkspaceId: workspaceId }, + { fileId: file.id } + ) logger.info('Deleted workspace file via rm', { fileId: file.id, workspaceId }) return { from: path, kind: 'file', id: file.id } } @@ -993,9 +992,9 @@ async function removeWorkspaceFilePath( if (!folderId) return { from: path, kind: 'file', error: `Not found: ${path}` } try { - const result = await archiveWorkspaceFileItemsOperation.execute({ - principal: copilotFilePrincipal(context, workspaceId), - input: { workspaceId, folderIds: [folderId] }, + const result = await executeCopilotFileUseCase(context, archiveWorkspaceFileItemsOperation, { + workspaceId, + folderIds: [folderId], }) logger.info('Deleted file folder via rm', { folderId, workspaceId }) return { from: path, kind: 'file_folder', id: folderId } diff --git a/apps/sim/lib/copilot/tools/handlers/vfs.ts b/apps/sim/lib/copilot/tools/handlers/vfs.ts index 4fff1d8d4e7..3aade59a519 100644 --- a/apps/sim/lib/copilot/tools/handlers/vfs.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs.ts @@ -1,6 +1,6 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' -import { createCopilotFilePrincipal } from '@/lib/copilot/auth/file-delegation' +import { resolveCopilotFilePrincipal } from '@/lib/copilot/auth/file-delegation' import { getBlockVisibilityForCopilot } from '@/lib/copilot/block-visibility' import { TOOL_RESULT_MAX_INLINE_CHARS } from '@/lib/copilot/constants' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' @@ -33,7 +33,7 @@ async function getGatedVFS(context: ExecutionContext) { const vis = await getBlockVisibilityForCopilot(context.userId, workspaceId) const filePrincipal = context.copilotToolExecution && context.toolCallId - ? createCopilotFilePrincipal(context, workspaceId) + ? resolveCopilotFilePrincipal(context) : undefined return withBlockVisibility(vis, () => getOrMaterializeVFS(workspaceId, context.userId, { diff --git a/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts b/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts index d8e4a6d7b04..fb250666e14 100644 --- a/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts @@ -1,6 +1,6 @@ import { toError } from '@sim/utils/errors' import { mergeSubblockStateWithValues } from '@sim/workflow-persistence/subblocks' -import { createCopilotFilePrincipal } from '@/lib/copilot/auth/file-delegation' +import { executeCopilotFileUseCase } from '@/lib/copilot/application/execute-file-use-case' import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { formatNormalizedWorkflowForCopilot } from '@/lib/copilot/tools/shared/workflow-utils' import { mcpService } from '@/lib/mcp/service' @@ -192,9 +192,9 @@ export async function executeGetWorkflowData( if (!workspaceId) { return { success: false, error: 'workspaceId is required' } } - const { files } = await listAllWorkspaceFiles.execute({ - principal: createCopilotFilePrincipal(context, workspaceId), - input: { workspaceId, scope: 'active' }, + const { files } = await executeCopilotFileUseCase(context, listAllWorkspaceFiles, { + workspaceId, + scope: 'active', }) const fileResults = files.map((file) => ({ id: String(file.id || ''), diff --git a/apps/sim/lib/copilot/tools/server/files/create-file.ts b/apps/sim/lib/copilot/tools/server/files/create-file.ts index 68ff4023970..430545f8b0a 100644 --- a/apps/sim/lib/copilot/tools/server/files/create-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/create-file.ts @@ -1,8 +1,6 @@ import { createLogger } from '@sim/logger' -import { - createCopilotFilePrincipal, - messageForCopilotFileError, -} from '@/lib/copilot/auth/file-delegation' +import { executeCopilotFileUseCase } from '@/lib/copilot/application/execute-file-use-case' +import { messageForCopilotFileError } from '@/lib/copilot/auth/file-delegation' import { assertServerToolNotAborted, type BaseServerTool, @@ -56,34 +54,27 @@ export const createFileServerTool: BaseServerTool> = + await executeCopilotFileUseCase(context, createWorkspaceFileFolderOperation, { + workspaceId, + name, + parentId, + }) parentId = result.folder.id } return parentId diff --git a/apps/sim/lib/copilot/tools/server/files/file-folders.ts b/apps/sim/lib/copilot/tools/server/files/file-folders.ts index 1fe5fbb5569..c9357a6d796 100644 --- a/apps/sim/lib/copilot/tools/server/files/file-folders.ts +++ b/apps/sim/lib/copilot/tools/server/files/file-folders.ts @@ -1,4 +1,8 @@ import { createLogger } from '@sim/logger' +import { + executeCopilotFileUseCase, + resolveCopilotWorkspaceFileReference, +} from '@/lib/copilot/application/execute-file-use-case' import { messageForCopilotFileError } from '@/lib/copilot/auth/file-delegation' import { assertServerToolNotAborted, @@ -6,7 +10,6 @@ import { type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' import { - copilotFilePrincipal, ensureCopilotFileFolderPath, requireCopilotWorkspace, } from '@/lib/copilot/tools/server/files/file-folder-application' @@ -19,7 +22,6 @@ import { } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { moveWorkspaceFileItemsOperation } from '@/lib/workspace-files/application/move-workspace-file-items' import { fileOperations } from '@/lib/workspace-files/application/operations' -import { resolveWorkspaceFileReference } from '@/lib/workspace-files/application/resolve-workspace-file-reference' import { createWorkspaceFileFolderOperation, listWorkspaceFileFoldersOperation, @@ -147,12 +149,9 @@ async function resolveFileIdsFromPaths( }> { const fileIds: string[] = [] const failed: string[] = [] - const principal = copilotFilePrincipal(context, workspaceId) for (const path of paths) { try { - const file = await resolveWorkspaceFileReference({ - principal, - operation: fileOperations.move, + const file = await resolveCopilotWorkspaceFileReference(context, fileOperations.move, { workspaceId, reference: path, }) @@ -196,9 +195,8 @@ export const listFileFoldersServerTool: BaseServerTool null assertServerToolNotAborted(context) - const result = await moveWorkspaceFileItemsOperation.execute({ - principal: copilotFilePrincipal(context, workspaceId), - input: { workspaceId, fileIds, targetFolderId: folderId }, + const result = await executeCopilotFileUseCase(context, moveWorkspaceFileItemsOperation, { + workspaceId, + fileIds, + targetFolderId: folderId, }) logger.info('Files moved via move_file', { diff --git a/apps/sim/lib/copilot/tools/server/files/file-preview.ts b/apps/sim/lib/copilot/tools/server/files/file-preview.ts index 64ae2f03079..d99f219021b 100644 --- a/apps/sim/lib/copilot/tools/server/files/file-preview.ts +++ b/apps/sim/lib/copilot/tools/server/files/file-preview.ts @@ -1,6 +1,6 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' -import { createCopilotFilePrincipal } from '@/lib/copilot/auth/file-delegation' +import { executeCopilotFileUseCase } from '@/lib/copilot/application/execute-file-use-case' import type { ExecutionContext } from '@/lib/copilot/request/types' import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' @@ -152,14 +152,16 @@ export async function loadWorkspaceFileTextForPreview( fileId: string ): Promise { try { - const { content: buffer } = await readWorkspaceFileContent.execute({ - principal: createCopilotFilePrincipal(context, workspaceId, fileId), - input: { + const { content: buffer } = await executeCopilotFileUseCase( + context, + readWorkspaceFileContent, + { fileId, assertedWorkspaceId: workspaceId, maxBytes: MAX_PREVIEW_SOURCE_BYTES, }, - }) + { fileId } + ) return { text: buffer.toString('utf-8'), } diff --git a/apps/sim/lib/copilot/tools/server/files/rename-file.ts b/apps/sim/lib/copilot/tools/server/files/rename-file.ts index b141aec6bf0..807e42e1a39 100644 --- a/apps/sim/lib/copilot/tools/server/files/rename-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/rename-file.ts @@ -1,8 +1,9 @@ import { createLogger } from '@sim/logger' import { - createCopilotFilePrincipal, - messageForCopilotFileError, -} from '@/lib/copilot/auth/file-delegation' + executeCopilotFileUseCase, + resolveCopilotWorkspaceFileReference, +} from '@/lib/copilot/application/execute-file-use-case' +import { messageForCopilotFileError } from '@/lib/copilot/auth/file-delegation' import { assertServerToolNotAborted, type BaseServerTool, @@ -12,7 +13,6 @@ import { asOrchestrationError } from '@/lib/core/orchestration/types' import { fileOperations } from '@/lib/workspace-files/application/operations' import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' import { renameWorkspaceFile } from '@/lib/workspace-files/application/rename-workspace-file' -import { resolveWorkspaceFileReference } from '@/lib/workspace-files/application/resolve-workspace-file-reference' import { validateFlatWorkspaceFileName } from './workspace-file' const logger = createLogger('RenameFileServerTool') @@ -59,21 +59,20 @@ export const renameFileServerTool: BaseServerTool const targetRef = path || legacyFileId if (!targetRef) return { success: false, message: 'path is required' } - const principal = createCopilotFilePrincipal(context, workspaceId) let existingFile try { existingFile = path - ? await resolveWorkspaceFileReference({ - principal, - operation: fileOperations.updateShare, + ? await resolveCopilotWorkspaceFileReference(context, fileOperations.updateShare, { workspaceId, reference: path, }) : ( - await readWorkspaceFileMetadata.execute({ - principal, - input: { fileId: legacyFileId, assertedWorkspaceId: workspaceId }, - }) + await executeCopilotFileUseCase( + context, + readWorkspaceFileMetadata, + { fileId: legacyFileId, assertedWorkspaceId: workspaceId }, + { fileId: legacyFileId } + ) ).file } catch (error) { const classified = asOrchestrationError(error) @@ -92,12 +91,12 @@ export const shareFileServerTool: BaseServerTool return { success: false, message: `File not found: ${targetRef}` } } assertServerToolNotAborted(context) - const principalWithFile = createCopilotFilePrincipal(context, workspaceId, existingFile.id) const isActive = action !== 'unshare' try { - const result = await updateWorkspaceFileShare.execute({ - principal: principalWithFile, - input: { + const result = await executeCopilotFileUseCase( + context, + updateWorkspaceFileShare, + { fileId: existingFile.id, assertedWorkspaceId: workspaceId, isActive, @@ -106,7 +105,8 @@ export const shareFileServerTool: BaseServerTool allowedEmails, noOpIfInactive: !isActive, }, - }) + { fileId: existingFile.id } + ) const share = result.share logger.info(`${isActive ? 'Enabled' : 'Disabled'} share for file via share_file`, { fileId: existingFile.id, diff --git a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts index 97e46aab43d..9f9f32a5a1f 100644 --- a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts @@ -3,8 +3,12 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import { truncate } from '@sim/utils/string' import { - createCopilotFilePrincipal, + executeCopilotFileUseCase, + resolveCopilotWorkspaceFileReference, +} from '@/lib/copilot/application/execute-file-use-case' +import { messageForCopilotFileError, + resolveCopilotFilePrincipal, } from '@/lib/copilot/auth/file-delegation' import { WorkspaceFile } from '@/lib/copilot/generated/tool-catalog-v1' import { @@ -25,7 +29,6 @@ import { fileOperations } from '@/lib/workspace-files/application/operations' import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' import { renameWorkspaceFile } from '@/lib/workspace-files/application/rename-workspace-file' -import { resolveWorkspaceFileReference } from '@/lib/workspace-files/application/resolve-workspace-file-reference' import type { SandboxTaskId } from '@/sandbox-tasks/registry' import { compileDoc, @@ -287,7 +290,7 @@ export const workspaceFileServerTool: BaseServerTool> try { - result = await createWorkspaceFile.execute({ - principal, - input: { - workspaceId, - name: fileName, - contentType, - content, - encoding: 'utf-8', - folderId, - exactName: false, - }, + result = await executeCopilotFileUseCase(context, createWorkspaceFile, { + workspaceId, + name: fileName, + contentType, + content, + encoding: 'utf-8', + folderId, + exactName: false, }) } catch (error) { return { @@ -439,13 +445,15 @@ export const workspaceFileServerTool: BaseServerTool = [] const failedFiles: string[] = [] - const filePrincipal = createCopilotFilePrincipal(context, kbWorkspaceId) + const filePrincipal = resolveCopilotFilePrincipal(context) for (const fileRef of fileRefs) { let fileRecord diff --git a/apps/sim/lib/copilot/tools/server/media/ffmpeg.ts b/apps/sim/lib/copilot/tools/server/media/ffmpeg.ts index a395a96b5a4..61f12590d97 100644 --- a/apps/sim/lib/copilot/tools/server/media/ffmpeg.ts +++ b/apps/sim/lib/copilot/tools/server/media/ffmpeg.ts @@ -1,18 +1,20 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { createCopilotFilePrincipal } from '@/lib/copilot/auth/file-delegation' +import { + executeCopilotFileUseCase, + resolveCopilotWorkspaceFileReference, +} from '@/lib/copilot/application/execute-file-use-case' import { Ffmpeg } from '@/lib/copilot/generated/tool-catalog-v1' import { assertServerToolNotAborted, type BaseServerTool, type ServerToolContext, } from '@/lib/copilot/tools/server/base-tool' -import { writeWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer' +import { writeCopilotWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer' import { MAX_MEDIA_BYTES } from '@/lib/media/falai' import { type FfmpegOperation, type MediaFile, runFfmpegOperation } from '@/lib/media/ffmpeg' 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' const logger = createLogger('FfmpegTool') @@ -84,22 +86,25 @@ export const ffmpegServerTool: BaseServerTool = { try { const mediaFiles: MediaFile[] = [] let totalInputBytes = 0 - const principal = createCopilotFilePrincipal(context, workspaceId) for (const filePath of inputPaths) { - const fileRecord = await resolveWorkspaceFileReference({ - principal, - operation: fileOperations.readContent, - workspaceId, - reference: filePath, - }) - const { content: buffer } = await readWorkspaceFileContent.execute({ - principal, - input: { + const fileRecord = await resolveCopilotWorkspaceFileReference( + context, + fileOperations.readContent, + { + workspaceId, + reference: filePath, + } + ) + const { content: buffer } = await executeCopilotFileUseCase( + context, + readWorkspaceFileContent, + { fileId: fileRecord.id, assertedWorkspaceId: workspaceId, maxBytes: MAX_MEDIA_BYTES, }, - }) + { fileId: fileRecord.id } + ) totalInputBytes += buffer.length if (totalInputBytes > MAX_MEDIA_BYTES) { throw new Error(`Input files exceed the ${MAX_MEDIA_BYTES} byte limit`) @@ -144,9 +149,8 @@ export const ffmpegServerTool: BaseServerTool = { const mode = outputFile?.mode ?? 'create' assertServerToolNotAborted(context) - const written = await writeWorkspaceFileByPath({ + const written = await writeCopilotWorkspaceFileByPath(context, { workspaceId, - principal, target: { path: outputPath, mode, mimeType: outputFile?.mimeType }, buffer: result.buffer, inferredMimeType: result.contentType || 'application/octet-stream', diff --git a/apps/sim/lib/copilot/tools/server/media/generate-audio.ts b/apps/sim/lib/copilot/tools/server/media/generate-audio.ts index 4d4eadbc458..b0faecef2dd 100644 --- a/apps/sim/lib/copilot/tools/server/media/generate-audio.ts +++ b/apps/sim/lib/copilot/tools/server/media/generate-audio.ts @@ -1,6 +1,9 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' -import { createCopilotFilePrincipal } from '@/lib/copilot/auth/file-delegation' +import { + executeCopilotFileUseCase, + resolveCopilotWorkspaceFileReference, +} from '@/lib/copilot/application/execute-file-use-case' import { GenerateAudio } from '@/lib/copilot/generated/tool-catalog-v1' import { assertServerToolNotAborted, @@ -11,12 +14,11 @@ import { assertOpaqueWorkspaceFileModelSafe, projectServerToolModelInput, } from '@/lib/copilot/tools/server/model-input' -import { writeWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer' +import { writeCopilotWorkspaceFileByPath } from '@/lib/copilot/vfs/resource-writer' import { MAX_MEDIA_BYTES } from '@/lib/media/falai' import { type AudioType, generateFalAudio } from '@/lib/media/falai-audio' 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' const logger = createLogger('GenerateAudioTool') @@ -95,18 +97,21 @@ export const generateAudioServerTool: BaseServerTool ({ })) vi.mock('@/lib/core/config/api-keys', () => ({ getRotatingApiKey: vi.fn(() => 'api-key') })) vi.mock('@/lib/copilot/vfs/resource-writer', () => ({ - writeWorkspaceFileByPath: mockWriteWorkspaceFileByPath, + writeCopilotWorkspaceFileByPath: mockWriteWorkspaceFileByPath, })) vi.mock('@/lib/media/falai-audio', () => ({ generateFalAudio: mockGenerateFalAudio })) vi.mock('@/lib/media/falai-video', () => ({ generateFalVideo: mockGenerateFalVideo })) 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 6b5c6c9089d..6395f4ddf44 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 @@ -80,7 +80,7 @@ vi.mock('@/lib/workspace-files/application/read-workspace-file-content', () => ( }, })) vi.mock('@/lib/copilot/auth/file-delegation', () => ({ - createCopilotFilePrincipal: vi.fn(() => ({ + resolveCopilotFilePrincipal: vi.fn(() => ({ kind: 'delegated', serviceId: 'copilot', subjectUserId: 'user-1', 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 b9c5ba4830b..0a119295f69 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -2,7 +2,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' -import { createCopilotFilePrincipal } from '@/lib/copilot/auth/file-delegation' +import { resolveCopilotFilePrincipal } from '@/lib/copilot/auth/file-delegation' import { UserTable } from '@/lib/copilot/generated/tool-catalog-v1' import { assertServerToolNotAborted, @@ -125,7 +125,7 @@ const MAX_INLINE_FILE_BYTES = 50 * 1024 * 1024 async function resolveWorkspaceFileRecordOrThrow( fileReference: string, workspaceId: string, - principal: ReturnType + principal: ReturnType ) { let record try { @@ -1272,7 +1272,7 @@ export const userTableServerTool: BaseServerTool return { success: false, message: 'Workspace ID is required' } } - const filePrincipal = createCopilotFilePrincipal(context, workspaceId) + const filePrincipal = resolveCopilotFilePrincipal(context) const record = await resolveWorkspaceFileRecordOrThrow( fileReference, workspaceId, @@ -1474,7 +1474,7 @@ export const userTableServerTool: BaseServerTool return { success: false, message: `Table is archived: ${tableId}` } } - const filePrincipal = createCopilotFilePrincipal(context, workspaceId) + const filePrincipal = resolveCopilotFilePrincipal(context) const record = await resolveWorkspaceFileRecordOrThrow( fileReference, workspaceId, diff --git a/apps/sim/lib/copilot/vfs/resource-writer.ts b/apps/sim/lib/copilot/vfs/resource-writer.ts index 04a3069d35c..1d9108a7d3b 100644 --- a/apps/sim/lib/copilot/vfs/resource-writer.ts +++ b/apps/sim/lib/copilot/vfs/resource-writer.ts @@ -1,4 +1,8 @@ import type { Principal } from '@sim/auth/principal' +import { + type CopilotFileDelegationContext, + resolveCopilotFilePrincipal, +} from '@/lib/copilot/auth/file-delegation' import { canonicalWorkspaceFilePath } from '@/lib/copilot/vfs/path-utils' import { findWorkspaceFileFolderIdByPath } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { @@ -178,3 +182,18 @@ export async function writeWorkspaceFileByPath(args: { mode: 'create', } } + +type CopilotWorkspaceFileWriteArgs = Omit< + Parameters[0], + 'principal' +> + +export function writeCopilotWorkspaceFileByPath( + context: CopilotFileDelegationContext | undefined, + args: CopilotWorkspaceFileWriteArgs +) { + return writeWorkspaceFileByPath({ + ...args, + principal: resolveCopilotFilePrincipal(context), + }) +} From 0ab6d68a54dd916d25602d535a059c2475157cf9 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 8 Aug 2026 02:04:45 -0700 Subject: [PATCH 9/9] docs(skills): generalize application operation migration --- .../migrate-application-operation/SKILL.md | 291 ++++++++++++++++++ .../agents/openai.yaml | 4 + .../skills/migrate-file-operation/SKILL.md | 231 -------------- .../migrate-file-operation/agents/openai.yaml | 4 - .../commands/migrate-application-operation.md | 290 +++++++++++++++++ .claude/commands/migrate-file-operation.md | 230 -------------- .../commands/migrate-application-operation.md | 286 +++++++++++++++++ .cursor/commands/migrate-file-operation.md | 226 -------------- 8 files changed, 871 insertions(+), 691 deletions(-) create mode 100644 .agents/skills/migrate-application-operation/SKILL.md create mode 100644 .agents/skills/migrate-application-operation/agents/openai.yaml delete mode 100644 .agents/skills/migrate-file-operation/SKILL.md delete mode 100644 .agents/skills/migrate-file-operation/agents/openai.yaml create mode 100644 .claude/commands/migrate-application-operation.md delete mode 100644 .claude/commands/migrate-file-operation.md create mode 100644 .cursor/commands/migrate-application-operation.md delete mode 100644 .cursor/commands/migrate-file-operation.md diff --git a/.agents/skills/migrate-application-operation/SKILL.md b/.agents/skills/migrate-application-operation/SKILL.md new file mode 100644 index 00000000000..fa7923be6a7 --- /dev/null +++ b/.agents/skills/migrate-application-operation/SKILL.md @@ -0,0 +1,291 @@ +--- +name: migrate-application-operation +description: Migrate one existing Sim resource operation into the shared Principal and application-use-case architecture across internal APIs, public or versioned APIs, Copilot, and other trusted tool adapters. Use when removing route- or tool-local authorization and business logic, consolidating resource reads or writes behind semantic operation policies, or adding another surface to an existing application operation while preserving contracts, identity, errors, rate limits, audit, analytics, and compatibility behavior. Treat v1, uploads, streams, large bodies, bulk recursion, and polymorphic tools as explicitly scoped special cases. +--- + +# Migrate Application Operation + +Migrate one bounded semantic operation at a time. Share authorization and business behavior without forcing internal APIs, public APIs, Copilot, and other tools to share authentication, input schemas, or response shapes. + +## Enforce the application boundary + +Apply this invariant: + +> Every real operation on persisted or protected data enters through an authorized application use case. + +This includes mutations, content and metadata reads, canonical resource lookup, and reference-to-resource resolution when the lookup is authorization-sensitive. + +Surface helpers may: + +- Normalize an already-authenticated surface context into a `Principal`. +- Translate aliases or wire arguments into application input. +- Select a code-defined operation and application use case. +- Call the application use case. +- Translate typed results and errors into the surface contract. + +Surface helpers must not: + +- Query databases or storage. +- Decide workspace or resource authorization. +- Implement business transactions. +- Record semantic audit or shared domain notifications. +- Infer authoritative identity, workspace, audience, or scope from untrusted arguments. +- Substitute billing attribution for identity. + +A helper that resolves a path is valid only when the actual protected lookup runs through an authorized application resolver. If a helper begins doing real data work, move that work into an application use case. + +## Read the foundation first + +Read these files completely before editing: + +- `packages/auth/src/principal.ts` +- `apps/sim/lib/core/application/operation.ts` +- `apps/sim/lib/core/application/workspace-operation.ts` +- `apps/sim/lib/core/application/workspace-authorization.ts` +- `apps/sim/lib/core/application/authorized-workspace-use-case.ts` +- `apps/sim/lib/api/server/routes/internal-json-route.ts` +- `apps/sim/lib/api/server/routes/v2-json-route.ts` + +Use the file domain only as a representative golden slice: + +- `apps/sim/lib/workspace-files/application/operations.ts` +- `apps/sim/lib/workspace-files/application/authorized-workspace-file-use-case.ts` +- `apps/sim/lib/workspace-files/application/rename-workspace-file.ts` +- `apps/sim/lib/copilot/application/execute-file-use-case.ts` +- `apps/sim/lib/copilot/auth/file-delegation.ts` + +Then read the target domain's operation registry, application code, repositories, contracts, adapters, aliases, resume paths, and focused tests. Fail immediately if the shared foundation is absent. Do not recreate it inside the domain. + +## Bound the migration + +Inventory every entry point for the behavior before editing: + +- Internal HTTP routes and contracts. +- Public or versioned API routes and contracts. +- Copilot tools, aliases, resume paths, and polymorphic branches. +- Other tool servers, workflow executors, jobs, or service callers. +- Current authentication, authorization, workspace assertions, and concealment. +- Manager or orchestration call chains. +- Audit, notification, analytics, and billing side effects. +- Error/status/result behavior. +- Rate-limit identity, rollout gates, quota, and concurrency admission. + +Classify each as `migrate`, `defer`, or `non-goal`. Do not migrate adjacent operations merely because they share a module. Do not modify v1 unless the request explicitly includes it. + +Preserve behavior unless the task explicitly changes it. Stop and report a decision when surfaces currently disagree on security or compatibility behavior; do not silently choose one. + +## Keep the layers distinct + +Use these responsibilities: + +1. Authentication adapter: verify the surface credential or trusted execution context and construct a `Principal`. +2. Route or tool adapter: select rate policy, parse its contract, translate input, call the application use case, and render its own result. +3. Application use case: load canonical context, compare asserted scope, authorize the semantic operation, execute business behavior, project semantic audit, and trigger shared domain effects. +4. Manager or repository: perform database and storage reads or writes using canonical identifiers and scope. Never accept credentials or principals. +5. Presenter: return only the surface success body or typed binary descriptor. Never construct auth, rate, or error behavior. + +For ordinary public JSON routes, preserve this order: + +```text +IP abuse limit + -> authenticate + -> build Principal + -> operation rate limit + -> parse surface contract + -> application use case + -> canonical load + -> asserted-scope concealment + -> current authorization + -> manager read or mutation + -> semantic audit + -> shared domain effects + -> surface presenter +``` + +Internal routes may omit the IP bucket or operation limit only through an explicit policy with a reason. Usage billing, storage quota, cost admission, and concurrency are separate from request-rate limiting. + +Never query API keys or sessions from the application layer. Never add fallback identity or authorization behavior. Propagate infrastructure failures instead of turning them into not-found or forbidden results. + +## Define the semantic operation once + +Add one stable entry to the target domain's operation registry: + +```ts +rename: defineWorkspaceOperation({ + id: 'widgets.rename', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], +}) +``` + +Do not create internal-, public-, or Copilot-specific versions of the same semantic operation. If two callers have materially different business or transactional semantics, define separate use cases and explain the distinction. + +Choose principal kinds from actual behavior. Do not accept every principal merely because the use case is shared. Workspace API keys have a write ceiling and cannot satisfy admin operations. The operation definition must fail fast when its role, workspace-key policy, and principal kinds disagree. + +Route declarations, tool adapters, and use cases must use the same literal operation. Runtime operation selection is permitted only from a trusted, code-defined registry. Never accept an operation ID or permission tag from an HTTP body, model argument, or other untrusted input. + +## Implement the application use case + +Use `defineAuthorizedWorkspaceUseCase` directly or a thin domain binding that supplies domain-specific authorization options: + +```ts +export const renameWidget = defineAuthorizedWorkspaceUseCase({ + operation: widgetOperations.rename, + resolveContext: ({ input }: { input: RenameWidgetInput }) => + loadCanonicalWidgetContext(input.id, input.assertedWorkspaceId), + authorizationOptions: { delegation: widgetDelegationPolicy }, + execute: async ({ input, context }) => renameWidgetRecord({ + workspaceId: context.workspaceId, + widgetId: context.resourceId, + name: input.name, + }), + projectAudit: ({ result }) => ({ + action: AuditAction.WIDGET_UPDATED, + resourceType: AuditResourceType.WIDGET, + resourceId: result.id, + resourceName: result.name, + }), + afterSuccess: ({ context }) => notifyWidgetsChanged(context.workspaceId), +}) +``` + +Adapt the example to the domain's real authorization options; do not copy invented field names. + +The wrapper must own this lifecycle: + +1. Reject disallowed principal kinds before protected loading. +2. Load canonical context and conceal asserted-scope mismatches as required. +3. Authorize the operation using current policy state. +4. Execute the manager or repository primitive. +5. Project audit from authoritative results. +6. Await shared post-success effects. + +Do not call shared authorization, principal audit attribution, or `recordAudit` manually from an ordinary migrated use-case body. Use `projectAudit` only when the operation has semantic audit. Return no audit entries for authoritative no-ops. Keep product analytics such as `captureServerEvent` surface-specific through the adapter's success hook. + +Inspect legacy orchestration before reusing it. If it already authorizes, audits, notifies, or captures analytics, call a lower-level primitive or remove duplicate responsibility for migrated callers. + +## Adapt internal APIs + +Use `defineInternalJsonRoute` for ordinary JSON routes. Explicitly declare the contract, session authentication policy, semantic operation, rate policy, error policy, input mapping, use case, and presenter when the wire result differs. + +The internal adapter owns session authentication and internal response envelopes. It must not implement workspace authorization. Preserve internal-only analytics through `onSuccess` after application success. + +Keep the route module declarative. If several internal routes repeat authentication, parsing, error rendering, or response construction, improve the shared internal route builder instead of adding a domain-specific route wrapper. + +## Adapt public or versioned APIs + +Use the appropriate public/versioned route builder, such as `defineV2JsonRoute`, with API-key authentication, explicit operation rate policy, rollout policy, external error projection, and an external presenter. + +Authentication and HTTP formatting may differ from internal APIs; authorization and business behavior must not. Rate-limit using the credential or principal subject, never a billed owner. Resolve billing attribution only for billing, quota, or legacy required-user fields. + +Keep surface contracts separate when their wire shapes differ. Reuse shared primitive schemas and domain validators for invariants such as IDs, names, bounds, and formats. Do not maintain duplicate internal and external schemas merely because the routes are separate; import the same schema when the wire shape is genuinely identical. Never cast one surface response into another. + +Keep v1 middleware and routes unchanged unless explicitly included. + +## Adapt Copilot + +Create one domain-level Copilot application adapter instead of constructing delegated principals in every tool: + +```ts +executeCopilotWidgetUseCase(context, renameWidget, input, { resourceId }) +``` + +That adapter must: + +- Require a trusted server-authored Copilot execution marker. +- Require the authenticated subject, canonical workspace, tool-call or execution identity, and required audience or lifecycle scope. +- Construct the shared delegated `Principal` in one place. +- Optionally bind the canonical resource scope after trusted resolution. +- Verify that the use case exposes a registered code-defined operation. +- Call the application use case directly. + +Never construct authoritative delegation from model-provided workspace IDs, user IDs, operation IDs, resource scope, or permission tags. Model arguments are requested targets only and must be checked against trusted execution context and canonical data. + +Tool handlers own argument aliases, resumable legacy names, abort checks, and tool-specific presentation. They must not query managers directly for protected operations or manually authorize. + +A Copilot reference helper may translate a path to a resource only by calling an authorized application resolver under the intended semantic operation. Passing a code-defined operation object is acceptable; passing a model-provided operation string is not. Reauthorizing during later execution is safe but redundant. When resolution and execution form one business operation, need a consistent snapshot, or appear repeatedly together, prefer a top-level application use case such as `renameWidgetByReference`. + +Special composition roots may resolve one principal and deliberately thread it through several application calls or lower-level admission stages. Keep this exceptional and explicit; ordinary tools should use the shared execution adapter. + +Map expected typed errors to safe tool results. Unknown errors must become generic system/retryable messages while retaining full causes in server logs. Never return raw database or storage errors to the model. + +## Adapt other internal or external tools + +Treat every tool runtime as a surface adapter: + +- Normalize its already-authenticated execution context into an existing `Principal` through one shared adapter for that runtime or domain. +- Call the same application use case used by HTTP and Copilot surfaces. +- Preserve the tool protocol's input, output, retry, and cancellation semantics. +- Keep authoritative workspace and subject scope server-authored. + +An internal caller is not automatically trusted to bypass authorization. It must supply an explicit principal or use a deliberately designed service/delegation principal. If the current principal model cannot express its authority, stop and extend the identity model intentionally; do not fall back to an owner, uploader, creator, or arbitrary user ID. + +External tool endpoints authenticate at their adapter exactly like public APIs. Do not authenticate again inside the application use case. + +## Preserve identity and attribution + +- Session and personal-key principals authorize through current human workspace permission. +- Personal API keys also respect the workspace's personal-key policy. +- Workspace keys authorize as the workspace under explicit operation policy and the write ceiling, independent of creator membership. +- Delegated principals re-check the current subject and their workspace, audience, expiry, execution, and resource scope. +- Billing owners are attribution for billing or legacy required columns only, never authorization, rate identity, delegated identity, audit actor, or human analytics identity. +- Preserve structured `PrincipalActor` metadata in semantic audit. + +If a required legacy user column cannot represent the real actor, label the compatibility attribution explicitly. Never pretend it is the acting human. + +## Handle special operations explicitly + +Do not force these through an ordinary JSON migration: + +- Upload or multipart lifecycles: bind immutable credential identity, reauthorize control legs and finalization, and make durable completion idempotent. +- Large bodies: authenticate and perform cheap admission before bounded buffering. +- Binary or streaming responses: use binary/stream builders and typed descriptors. +- Bulk or recursive operations: deduplicate and cap inputs and expansion, load all resources canonically, and define atomic versus best-effort behavior. +- Polymorphic tools: select the semantic operation only after trusted target-kind resolution; do not route unrelated branches through one domain registry. +- Multi-resource transactions: keep canonical scope predicates and derive audit from authoritative affected rows. + +Stop and report a missing design rather than weakening identity, authorization, limits, or errors. + +## Test the complete matrix + +Add focused tests for every migrated surface and principal kind allowed by the operation: + +- Application: allowed and disallowed roles, principal-kind rejection before canonical loading, workspace assertion mismatch, delegated scope, not found, conflict, no-op, and infrastructure propagation. +- Repository: canonical active lookup, workspace-predicated writes, archived resources, authoritative affected rows, and database error propagation. +- Internal API: authentication before parsing, exact contract, typed errors, and surface analytics only after success. +- Public API: personal and workspace keys, rate and rollout behavior, concealment, exact external envelope, and rate headers. +- Copilot or tools: trusted context, rejected forged scope, aliases and resume paths, permission re-check, safe errors, and unchanged tool result shapes. +- Side effects: audit derives from authoritative results; shared notifications follow audit; neither occurs for rejection or no-op. + +Run at minimum: + +```bash +bunx vitest run +bunx biome check +bunx turbo run type-check --filter=sim --filter=@sim/auth +bun run check:api-validation:strict +git diff --check +``` + +Do not claim a check passed unless it completed successfully. + +## Work safely in parallel + +- Assign non-overlapping route modules and caller sets. Two methods in one route file are one ownership unit. +- Treat operation registries, contract families, route policies, and shared surface adapters as merge hotspots. +- Keep shared core foundations owned by one task; ordinary domain migrations should consume them without modifying them. +- Preserve unrelated working-tree changes. Never stage proposal docs, lockfile drift, or another agent's edits. +- Do not commit, push, or open a PR unless requested. + +## Hand off + +Report: + +1. Semantic operation, role, workspace-key policy, and principal kinds. +2. Migrated, deferred, and non-goal entry points. +3. Behavior preserved per internal, public, Copilot, and other tool surface. +4. Identity construction and authoritative scope source for each surface. +5. Files changed and shared merge hotspots. +6. Tests and checks run with results. +7. Remaining risks or blockers. Fail fast when an invariant could not be implemented. diff --git a/.agents/skills/migrate-application-operation/agents/openai.yaml b/.agents/skills/migrate-application-operation/agents/openai.yaml new file mode 100644 index 00000000000..625ce6954ca --- /dev/null +++ b/.agents/skills/migrate-application-operation/agents/openai.yaml @@ -0,0 +1,4 @@ +interface: + display_name: "Migrate Application Operation" + short_description: "Share one operation across API and tool surfaces" + default_prompt: "Use $migrate-application-operation to migrate one resource operation across internal APIs, public APIs, Copilot, and other tools." diff --git a/.agents/skills/migrate-file-operation/SKILL.md b/.agents/skills/migrate-file-operation/SKILL.md deleted file mode 100644 index 0ee72a1b53a..00000000000 --- a/.agents/skills/migrate-file-operation/SKILL.md +++ /dev/null @@ -1,231 +0,0 @@ ---- -name: migrate-file-operation -description: Migrate one existing Sim workspace-file operation to the shared Principal, operation policy, application use-case, internal route, v2 route, and Copilot delegation architecture. Use when consolidating file auth or moving a file read/write/delete/move operation away from route-local authorization while preserving each surface's contracts, errors, analytics, and compatibility behavior. Do not use for v1 routes; treat uploads, large bodies, bulk recursion, and binary streaming as special cases. ---- - -# Migrate File Operation - -Migrate one bounded semantic operation at a time. Share authorization and business behavior without forcing internal, v2, and Copilot surfaces to share authentication or response shapes. - -## Start from the golden slice - -Read these files before editing: - -- `packages/auth/src/principal.ts` -- `apps/sim/lib/core/application/operation.ts` -- `apps/sim/lib/core/application/workspace-operation.ts` -- `apps/sim/lib/core/application/workspace-authorization.ts` -- `apps/sim/lib/workspace-files/application/operations.ts` -- `apps/sim/lib/workspace-files/application/authorization.ts` -- `apps/sim/lib/workspace-files/application/rename-workspace-file.ts` -- `apps/sim/lib/api/server/routes/internal-json-route.ts` -- `apps/sim/lib/api/server/routes/v2-json-route.ts` -- `apps/sim/lib/workspace-files/api/route-policies.ts` -- `apps/sim/lib/copilot/auth/file-delegation.ts` -- The internal, v2, manager, contract, Copilot, and test files for the requested operation - -Fail immediately if the shared principal or route foundation is absent. Do not recreate a parallel framework. - -## Bound the slice - -Before editing, inventory every current entry point for the requested behavior: - -- HTTP method and route module -- Internal and v2 contracts and response envelopes -- Manager/orchestration call chain -- Copilot tools, aliases, resume paths, and polymorphic branches -- Permission checks and workspace assertions -- Audit, notification, product analytics, and other side effects -- Error/status behavior and resource-concealment behavior -- Rate-bucket identity, endpoint label, response headers, and rollout-gate behavior - -Record each entry point as `migrate`, `defer`, or `non-goal` in the handoff. Do not modify v1. Do not migrate adjacent methods merely because they share a route module. - -Preserve existing behavior unless the assigned migration explicitly changes it. If the target architecture conflicts with a current contract or test—such as 403 versus concealed 404, a legacy rate-bucket label, or a surface-specific event—stop and report the decision instead of silently choosing one. - -Use one semantic operation for all migrated surfaces. Never create internal-, v2-, or Copilot-specific copies of the same operation. If two paths have materially different transactional semantics, keep separate use cases and explain why. - -## Preserve the boundary - -Keep responsibilities in these layers: - -1. Route/tool adapter: authenticate, construct a `Principal`, select the operation and rate policy, parse the surface contract, call the use case, and render the surface response. -2. Application use case: load the canonical resource, compare any asserted workspace, authorize the operation, run the business operation, record semantic audit, and trigger shared domain notifications. -3. Manager/repository: execute storage and database reads/writes. Accept canonical IDs and workspace scope, not credentials or principals. -4. Presenter: return the contract success body only. Omit it when the use-case result already matches the contract. Never return `NextResponse`, rate metadata, or errors. - -Use this order for ordinary JSON routes: - -```text -IP abuse limit, when public - -> authenticate - -> build Principal - -> operation rate limit - -> parse contract - -> application use case - -> canonical load by resource ID - -> asserted-workspace concealment - -> current authorization - -> manager mutation/read - -> audit and shared notification - -> surface presenter -``` - -Never query API keys or sessions from the application use case. Authentication freshness belongs to the adapter. Never implement raw database transactions in the application use case when a manager primitive exists; add a narrow throwing manager primitive when needed. - -Do not add API-key rechecks, row locks, or transaction-wide authorization solely for a theoretical race. Use the authenticated principal and canonical resource snapshot unless the business operation itself requires atomic multi-row behavior or the task explicitly asks for stronger race semantics. - -## Define the operation once - -Add exactly one entry to `fileOperations` with a stable semantic ID, minimum workspace role, explicit workspace-key policy, and accepted principal kinds. - -```ts -operationName: defineWorkspaceOperation({ - id: 'files.operation_name', - minimumRole: 'read' satisfies PermissionType, - workspaceApiKey: 'allow', - principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], -}) -``` - -Do not accept every principal merely because a use case is shared. An internal-only operation can declare `['session']`; a shared internal/Copilot operation can declare `['session', 'delegated']`. The definition fails immediately when `principalKinds` and the workspace-key policy disagree. The authorized-use-case wrapper rejects disallowed kinds before canonical loading and narrows the business callback's principal type. - -Choose policy from behavior, not from the calling surface: - -- Reads normally require `read`. -- Content or metadata mutations normally require `write`. -- Workspace API keys have a fixed `write` ceiling and cannot satisfy admin operations. -- Use `deny` when a workspace key must not perform the operation. - -The route declaration and use case must expose the same literal operation. Let route-definition initialization fail on mismatches. - -## Implement the application use case - -Define the use case with `defineAuthorizedWorkspaceFileUseCase`, the file-domain binding over the resource-agnostic application foundation. - -```ts -export const operationWorkspaceFile = defineAuthorizedWorkspaceFileUseCase({ - operation: fileOperations.operationName, - resolveContext: ({ input }: { input: OperationWorkspaceFileInput }) => - loadCanonicalOperationContext(input), - execute: executeOperationWorkspaceFile, - projectAudit: ({ result }) => ({ - action: AuditAction.FILE_UPDATED, - resourceType: AuditResourceType.FILE, - resourceId: result.file.id, - resourceName: result.file.name, - }), - afterSuccess: ({ context }) => notifyWorkspaceFilesChanged(context.workspaceId), -}) -``` - -The wrapper owns this lifecycle: - -1. Reject principal kinds not declared by the operation. -2. Resolve the canonical context and authorize it with file delegation scope. -3. Execute the business callback. -4. Resolve audit attribution and record projected semantic entries. -5. Await declared post-success domain effects. - -`resolveContext` loads canonical resources, derives workspace policy state, and conceals asserted-workspace mismatches. It does not authorize. `execute` receives an already-authorized, operation-narrowed principal and calls manager/repository primitives. `projectAudit` derives zero, one, or several entries from the authoritative mutation result; return `[]` for a no-op and never infer affected resources from requested IDs. The wrapper injects workspace, operation, actor, and request metadata. `afterSuccess` owns shared notifications. - -Do not call `authorizeWorkspaceFileAccess`, `authorizeWorkspaceFileOperation`, `resolvePrincipalAuditAttribution`, or `recordAudit` from an ordinary migrated use-case body. Resolve `PrincipalAttribution` only when a required legacy user field needs it. Upload lifecycle code and other explicitly deferred special cases may retain their dedicated flow until separately migrated. - -Propagate infrastructure failures. Use existing typed orchestration errors for expected not-found, forbidden, validation, and conflict outcomes. Do not turn database failures into not-found or authorization failures, and never add fallback behavior. - -Inspect legacy orchestration before reusing it. If it already records audit, sends notifications, or emits analytics, call a lower-level manager primitive or remove the duplicated responsibility for migrated callers. Never wrap a side-effecting orchestration helper and then emit the same side effects again. - -### Principal and attribution rules - -- Session and personal-key principals authorize through the current human workspace permission. -- Personal API keys must also respect the workspace's `allowPersonalApiKeys` setting. -- Workspace-key principals authorize as the workspace itself under the operation's explicit allow/deny policy and write ceiling. Do not use creator membership for authorization. -- Delegated Copilot principals re-check the current subject user's permission and their workspace, audience, expiry, and resource scope. -- Use the current billing owner only where a legacy required user ID or billing attribution requires one. Never use that owner for authorization, rate-limit identity, delegated identity, or human product analytics. -- Preserve `PrincipalActor` metadata in audit records so a workspace key is not represented as a human actor. - -Audit is shared semantic behavior declared through `projectAudit`; the wrapper records it after successful execution using the real `PrincipalActor`. Product analytics are surface behavior: preserve an existing internal `captureServerEvent` with the internal route's optional `onSuccess`; do not move it into the shared use case or manufacture a human distinct ID for workspace keys or Copilot. - -## Adapt each surface independently - -### Internal JSON - -Use `defineInternalJsonRoute` and explicitly declare: - -- `contract` -- `auth: internalSessionAuth` -- `operation` -- `rateLimit`, including a reason for `none` -- `errorPolicy` -- `mapInput` -- `useCase` -- optional `onSuccess` only to preserve surface-specific behavior -- `present` when the surface wire body differs from the use-case result - -Keep the existing internal success and error envelope. Error policies project typed status/body descriptors rather than constructing responses. The builder owns `withRouteHandler`, authentication-before-parse ordering, response validation, and `NextResponse` creation. - -### V2 JSON - -Use `defineV2JsonRoute` with `v2ApiKeyAuth`, the explicit operation rate policy, the v2 error policy, and a v2-only presenter. Keep v2 schemas in `apps/sim/lib/api/contracts/v2/files.ts` and internal schemas in the workspace-file contract family. Never import Zod or declare boundary schemas in a route. - -For resource-ID operations, the target v2 policy normally conceals absence, cross-workspace assertions, and unauthorized access. If the current contract or tests expose 403, flag that compatibility decision before changing it. Keep v1 middleware and v1 routes untouched. - -### Copilot - -Resolve path/name aliases only within the trusted execution workspace, then call the same application use case with `createCopilotFilePrincipal`. Never construct authoritative workspace, subject, audience, or resource scope from model-provided tool arguments. Preserve each tool's existing result shape and resumable aliases. - -Map typed failures to a safe Copilot message. Unknown failures must become a generic retryable/system message; never return raw database or storage error messages to the model. - -## Handle special operations explicitly - -Do not force these through the ordinary JSON pipeline: - -- Upload/multipart: use a lifecycle-specific design with immutable credential binding, fresh authorization on control legs and finalization, current billing attribution at durable registration, and idempotent completion. Do not add a database migration unless explicitly authorized; under the no-migration design, store a versioned server-authored `authBinding` in existing upload-session metadata, reject missing or invalid new-format bindings, and explicitly drain or version legacy in-flight sessions. -- Large JSON bodies: perform cheap authenticated admission before bounded body buffering. -- Binary/download/streaming: use a binary or streaming route builder and a typed response descriptor. -- Bulk/recursive operations: deduplicate and cap requested IDs, cap expanded rows/bytes, load every resource canonically, preserve workspace predicates, and decide atomic versus best-effort behavior explicitly. - -Stop and report the missing design instead of weakening limits, authorization, or error handling. - -## Test the complete behavior matrix - -Add focused tests proportional to the migrated surfaces: - -- Use case: session admin/writer/reader, personal key enabled/disabled, allowed/wrong-workspace workspace key, delegated current/stale/out-of-scope principal, asserted-workspace mismatch, not found, conflict, and infrastructure failure. -- Manager: canonical active lookup, workspace-predicated mutation, archived resources, and propagated database errors. -- Internal route: authentication before parsing, exact response contract, typed errors, and any preserved `onSuccess` event only after success. -- V2 route: personal and workspace keys, rollout/rate behavior, concealment, exact v2 envelope, and rate headers after charging. -- Copilot: trusted delegation, aliases/resume paths, permission re-check, safe errors, and unchanged tool response shape. -- Side effects: audit is projected from authoritative results, notifications follow audit projection, and neither occurs for rejected operations or zero-row results. Do not claim exactly-once delivery across independent client retries without durable idempotency/outbox support. - -Run at minimum: - -```bash -bunx vitest run -bunx biome check -bunx turbo run type-check --filter=sim --filter=@sim/auth -bun run check:api-validation:strict -git diff --check -``` - -Do not claim a check passed unless it was run successfully. - -## Work safely in parallel - -- Assign one agent a non-overlapping route-module and caller set. Two methods in one route file are not safe parallel assignments. -- Start every agent from the same foundation commit in its own branch/worktree when possible. -- Treat the file `operations.ts` registry, contract family files, and file route policies as merge hotspots. The core application primitives and shared route builders should not change for an ordinary file-operation migration. -- Do not refactor shared principal, authorization, rate limiting, or error foundations unless the task explicitly assigns that ownership. -- Preserve unrelated working-tree changes. Never stage architecture docs, lockfile drift, or another agent's edits. -- Do not commit, push, or open a PR unless the task explicitly asks for it. - -## Hand off - -Report: - -1. The semantic operation and policy. -2. Every migrated, deferred, and non-goal entry point. -3. Behavior preserved per surface, including analytics and response differences. -4. Files changed and any shared merge hotspots. -5. Tests and checks run with results. -6. Remaining risks or blockers. Fail fast if any required invariant could not be implemented. diff --git a/.agents/skills/migrate-file-operation/agents/openai.yaml b/.agents/skills/migrate-file-operation/agents/openai.yaml deleted file mode 100644 index d668a08e7be..00000000000 --- a/.agents/skills/migrate-file-operation/agents/openai.yaml +++ /dev/null @@ -1,4 +0,0 @@ -interface: - display_name: "Migrate File Operation" - short_description: "Migrate one file operation across shared auth surfaces" - default_prompt: "Use $migrate-file-operation to migrate one file operation across internal, v2, and Copilot surfaces." diff --git a/.claude/commands/migrate-application-operation.md b/.claude/commands/migrate-application-operation.md new file mode 100644 index 00000000000..6411a56f3ad --- /dev/null +++ b/.claude/commands/migrate-application-operation.md @@ -0,0 +1,290 @@ +--- +description: Migrate one existing Sim resource operation into the shared Principal and application-use-case architecture across internal APIs, public or versioned APIs, Copilot, and other trusted tool adapters. Use when removing route- or tool-local authorization and business logic, consolidating resource reads or writes behind semantic operation policies, or adding another surface to an existing application operation while preserving contracts, identity, errors, rate limits, audit, analytics, and compatibility behavior. Treat v1, uploads, streams, large bodies, bulk recursion, and polymorphic tools as explicitly scoped special cases. +--- + +# Migrate Application Operation + +Migrate one bounded semantic operation at a time. Share authorization and business behavior without forcing internal APIs, public APIs, Copilot, and other tools to share authentication, input schemas, or response shapes. + +## Enforce the application boundary + +Apply this invariant: + +> Every real operation on persisted or protected data enters through an authorized application use case. + +This includes mutations, content and metadata reads, canonical resource lookup, and reference-to-resource resolution when the lookup is authorization-sensitive. + +Surface helpers may: + +- Normalize an already-authenticated surface context into a `Principal`. +- Translate aliases or wire arguments into application input. +- Select a code-defined operation and application use case. +- Call the application use case. +- Translate typed results and errors into the surface contract. + +Surface helpers must not: + +- Query databases or storage. +- Decide workspace or resource authorization. +- Implement business transactions. +- Record semantic audit or shared domain notifications. +- Infer authoritative identity, workspace, audience, or scope from untrusted arguments. +- Substitute billing attribution for identity. + +A helper that resolves a path is valid only when the actual protected lookup runs through an authorized application resolver. If a helper begins doing real data work, move that work into an application use case. + +## Read the foundation first + +Read these files completely before editing: + +- `packages/auth/src/principal.ts` +- `apps/sim/lib/core/application/operation.ts` +- `apps/sim/lib/core/application/workspace-operation.ts` +- `apps/sim/lib/core/application/workspace-authorization.ts` +- `apps/sim/lib/core/application/authorized-workspace-use-case.ts` +- `apps/sim/lib/api/server/routes/internal-json-route.ts` +- `apps/sim/lib/api/server/routes/v2-json-route.ts` + +Use the file domain only as a representative golden slice: + +- `apps/sim/lib/workspace-files/application/operations.ts` +- `apps/sim/lib/workspace-files/application/authorized-workspace-file-use-case.ts` +- `apps/sim/lib/workspace-files/application/rename-workspace-file.ts` +- `apps/sim/lib/copilot/application/execute-file-use-case.ts` +- `apps/sim/lib/copilot/auth/file-delegation.ts` + +Then read the target domain's operation registry, application code, repositories, contracts, adapters, aliases, resume paths, and focused tests. Fail immediately if the shared foundation is absent. Do not recreate it inside the domain. + +## Bound the migration + +Inventory every entry point for the behavior before editing: + +- Internal HTTP routes and contracts. +- Public or versioned API routes and contracts. +- Copilot tools, aliases, resume paths, and polymorphic branches. +- Other tool servers, workflow executors, jobs, or service callers. +- Current authentication, authorization, workspace assertions, and concealment. +- Manager or orchestration call chains. +- Audit, notification, analytics, and billing side effects. +- Error/status/result behavior. +- Rate-limit identity, rollout gates, quota, and concurrency admission. + +Classify each as `migrate`, `defer`, or `non-goal`. Do not migrate adjacent operations merely because they share a module. Do not modify v1 unless the request explicitly includes it. + +Preserve behavior unless the task explicitly changes it. Stop and report a decision when surfaces currently disagree on security or compatibility behavior; do not silently choose one. + +## Keep the layers distinct + +Use these responsibilities: + +1. Authentication adapter: verify the surface credential or trusted execution context and construct a `Principal`. +2. Route or tool adapter: select rate policy, parse its contract, translate input, call the application use case, and render its own result. +3. Application use case: load canonical context, compare asserted scope, authorize the semantic operation, execute business behavior, project semantic audit, and trigger shared domain effects. +4. Manager or repository: perform database and storage reads or writes using canonical identifiers and scope. Never accept credentials or principals. +5. Presenter: return only the surface success body or typed binary descriptor. Never construct auth, rate, or error behavior. + +For ordinary public JSON routes, preserve this order: + +```text +IP abuse limit + -> authenticate + -> build Principal + -> operation rate limit + -> parse surface contract + -> application use case + -> canonical load + -> asserted-scope concealment + -> current authorization + -> manager read or mutation + -> semantic audit + -> shared domain effects + -> surface presenter +``` + +Internal routes may omit the IP bucket or operation limit only through an explicit policy with a reason. Usage billing, storage quota, cost admission, and concurrency are separate from request-rate limiting. + +Never query API keys or sessions from the application layer. Never add fallback identity or authorization behavior. Propagate infrastructure failures instead of turning them into not-found or forbidden results. + +## Define the semantic operation once + +Add one stable entry to the target domain's operation registry: + +```ts +rename: defineWorkspaceOperation({ + id: 'widgets.rename', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], +}) +``` + +Do not create internal-, public-, or Copilot-specific versions of the same semantic operation. If two callers have materially different business or transactional semantics, define separate use cases and explain the distinction. + +Choose principal kinds from actual behavior. Do not accept every principal merely because the use case is shared. Workspace API keys have a write ceiling and cannot satisfy admin operations. The operation definition must fail fast when its role, workspace-key policy, and principal kinds disagree. + +Route declarations, tool adapters, and use cases must use the same literal operation. Runtime operation selection is permitted only from a trusted, code-defined registry. Never accept an operation ID or permission tag from an HTTP body, model argument, or other untrusted input. + +## Implement the application use case + +Use `defineAuthorizedWorkspaceUseCase` directly or a thin domain binding that supplies domain-specific authorization options: + +```ts +export const renameWidget = defineAuthorizedWorkspaceUseCase({ + operation: widgetOperations.rename, + resolveContext: ({ input }: { input: RenameWidgetInput }) => + loadCanonicalWidgetContext(input.id, input.assertedWorkspaceId), + authorizationOptions: { delegation: widgetDelegationPolicy }, + execute: async ({ input, context }) => renameWidgetRecord({ + workspaceId: context.workspaceId, + widgetId: context.resourceId, + name: input.name, + }), + projectAudit: ({ result }) => ({ + action: AuditAction.WIDGET_UPDATED, + resourceType: AuditResourceType.WIDGET, + resourceId: result.id, + resourceName: result.name, + }), + afterSuccess: ({ context }) => notifyWidgetsChanged(context.workspaceId), +}) +``` + +Adapt the example to the domain's real authorization options; do not copy invented field names. + +The wrapper must own this lifecycle: + +1. Reject disallowed principal kinds before protected loading. +2. Load canonical context and conceal asserted-scope mismatches as required. +3. Authorize the operation using current policy state. +4. Execute the manager or repository primitive. +5. Project audit from authoritative results. +6. Await shared post-success effects. + +Do not call shared authorization, principal audit attribution, or `recordAudit` manually from an ordinary migrated use-case body. Use `projectAudit` only when the operation has semantic audit. Return no audit entries for authoritative no-ops. Keep product analytics such as `captureServerEvent` surface-specific through the adapter's success hook. + +Inspect legacy orchestration before reusing it. If it already authorizes, audits, notifies, or captures analytics, call a lower-level primitive or remove duplicate responsibility for migrated callers. + +## Adapt internal APIs + +Use `defineInternalJsonRoute` for ordinary JSON routes. Explicitly declare the contract, session authentication policy, semantic operation, rate policy, error policy, input mapping, use case, and presenter when the wire result differs. + +The internal adapter owns session authentication and internal response envelopes. It must not implement workspace authorization. Preserve internal-only analytics through `onSuccess` after application success. + +Keep the route module declarative. If several internal routes repeat authentication, parsing, error rendering, or response construction, improve the shared internal route builder instead of adding a domain-specific route wrapper. + +## Adapt public or versioned APIs + +Use the appropriate public/versioned route builder, such as `defineV2JsonRoute`, with API-key authentication, explicit operation rate policy, rollout policy, external error projection, and an external presenter. + +Authentication and HTTP formatting may differ from internal APIs; authorization and business behavior must not. Rate-limit using the credential or principal subject, never a billed owner. Resolve billing attribution only for billing, quota, or legacy required-user fields. + +Keep surface contracts separate when their wire shapes differ. Reuse shared primitive schemas and domain validators for invariants such as IDs, names, bounds, and formats. Do not maintain duplicate internal and external schemas merely because the routes are separate; import the same schema when the wire shape is genuinely identical. Never cast one surface response into another. + +Keep v1 middleware and routes unchanged unless explicitly included. + +## Adapt Copilot + +Create one domain-level Copilot application adapter instead of constructing delegated principals in every tool: + +```ts +executeCopilotWidgetUseCase(context, renameWidget, input, { resourceId }) +``` + +That adapter must: + +- Require a trusted server-authored Copilot execution marker. +- Require the authenticated subject, canonical workspace, tool-call or execution identity, and required audience or lifecycle scope. +- Construct the shared delegated `Principal` in one place. +- Optionally bind the canonical resource scope after trusted resolution. +- Verify that the use case exposes a registered code-defined operation. +- Call the application use case directly. + +Never construct authoritative delegation from model-provided workspace IDs, user IDs, operation IDs, resource scope, or permission tags. Model arguments are requested targets only and must be checked against trusted execution context and canonical data. + +Tool handlers own argument aliases, resumable legacy names, abort checks, and tool-specific presentation. They must not query managers directly for protected operations or manually authorize. + +A Copilot reference helper may translate a path to a resource only by calling an authorized application resolver under the intended semantic operation. Passing a code-defined operation object is acceptable; passing a model-provided operation string is not. Reauthorizing during later execution is safe but redundant. When resolution and execution form one business operation, need a consistent snapshot, or appear repeatedly together, prefer a top-level application use case such as `renameWidgetByReference`. + +Special composition roots may resolve one principal and deliberately thread it through several application calls or lower-level admission stages. Keep this exceptional and explicit; ordinary tools should use the shared execution adapter. + +Map expected typed errors to safe tool results. Unknown errors must become generic system/retryable messages while retaining full causes in server logs. Never return raw database or storage errors to the model. + +## Adapt other internal or external tools + +Treat every tool runtime as a surface adapter: + +- Normalize its already-authenticated execution context into an existing `Principal` through one shared adapter for that runtime or domain. +- Call the same application use case used by HTTP and Copilot surfaces. +- Preserve the tool protocol's input, output, retry, and cancellation semantics. +- Keep authoritative workspace and subject scope server-authored. + +An internal caller is not automatically trusted to bypass authorization. It must supply an explicit principal or use a deliberately designed service/delegation principal. If the current principal model cannot express its authority, stop and extend the identity model intentionally; do not fall back to an owner, uploader, creator, or arbitrary user ID. + +External tool endpoints authenticate at their adapter exactly like public APIs. Do not authenticate again inside the application use case. + +## Preserve identity and attribution + +- Session and personal-key principals authorize through current human workspace permission. +- Personal API keys also respect the workspace's personal-key policy. +- Workspace keys authorize as the workspace under explicit operation policy and the write ceiling, independent of creator membership. +- Delegated principals re-check the current subject and their workspace, audience, expiry, execution, and resource scope. +- Billing owners are attribution for billing or legacy required columns only, never authorization, rate identity, delegated identity, audit actor, or human analytics identity. +- Preserve structured `PrincipalActor` metadata in semantic audit. + +If a required legacy user column cannot represent the real actor, label the compatibility attribution explicitly. Never pretend it is the acting human. + +## Handle special operations explicitly + +Do not force these through an ordinary JSON migration: + +- Upload or multipart lifecycles: bind immutable credential identity, reauthorize control legs and finalization, and make durable completion idempotent. +- Large bodies: authenticate and perform cheap admission before bounded buffering. +- Binary or streaming responses: use binary/stream builders and typed descriptors. +- Bulk or recursive operations: deduplicate and cap inputs and expansion, load all resources canonically, and define atomic versus best-effort behavior. +- Polymorphic tools: select the semantic operation only after trusted target-kind resolution; do not route unrelated branches through one domain registry. +- Multi-resource transactions: keep canonical scope predicates and derive audit from authoritative affected rows. + +Stop and report a missing design rather than weakening identity, authorization, limits, or errors. + +## Test the complete matrix + +Add focused tests for every migrated surface and principal kind allowed by the operation: + +- Application: allowed and disallowed roles, principal-kind rejection before canonical loading, workspace assertion mismatch, delegated scope, not found, conflict, no-op, and infrastructure propagation. +- Repository: canonical active lookup, workspace-predicated writes, archived resources, authoritative affected rows, and database error propagation. +- Internal API: authentication before parsing, exact contract, typed errors, and surface analytics only after success. +- Public API: personal and workspace keys, rate and rollout behavior, concealment, exact external envelope, and rate headers. +- Copilot or tools: trusted context, rejected forged scope, aliases and resume paths, permission re-check, safe errors, and unchanged tool result shapes. +- Side effects: audit derives from authoritative results; shared notifications follow audit; neither occurs for rejection or no-op. + +Run at minimum: + +```bash +bunx vitest run +bunx biome check +bunx turbo run type-check --filter=sim --filter=@sim/auth +bun run check:api-validation:strict +git diff --check +``` + +Do not claim a check passed unless it completed successfully. + +## Work safely in parallel + +- Assign non-overlapping route modules and caller sets. Two methods in one route file are one ownership unit. +- Treat operation registries, contract families, route policies, and shared surface adapters as merge hotspots. +- Keep shared core foundations owned by one task; ordinary domain migrations should consume them without modifying them. +- Preserve unrelated working-tree changes. Never stage proposal docs, lockfile drift, or another agent's edits. +- Do not commit, push, or open a PR unless requested. + +## Hand off + +Report: + +1. Semantic operation, role, workspace-key policy, and principal kinds. +2. Migrated, deferred, and non-goal entry points. +3. Behavior preserved per internal, public, Copilot, and other tool surface. +4. Identity construction and authoritative scope source for each surface. +5. Files changed and shared merge hotspots. +6. Tests and checks run with results. +7. Remaining risks or blockers. Fail fast when an invariant could not be implemented. diff --git a/.claude/commands/migrate-file-operation.md b/.claude/commands/migrate-file-operation.md deleted file mode 100644 index 2a42d5457db..00000000000 --- a/.claude/commands/migrate-file-operation.md +++ /dev/null @@ -1,230 +0,0 @@ ---- -description: Migrate one existing Sim workspace-file operation to the shared Principal, operation policy, application use-case, internal route, v2 route, and Copilot delegation architecture. Use when consolidating file auth or moving a file read/write/delete/move operation away from route-local authorization while preserving each surface's contracts, errors, analytics, and compatibility behavior. Do not use for v1 routes; treat uploads, large bodies, bulk recursion, and binary streaming as special cases. ---- - -# Migrate File Operation - -Migrate one bounded semantic operation at a time. Share authorization and business behavior without forcing internal, v2, and Copilot surfaces to share authentication or response shapes. - -## Start from the golden slice - -Read these files before editing: - -- `packages/auth/src/principal.ts` -- `apps/sim/lib/core/application/operation.ts` -- `apps/sim/lib/core/application/workspace-operation.ts` -- `apps/sim/lib/core/application/workspace-authorization.ts` -- `apps/sim/lib/workspace-files/application/operations.ts` -- `apps/sim/lib/workspace-files/application/authorization.ts` -- `apps/sim/lib/workspace-files/application/rename-workspace-file.ts` -- `apps/sim/lib/api/server/routes/internal-json-route.ts` -- `apps/sim/lib/api/server/routes/v2-json-route.ts` -- `apps/sim/lib/workspace-files/api/route-policies.ts` -- `apps/sim/lib/copilot/auth/file-delegation.ts` -- The internal, v2, manager, contract, Copilot, and test files for the requested operation - -Fail immediately if the shared principal or route foundation is absent. Do not recreate a parallel framework. - -## Bound the slice - -Before editing, inventory every current entry point for the requested behavior: - -- HTTP method and route module -- Internal and v2 contracts and response envelopes -- Manager/orchestration call chain -- Copilot tools, aliases, resume paths, and polymorphic branches -- Permission checks and workspace assertions -- Audit, notification, product analytics, and other side effects -- Error/status behavior and resource-concealment behavior -- Rate-bucket identity, endpoint label, response headers, and rollout-gate behavior - -Record each entry point as `migrate`, `defer`, or `non-goal` in the handoff. Do not modify v1. Do not migrate adjacent methods merely because they share a route module. - -Preserve existing behavior unless the assigned migration explicitly changes it. If the target architecture conflicts with a current contract or test—such as 403 versus concealed 404, a legacy rate-bucket label, or a surface-specific event—stop and report the decision instead of silently choosing one. - -Use one semantic operation for all migrated surfaces. Never create internal-, v2-, or Copilot-specific copies of the same operation. If two paths have materially different transactional semantics, keep separate use cases and explain why. - -## Preserve the boundary - -Keep responsibilities in these layers: - -1. Route/tool adapter: authenticate, construct a `Principal`, select the operation and rate policy, parse the surface contract, call the use case, and render the surface response. -2. Application use case: load the canonical resource, compare any asserted workspace, authorize the operation, run the business operation, record semantic audit, and trigger shared domain notifications. -3. Manager/repository: execute storage and database reads/writes. Accept canonical IDs and workspace scope, not credentials or principals. -4. Presenter: return the contract success body only. Omit it when the use-case result already matches the contract. Never return `NextResponse`, rate metadata, or errors. - -Use this order for ordinary JSON routes: - -```text -IP abuse limit, when public - -> authenticate - -> build Principal - -> operation rate limit - -> parse contract - -> application use case - -> canonical load by resource ID - -> asserted-workspace concealment - -> current authorization - -> manager mutation/read - -> audit and shared notification - -> surface presenter -``` - -Never query API keys or sessions from the application use case. Authentication freshness belongs to the adapter. Never implement raw database transactions in the application use case when a manager primitive exists; add a narrow throwing manager primitive when needed. - -Do not add API-key rechecks, row locks, or transaction-wide authorization solely for a theoretical race. Use the authenticated principal and canonical resource snapshot unless the business operation itself requires atomic multi-row behavior or the task explicitly asks for stronger race semantics. - -## Define the operation once - -Add exactly one entry to `fileOperations` with a stable semantic ID, minimum workspace role, explicit workspace-key policy, and accepted principal kinds. - -```ts -operationName: defineWorkspaceOperation({ - id: 'files.operation_name', - minimumRole: 'read' satisfies PermissionType, - workspaceApiKey: 'allow', - principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], -}) -``` - -Do not accept every principal merely because a use case is shared. An internal-only operation can declare `['session']`; a shared internal/Copilot operation can declare `['session', 'delegated']`. The definition fails immediately when `principalKinds` and the workspace-key policy disagree. The authorized-use-case wrapper rejects disallowed kinds before canonical loading and narrows the business callback's principal type. - -Choose policy from behavior, not from the calling surface: - -- Reads normally require `read`. -- Content or metadata mutations normally require `write`. -- Workspace API keys have a fixed `write` ceiling and cannot satisfy admin operations. -- Use `deny` when a workspace key must not perform the operation. - -The route declaration and use case must expose the same literal operation. Let route-definition initialization fail on mismatches. - -## Implement the application use case - -Define the use case with `defineAuthorizedWorkspaceFileUseCase`, the file-domain binding over the resource-agnostic application foundation. - -```ts -export const operationWorkspaceFile = defineAuthorizedWorkspaceFileUseCase({ - operation: fileOperations.operationName, - resolveContext: ({ input }: { input: OperationWorkspaceFileInput }) => - loadCanonicalOperationContext(input), - execute: executeOperationWorkspaceFile, - projectAudit: ({ result }) => ({ - action: AuditAction.FILE_UPDATED, - resourceType: AuditResourceType.FILE, - resourceId: result.file.id, - resourceName: result.file.name, - }), - afterSuccess: ({ context }) => notifyWorkspaceFilesChanged(context.workspaceId), -}) -``` - -The wrapper owns this lifecycle: - -1. Reject principal kinds not declared by the operation. -2. Resolve the canonical context and authorize it with file delegation scope. -3. Execute the business callback. -4. Resolve audit attribution and record projected semantic entries. -5. Await declared post-success domain effects. - -`resolveContext` loads canonical resources, derives workspace policy state, and conceals asserted-workspace mismatches. It does not authorize. `execute` receives an already-authorized, operation-narrowed principal and calls manager/repository primitives. `projectAudit` derives zero, one, or several entries from the authoritative mutation result; return `[]` for a no-op and never infer affected resources from requested IDs. The wrapper injects workspace, operation, actor, and request metadata. `afterSuccess` owns shared notifications. - -Do not call `authorizeWorkspaceFileAccess`, `authorizeWorkspaceFileOperation`, `resolvePrincipalAuditAttribution`, or `recordAudit` from an ordinary migrated use-case body. Resolve `PrincipalAttribution` only when a required legacy user field needs it. Upload lifecycle code and other explicitly deferred special cases may retain their dedicated flow until separately migrated. - -Propagate infrastructure failures. Use existing typed orchestration errors for expected not-found, forbidden, validation, and conflict outcomes. Do not turn database failures into not-found or authorization failures, and never add fallback behavior. - -Inspect legacy orchestration before reusing it. If it already records audit, sends notifications, or emits analytics, call a lower-level manager primitive or remove the duplicated responsibility for migrated callers. Never wrap a side-effecting orchestration helper and then emit the same side effects again. - -### Principal and attribution rules - -- Session and personal-key principals authorize through the current human workspace permission. -- Personal API keys must also respect the workspace's `allowPersonalApiKeys` setting. -- Workspace-key principals authorize as the workspace itself under the operation's explicit allow/deny policy and write ceiling. Do not use creator membership for authorization. -- Delegated Copilot principals re-check the current subject user's permission and their workspace, audience, expiry, and resource scope. -- Use the current billing owner only where a legacy required user ID or billing attribution requires one. Never use that owner for authorization, rate-limit identity, delegated identity, or human product analytics. -- Preserve `PrincipalActor` metadata in audit records so a workspace key is not represented as a human actor. - -Audit is shared semantic behavior declared through `projectAudit`; the wrapper records it after successful execution using the real `PrincipalActor`. Product analytics are surface behavior: preserve an existing internal `captureServerEvent` with the internal route's optional `onSuccess`; do not move it into the shared use case or manufacture a human distinct ID for workspace keys or Copilot. - -## Adapt each surface independently - -### Internal JSON - -Use `defineInternalJsonRoute` and explicitly declare: - -- `contract` -- `auth: internalSessionAuth` -- `operation` -- `rateLimit`, including a reason for `none` -- `errorPolicy` -- `mapInput` -- `useCase` -- optional `onSuccess` only to preserve surface-specific behavior -- `present` when the surface wire body differs from the use-case result - -Keep the existing internal success and error envelope. Error policies project typed status/body descriptors rather than constructing responses. The builder owns `withRouteHandler`, authentication-before-parse ordering, response validation, and `NextResponse` creation. - -### V2 JSON - -Use `defineV2JsonRoute` with `v2ApiKeyAuth`, the explicit operation rate policy, the v2 error policy, and a v2-only presenter. Keep v2 schemas in `apps/sim/lib/api/contracts/v2/files.ts` and internal schemas in the workspace-file contract family. Never import Zod or declare boundary schemas in a route. - -For resource-ID operations, the target v2 policy normally conceals absence, cross-workspace assertions, and unauthorized access. If the current contract or tests expose 403, flag that compatibility decision before changing it. Keep v1 middleware and v1 routes untouched. - -### Copilot - -Resolve path/name aliases only within the trusted execution workspace, then call the same application use case with `createCopilotFilePrincipal`. Never construct authoritative workspace, subject, audience, or resource scope from model-provided tool arguments. Preserve each tool's existing result shape and resumable aliases. - -Map typed failures to a safe Copilot message. Unknown failures must become a generic retryable/system message; never return raw database or storage error messages to the model. - -## Handle special operations explicitly - -Do not force these through the ordinary JSON pipeline: - -- Upload/multipart: use a lifecycle-specific design with immutable credential binding, fresh authorization on control legs and finalization, current billing attribution at durable registration, and idempotent completion. Do not add a database migration unless explicitly authorized; under the no-migration design, store a versioned server-authored `authBinding` in existing upload-session metadata, reject missing or invalid new-format bindings, and explicitly drain or version legacy in-flight sessions. -- Large JSON bodies: perform cheap authenticated admission before bounded body buffering. -- Binary/download/streaming: use a binary or streaming route builder and a typed response descriptor. -- Bulk/recursive operations: deduplicate and cap requested IDs, cap expanded rows/bytes, load every resource canonically, preserve workspace predicates, and decide atomic versus best-effort behavior explicitly. - -Stop and report the missing design instead of weakening limits, authorization, or error handling. - -## Test the complete behavior matrix - -Add focused tests proportional to the migrated surfaces: - -- Use case: session admin/writer/reader, personal key enabled/disabled, allowed/wrong-workspace workspace key, delegated current/stale/out-of-scope principal, asserted-workspace mismatch, not found, conflict, and infrastructure failure. -- Manager: canonical active lookup, workspace-predicated mutation, archived resources, and propagated database errors. -- Internal route: authentication before parsing, exact response contract, typed errors, and any preserved `onSuccess` event only after success. -- V2 route: personal and workspace keys, rollout/rate behavior, concealment, exact v2 envelope, and rate headers after charging. -- Copilot: trusted delegation, aliases/resume paths, permission re-check, safe errors, and unchanged tool response shape. -- Side effects: audit is projected from authoritative results, notifications follow audit projection, and neither occurs for rejected operations or zero-row results. Do not claim exactly-once delivery across independent client retries without durable idempotency/outbox support. - -Run at minimum: - -```bash -bunx vitest run -bunx biome check -bunx turbo run type-check --filter=sim --filter=@sim/auth -bun run check:api-validation:strict -git diff --check -``` - -Do not claim a check passed unless it was run successfully. - -## Work safely in parallel - -- Assign one agent a non-overlapping route-module and caller set. Two methods in one route file are not safe parallel assignments. -- Start every agent from the same foundation commit in its own branch/worktree when possible. -- Treat the file `operations.ts` registry, contract family files, and file route policies as merge hotspots. The core application primitives and shared route builders should not change for an ordinary file-operation migration. -- Do not refactor shared principal, authorization, rate limiting, or error foundations unless the task explicitly assigns that ownership. -- Preserve unrelated working-tree changes. Never stage architecture docs, lockfile drift, or another agent's edits. -- Do not commit, push, or open a PR unless the task explicitly asks for it. - -## Hand off - -Report: - -1. The semantic operation and policy. -2. Every migrated, deferred, and non-goal entry point. -3. Behavior preserved per surface, including analytics and response differences. -4. Files changed and any shared merge hotspots. -5. Tests and checks run with results. -6. Remaining risks or blockers. Fail fast if any required invariant could not be implemented. diff --git a/.cursor/commands/migrate-application-operation.md b/.cursor/commands/migrate-application-operation.md new file mode 100644 index 00000000000..f8c67ba42cd --- /dev/null +++ b/.cursor/commands/migrate-application-operation.md @@ -0,0 +1,286 @@ +# Migrate Application Operation + +Migrate one bounded semantic operation at a time. Share authorization and business behavior without forcing internal APIs, public APIs, Copilot, and other tools to share authentication, input schemas, or response shapes. + +## Enforce the application boundary + +Apply this invariant: + +> Every real operation on persisted or protected data enters through an authorized application use case. + +This includes mutations, content and metadata reads, canonical resource lookup, and reference-to-resource resolution when the lookup is authorization-sensitive. + +Surface helpers may: + +- Normalize an already-authenticated surface context into a `Principal`. +- Translate aliases or wire arguments into application input. +- Select a code-defined operation and application use case. +- Call the application use case. +- Translate typed results and errors into the surface contract. + +Surface helpers must not: + +- Query databases or storage. +- Decide workspace or resource authorization. +- Implement business transactions. +- Record semantic audit or shared domain notifications. +- Infer authoritative identity, workspace, audience, or scope from untrusted arguments. +- Substitute billing attribution for identity. + +A helper that resolves a path is valid only when the actual protected lookup runs through an authorized application resolver. If a helper begins doing real data work, move that work into an application use case. + +## Read the foundation first + +Read these files completely before editing: + +- `packages/auth/src/principal.ts` +- `apps/sim/lib/core/application/operation.ts` +- `apps/sim/lib/core/application/workspace-operation.ts` +- `apps/sim/lib/core/application/workspace-authorization.ts` +- `apps/sim/lib/core/application/authorized-workspace-use-case.ts` +- `apps/sim/lib/api/server/routes/internal-json-route.ts` +- `apps/sim/lib/api/server/routes/v2-json-route.ts` + +Use the file domain only as a representative golden slice: + +- `apps/sim/lib/workspace-files/application/operations.ts` +- `apps/sim/lib/workspace-files/application/authorized-workspace-file-use-case.ts` +- `apps/sim/lib/workspace-files/application/rename-workspace-file.ts` +- `apps/sim/lib/copilot/application/execute-file-use-case.ts` +- `apps/sim/lib/copilot/auth/file-delegation.ts` + +Then read the target domain's operation registry, application code, repositories, contracts, adapters, aliases, resume paths, and focused tests. Fail immediately if the shared foundation is absent. Do not recreate it inside the domain. + +## Bound the migration + +Inventory every entry point for the behavior before editing: + +- Internal HTTP routes and contracts. +- Public or versioned API routes and contracts. +- Copilot tools, aliases, resume paths, and polymorphic branches. +- Other tool servers, workflow executors, jobs, or service callers. +- Current authentication, authorization, workspace assertions, and concealment. +- Manager or orchestration call chains. +- Audit, notification, analytics, and billing side effects. +- Error/status/result behavior. +- Rate-limit identity, rollout gates, quota, and concurrency admission. + +Classify each as `migrate`, `defer`, or `non-goal`. Do not migrate adjacent operations merely because they share a module. Do not modify v1 unless the request explicitly includes it. + +Preserve behavior unless the task explicitly changes it. Stop and report a decision when surfaces currently disagree on security or compatibility behavior; do not silently choose one. + +## Keep the layers distinct + +Use these responsibilities: + +1. Authentication adapter: verify the surface credential or trusted execution context and construct a `Principal`. +2. Route or tool adapter: select rate policy, parse its contract, translate input, call the application use case, and render its own result. +3. Application use case: load canonical context, compare asserted scope, authorize the semantic operation, execute business behavior, project semantic audit, and trigger shared domain effects. +4. Manager or repository: perform database and storage reads or writes using canonical identifiers and scope. Never accept credentials or principals. +5. Presenter: return only the surface success body or typed binary descriptor. Never construct auth, rate, or error behavior. + +For ordinary public JSON routes, preserve this order: + +```text +IP abuse limit + -> authenticate + -> build Principal + -> operation rate limit + -> parse surface contract + -> application use case + -> canonical load + -> asserted-scope concealment + -> current authorization + -> manager read or mutation + -> semantic audit + -> shared domain effects + -> surface presenter +``` + +Internal routes may omit the IP bucket or operation limit only through an explicit policy with a reason. Usage billing, storage quota, cost admission, and concurrency are separate from request-rate limiting. + +Never query API keys or sessions from the application layer. Never add fallback identity or authorization behavior. Propagate infrastructure failures instead of turning them into not-found or forbidden results. + +## Define the semantic operation once + +Add one stable entry to the target domain's operation registry: + +```ts +rename: defineWorkspaceOperation({ + id: 'widgets.rename', + minimumRole: 'write', + workspaceApiKey: 'allow', + principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], +}) +``` + +Do not create internal-, public-, or Copilot-specific versions of the same semantic operation. If two callers have materially different business or transactional semantics, define separate use cases and explain the distinction. + +Choose principal kinds from actual behavior. Do not accept every principal merely because the use case is shared. Workspace API keys have a write ceiling and cannot satisfy admin operations. The operation definition must fail fast when its role, workspace-key policy, and principal kinds disagree. + +Route declarations, tool adapters, and use cases must use the same literal operation. Runtime operation selection is permitted only from a trusted, code-defined registry. Never accept an operation ID or permission tag from an HTTP body, model argument, or other untrusted input. + +## Implement the application use case + +Use `defineAuthorizedWorkspaceUseCase` directly or a thin domain binding that supplies domain-specific authorization options: + +```ts +export const renameWidget = defineAuthorizedWorkspaceUseCase({ + operation: widgetOperations.rename, + resolveContext: ({ input }: { input: RenameWidgetInput }) => + loadCanonicalWidgetContext(input.id, input.assertedWorkspaceId), + authorizationOptions: { delegation: widgetDelegationPolicy }, + execute: async ({ input, context }) => renameWidgetRecord({ + workspaceId: context.workspaceId, + widgetId: context.resourceId, + name: input.name, + }), + projectAudit: ({ result }) => ({ + action: AuditAction.WIDGET_UPDATED, + resourceType: AuditResourceType.WIDGET, + resourceId: result.id, + resourceName: result.name, + }), + afterSuccess: ({ context }) => notifyWidgetsChanged(context.workspaceId), +}) +``` + +Adapt the example to the domain's real authorization options; do not copy invented field names. + +The wrapper must own this lifecycle: + +1. Reject disallowed principal kinds before protected loading. +2. Load canonical context and conceal asserted-scope mismatches as required. +3. Authorize the operation using current policy state. +4. Execute the manager or repository primitive. +5. Project audit from authoritative results. +6. Await shared post-success effects. + +Do not call shared authorization, principal audit attribution, or `recordAudit` manually from an ordinary migrated use-case body. Use `projectAudit` only when the operation has semantic audit. Return no audit entries for authoritative no-ops. Keep product analytics such as `captureServerEvent` surface-specific through the adapter's success hook. + +Inspect legacy orchestration before reusing it. If it already authorizes, audits, notifies, or captures analytics, call a lower-level primitive or remove duplicate responsibility for migrated callers. + +## Adapt internal APIs + +Use `defineInternalJsonRoute` for ordinary JSON routes. Explicitly declare the contract, session authentication policy, semantic operation, rate policy, error policy, input mapping, use case, and presenter when the wire result differs. + +The internal adapter owns session authentication and internal response envelopes. It must not implement workspace authorization. Preserve internal-only analytics through `onSuccess` after application success. + +Keep the route module declarative. If several internal routes repeat authentication, parsing, error rendering, or response construction, improve the shared internal route builder instead of adding a domain-specific route wrapper. + +## Adapt public or versioned APIs + +Use the appropriate public/versioned route builder, such as `defineV2JsonRoute`, with API-key authentication, explicit operation rate policy, rollout policy, external error projection, and an external presenter. + +Authentication and HTTP formatting may differ from internal APIs; authorization and business behavior must not. Rate-limit using the credential or principal subject, never a billed owner. Resolve billing attribution only for billing, quota, or legacy required-user fields. + +Keep surface contracts separate when their wire shapes differ. Reuse shared primitive schemas and domain validators for invariants such as IDs, names, bounds, and formats. Do not maintain duplicate internal and external schemas merely because the routes are separate; import the same schema when the wire shape is genuinely identical. Never cast one surface response into another. + +Keep v1 middleware and routes unchanged unless explicitly included. + +## Adapt Copilot + +Create one domain-level Copilot application adapter instead of constructing delegated principals in every tool: + +```ts +executeCopilotWidgetUseCase(context, renameWidget, input, { resourceId }) +``` + +That adapter must: + +- Require a trusted server-authored Copilot execution marker. +- Require the authenticated subject, canonical workspace, tool-call or execution identity, and required audience or lifecycle scope. +- Construct the shared delegated `Principal` in one place. +- Optionally bind the canonical resource scope after trusted resolution. +- Verify that the use case exposes a registered code-defined operation. +- Call the application use case directly. + +Never construct authoritative delegation from model-provided workspace IDs, user IDs, operation IDs, resource scope, or permission tags. Model arguments are requested targets only and must be checked against trusted execution context and canonical data. + +Tool handlers own argument aliases, resumable legacy names, abort checks, and tool-specific presentation. They must not query managers directly for protected operations or manually authorize. + +A Copilot reference helper may translate a path to a resource only by calling an authorized application resolver under the intended semantic operation. Passing a code-defined operation object is acceptable; passing a model-provided operation string is not. Reauthorizing during later execution is safe but redundant. When resolution and execution form one business operation, need a consistent snapshot, or appear repeatedly together, prefer a top-level application use case such as `renameWidgetByReference`. + +Special composition roots may resolve one principal and deliberately thread it through several application calls or lower-level admission stages. Keep this exceptional and explicit; ordinary tools should use the shared execution adapter. + +Map expected typed errors to safe tool results. Unknown errors must become generic system/retryable messages while retaining full causes in server logs. Never return raw database or storage errors to the model. + +## Adapt other internal or external tools + +Treat every tool runtime as a surface adapter: + +- Normalize its already-authenticated execution context into an existing `Principal` through one shared adapter for that runtime or domain. +- Call the same application use case used by HTTP and Copilot surfaces. +- Preserve the tool protocol's input, output, retry, and cancellation semantics. +- Keep authoritative workspace and subject scope server-authored. + +An internal caller is not automatically trusted to bypass authorization. It must supply an explicit principal or use a deliberately designed service/delegation principal. If the current principal model cannot express its authority, stop and extend the identity model intentionally; do not fall back to an owner, uploader, creator, or arbitrary user ID. + +External tool endpoints authenticate at their adapter exactly like public APIs. Do not authenticate again inside the application use case. + +## Preserve identity and attribution + +- Session and personal-key principals authorize through current human workspace permission. +- Personal API keys also respect the workspace's personal-key policy. +- Workspace keys authorize as the workspace under explicit operation policy and the write ceiling, independent of creator membership. +- Delegated principals re-check the current subject and their workspace, audience, expiry, execution, and resource scope. +- Billing owners are attribution for billing or legacy required columns only, never authorization, rate identity, delegated identity, audit actor, or human analytics identity. +- Preserve structured `PrincipalActor` metadata in semantic audit. + +If a required legacy user column cannot represent the real actor, label the compatibility attribution explicitly. Never pretend it is the acting human. + +## Handle special operations explicitly + +Do not force these through an ordinary JSON migration: + +- Upload or multipart lifecycles: bind immutable credential identity, reauthorize control legs and finalization, and make durable completion idempotent. +- Large bodies: authenticate and perform cheap admission before bounded buffering. +- Binary or streaming responses: use binary/stream builders and typed descriptors. +- Bulk or recursive operations: deduplicate and cap inputs and expansion, load all resources canonically, and define atomic versus best-effort behavior. +- Polymorphic tools: select the semantic operation only after trusted target-kind resolution; do not route unrelated branches through one domain registry. +- Multi-resource transactions: keep canonical scope predicates and derive audit from authoritative affected rows. + +Stop and report a missing design rather than weakening identity, authorization, limits, or errors. + +## Test the complete matrix + +Add focused tests for every migrated surface and principal kind allowed by the operation: + +- Application: allowed and disallowed roles, principal-kind rejection before canonical loading, workspace assertion mismatch, delegated scope, not found, conflict, no-op, and infrastructure propagation. +- Repository: canonical active lookup, workspace-predicated writes, archived resources, authoritative affected rows, and database error propagation. +- Internal API: authentication before parsing, exact contract, typed errors, and surface analytics only after success. +- Public API: personal and workspace keys, rate and rollout behavior, concealment, exact external envelope, and rate headers. +- Copilot or tools: trusted context, rejected forged scope, aliases and resume paths, permission re-check, safe errors, and unchanged tool result shapes. +- Side effects: audit derives from authoritative results; shared notifications follow audit; neither occurs for rejection or no-op. + +Run at minimum: + +```bash +bunx vitest run +bunx biome check +bunx turbo run type-check --filter=sim --filter=@sim/auth +bun run check:api-validation:strict +git diff --check +``` + +Do not claim a check passed unless it completed successfully. + +## Work safely in parallel + +- Assign non-overlapping route modules and caller sets. Two methods in one route file are one ownership unit. +- Treat operation registries, contract families, route policies, and shared surface adapters as merge hotspots. +- Keep shared core foundations owned by one task; ordinary domain migrations should consume them without modifying them. +- Preserve unrelated working-tree changes. Never stage proposal docs, lockfile drift, or another agent's edits. +- Do not commit, push, or open a PR unless requested. + +## Hand off + +Report: + +1. Semantic operation, role, workspace-key policy, and principal kinds. +2. Migrated, deferred, and non-goal entry points. +3. Behavior preserved per internal, public, Copilot, and other tool surface. +4. Identity construction and authoritative scope source for each surface. +5. Files changed and shared merge hotspots. +6. Tests and checks run with results. +7. Remaining risks or blockers. Fail fast when an invariant could not be implemented. diff --git a/.cursor/commands/migrate-file-operation.md b/.cursor/commands/migrate-file-operation.md deleted file mode 100644 index a855777a635..00000000000 --- a/.cursor/commands/migrate-file-operation.md +++ /dev/null @@ -1,226 +0,0 @@ -# Migrate File Operation - -Migrate one bounded semantic operation at a time. Share authorization and business behavior without forcing internal, v2, and Copilot surfaces to share authentication or response shapes. - -## Start from the golden slice - -Read these files before editing: - -- `packages/auth/src/principal.ts` -- `apps/sim/lib/core/application/operation.ts` -- `apps/sim/lib/core/application/workspace-operation.ts` -- `apps/sim/lib/core/application/workspace-authorization.ts` -- `apps/sim/lib/workspace-files/application/operations.ts` -- `apps/sim/lib/workspace-files/application/authorization.ts` -- `apps/sim/lib/workspace-files/application/rename-workspace-file.ts` -- `apps/sim/lib/api/server/routes/internal-json-route.ts` -- `apps/sim/lib/api/server/routes/v2-json-route.ts` -- `apps/sim/lib/workspace-files/api/route-policies.ts` -- `apps/sim/lib/copilot/auth/file-delegation.ts` -- The internal, v2, manager, contract, Copilot, and test files for the requested operation - -Fail immediately if the shared principal or route foundation is absent. Do not recreate a parallel framework. - -## Bound the slice - -Before editing, inventory every current entry point for the requested behavior: - -- HTTP method and route module -- Internal and v2 contracts and response envelopes -- Manager/orchestration call chain -- Copilot tools, aliases, resume paths, and polymorphic branches -- Permission checks and workspace assertions -- Audit, notification, product analytics, and other side effects -- Error/status behavior and resource-concealment behavior -- Rate-bucket identity, endpoint label, response headers, and rollout-gate behavior - -Record each entry point as `migrate`, `defer`, or `non-goal` in the handoff. Do not modify v1. Do not migrate adjacent methods merely because they share a route module. - -Preserve existing behavior unless the assigned migration explicitly changes it. If the target architecture conflicts with a current contract or test—such as 403 versus concealed 404, a legacy rate-bucket label, or a surface-specific event—stop and report the decision instead of silently choosing one. - -Use one semantic operation for all migrated surfaces. Never create internal-, v2-, or Copilot-specific copies of the same operation. If two paths have materially different transactional semantics, keep separate use cases and explain why. - -## Preserve the boundary - -Keep responsibilities in these layers: - -1. Route/tool adapter: authenticate, construct a `Principal`, select the operation and rate policy, parse the surface contract, call the use case, and render the surface response. -2. Application use case: load the canonical resource, compare any asserted workspace, authorize the operation, run the business operation, record semantic audit, and trigger shared domain notifications. -3. Manager/repository: execute storage and database reads/writes. Accept canonical IDs and workspace scope, not credentials or principals. -4. Presenter: return the contract success body only. Omit it when the use-case result already matches the contract. Never return `NextResponse`, rate metadata, or errors. - -Use this order for ordinary JSON routes: - -```text -IP abuse limit, when public - -> authenticate - -> build Principal - -> operation rate limit - -> parse contract - -> application use case - -> canonical load by resource ID - -> asserted-workspace concealment - -> current authorization - -> manager mutation/read - -> audit and shared notification - -> surface presenter -``` - -Never query API keys or sessions from the application use case. Authentication freshness belongs to the adapter. Never implement raw database transactions in the application use case when a manager primitive exists; add a narrow throwing manager primitive when needed. - -Do not add API-key rechecks, row locks, or transaction-wide authorization solely for a theoretical race. Use the authenticated principal and canonical resource snapshot unless the business operation itself requires atomic multi-row behavior or the task explicitly asks for stronger race semantics. - -## Define the operation once - -Add exactly one entry to `fileOperations` with a stable semantic ID, minimum workspace role, explicit workspace-key policy, and accepted principal kinds. - -```ts -operationName: defineWorkspaceOperation({ - id: 'files.operation_name', - minimumRole: 'read' satisfies PermissionType, - workspaceApiKey: 'allow', - principalKinds: ['session', 'personal_api_key', 'workspace_api_key', 'delegated'], -}) -``` - -Do not accept every principal merely because a use case is shared. An internal-only operation can declare `['session']`; a shared internal/Copilot operation can declare `['session', 'delegated']`. The definition fails immediately when `principalKinds` and the workspace-key policy disagree. The authorized-use-case wrapper rejects disallowed kinds before canonical loading and narrows the business callback's principal type. - -Choose policy from behavior, not from the calling surface: - -- Reads normally require `read`. -- Content or metadata mutations normally require `write`. -- Workspace API keys have a fixed `write` ceiling and cannot satisfy admin operations. -- Use `deny` when a workspace key must not perform the operation. - -The route declaration and use case must expose the same literal operation. Let route-definition initialization fail on mismatches. - -## Implement the application use case - -Define the use case with `defineAuthorizedWorkspaceFileUseCase`, the file-domain binding over the resource-agnostic application foundation. - -```ts -export const operationWorkspaceFile = defineAuthorizedWorkspaceFileUseCase({ - operation: fileOperations.operationName, - resolveContext: ({ input }: { input: OperationWorkspaceFileInput }) => - loadCanonicalOperationContext(input), - execute: executeOperationWorkspaceFile, - projectAudit: ({ result }) => ({ - action: AuditAction.FILE_UPDATED, - resourceType: AuditResourceType.FILE, - resourceId: result.file.id, - resourceName: result.file.name, - }), - afterSuccess: ({ context }) => notifyWorkspaceFilesChanged(context.workspaceId), -}) -``` - -The wrapper owns this lifecycle: - -1. Reject principal kinds not declared by the operation. -2. Resolve the canonical context and authorize it with file delegation scope. -3. Execute the business callback. -4. Resolve audit attribution and record projected semantic entries. -5. Await declared post-success domain effects. - -`resolveContext` loads canonical resources, derives workspace policy state, and conceals asserted-workspace mismatches. It does not authorize. `execute` receives an already-authorized, operation-narrowed principal and calls manager/repository primitives. `projectAudit` derives zero, one, or several entries from the authoritative mutation result; return `[]` for a no-op and never infer affected resources from requested IDs. The wrapper injects workspace, operation, actor, and request metadata. `afterSuccess` owns shared notifications. - -Do not call `authorizeWorkspaceFileAccess`, `authorizeWorkspaceFileOperation`, `resolvePrincipalAuditAttribution`, or `recordAudit` from an ordinary migrated use-case body. Resolve `PrincipalAttribution` only when a required legacy user field needs it. Upload lifecycle code and other explicitly deferred special cases may retain their dedicated flow until separately migrated. - -Propagate infrastructure failures. Use existing typed orchestration errors for expected not-found, forbidden, validation, and conflict outcomes. Do not turn database failures into not-found or authorization failures, and never add fallback behavior. - -Inspect legacy orchestration before reusing it. If it already records audit, sends notifications, or emits analytics, call a lower-level manager primitive or remove the duplicated responsibility for migrated callers. Never wrap a side-effecting orchestration helper and then emit the same side effects again. - -### Principal and attribution rules - -- Session and personal-key principals authorize through the current human workspace permission. -- Personal API keys must also respect the workspace's `allowPersonalApiKeys` setting. -- Workspace-key principals authorize as the workspace itself under the operation's explicit allow/deny policy and write ceiling. Do not use creator membership for authorization. -- Delegated Copilot principals re-check the current subject user's permission and their workspace, audience, expiry, and resource scope. -- Use the current billing owner only where a legacy required user ID or billing attribution requires one. Never use that owner for authorization, rate-limit identity, delegated identity, or human product analytics. -- Preserve `PrincipalActor` metadata in audit records so a workspace key is not represented as a human actor. - -Audit is shared semantic behavior declared through `projectAudit`; the wrapper records it after successful execution using the real `PrincipalActor`. Product analytics are surface behavior: preserve an existing internal `captureServerEvent` with the internal route's optional `onSuccess`; do not move it into the shared use case or manufacture a human distinct ID for workspace keys or Copilot. - -## Adapt each surface independently - -### Internal JSON - -Use `defineInternalJsonRoute` and explicitly declare: - -- `contract` -- `auth: internalSessionAuth` -- `operation` -- `rateLimit`, including a reason for `none` -- `errorPolicy` -- `mapInput` -- `useCase` -- optional `onSuccess` only to preserve surface-specific behavior -- `present` when the surface wire body differs from the use-case result - -Keep the existing internal success and error envelope. Error policies project typed status/body descriptors rather than constructing responses. The builder owns `withRouteHandler`, authentication-before-parse ordering, response validation, and `NextResponse` creation. - -### V2 JSON - -Use `defineV2JsonRoute` with `v2ApiKeyAuth`, the explicit operation rate policy, the v2 error policy, and a v2-only presenter. Keep v2 schemas in `apps/sim/lib/api/contracts/v2/files.ts` and internal schemas in the workspace-file contract family. Never import Zod or declare boundary schemas in a route. - -For resource-ID operations, the target v2 policy normally conceals absence, cross-workspace assertions, and unauthorized access. If the current contract or tests expose 403, flag that compatibility decision before changing it. Keep v1 middleware and v1 routes untouched. - -### Copilot - -Resolve path/name aliases only within the trusted execution workspace, then call the same application use case with `createCopilotFilePrincipal`. Never construct authoritative workspace, subject, audience, or resource scope from model-provided tool arguments. Preserve each tool's existing result shape and resumable aliases. - -Map typed failures to a safe Copilot message. Unknown failures must become a generic retryable/system message; never return raw database or storage error messages to the model. - -## Handle special operations explicitly - -Do not force these through the ordinary JSON pipeline: - -- Upload/multipart: use a lifecycle-specific design with immutable credential binding, fresh authorization on control legs and finalization, current billing attribution at durable registration, and idempotent completion. Do not add a database migration unless explicitly authorized; under the no-migration design, store a versioned server-authored `authBinding` in existing upload-session metadata, reject missing or invalid new-format bindings, and explicitly drain or version legacy in-flight sessions. -- Large JSON bodies: perform cheap authenticated admission before bounded body buffering. -- Binary/download/streaming: use a binary or streaming route builder and a typed response descriptor. -- Bulk/recursive operations: deduplicate and cap requested IDs, cap expanded rows/bytes, load every resource canonically, preserve workspace predicates, and decide atomic versus best-effort behavior explicitly. - -Stop and report the missing design instead of weakening limits, authorization, or error handling. - -## Test the complete behavior matrix - -Add focused tests proportional to the migrated surfaces: - -- Use case: session admin/writer/reader, personal key enabled/disabled, allowed/wrong-workspace workspace key, delegated current/stale/out-of-scope principal, asserted-workspace mismatch, not found, conflict, and infrastructure failure. -- Manager: canonical active lookup, workspace-predicated mutation, archived resources, and propagated database errors. -- Internal route: authentication before parsing, exact response contract, typed errors, and any preserved `onSuccess` event only after success. -- V2 route: personal and workspace keys, rollout/rate behavior, concealment, exact v2 envelope, and rate headers after charging. -- Copilot: trusted delegation, aliases/resume paths, permission re-check, safe errors, and unchanged tool response shape. -- Side effects: audit is projected from authoritative results, notifications follow audit projection, and neither occurs for rejected operations or zero-row results. Do not claim exactly-once delivery across independent client retries without durable idempotency/outbox support. - -Run at minimum: - -```bash -bunx vitest run -bunx biome check -bunx turbo run type-check --filter=sim --filter=@sim/auth -bun run check:api-validation:strict -git diff --check -``` - -Do not claim a check passed unless it was run successfully. - -## Work safely in parallel - -- Assign one agent a non-overlapping route-module and caller set. Two methods in one route file are not safe parallel assignments. -- Start every agent from the same foundation commit in its own branch/worktree when possible. -- Treat the file `operations.ts` registry, contract family files, and file route policies as merge hotspots. The core application primitives and shared route builders should not change for an ordinary file-operation migration. -- Do not refactor shared principal, authorization, rate limiting, or error foundations unless the task explicitly assigns that ownership. -- Preserve unrelated working-tree changes. Never stage architecture docs, lockfile drift, or another agent's edits. -- Do not commit, push, or open a PR unless the task explicitly asks for it. - -## Hand off - -Report: - -1. The semantic operation and policy. -2. Every migrated, deferred, and non-goal entry point. -3. Behavior preserved per surface, including analytics and response differences. -4. Files changed and any shared merge hotspots. -5. Tests and checks run with results. -6. Remaining risks or blockers. Fail fast if any required invariant could not be implemented.