diff --git a/apps/docs/content/docs/cli/files.mdx b/apps/docs/content/docs/cli/files.mdx index 1e7aa1588e2..402b92fe5ab 100644 --- a/apps/docs/content/docs/cli/files.mdx +++ b/apps/docs/content/docs/cli/files.mdx @@ -102,7 +102,7 @@ sim files describe [options] | Argument | Required | Description | | --- | --- | --- | -| `fileId` | Yes | File identifier, or the file’s VFS path: `files//`. | +| `fileId` | Yes | File identifier, or the VFS path of a live file: `files//`. Pass the id when two files in a folder have names that display alike. | @@ -303,7 +303,7 @@ sim files rename [options] | Argument | Required | Description | | --- | --- | --- | -| `fileId` | Yes | File identifier, or the file’s VFS path: `files//`. | +| `fileId` | Yes | File identifier, or the VFS path of a live file: `files//`. Pass the id when two files in a folder have names that display alike. | @@ -613,7 +613,7 @@ sim files delete [options] | Argument | Required | Description | | --- | --- | --- | -| `fileId` | Yes | File identifier, or the file’s VFS path: `files//`. | +| `fileId` | Yes | File identifier, or the VFS path of a live file: `files//`. Pass the id when two files in a folder have names that display alike. | diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx index 8dae294ffbb..171810846f2 100644 --- a/apps/docs/content/docs/cli/reference.mdx +++ b/apps/docs/content/docs/cli/reference.mdx @@ -1181,7 +1181,7 @@ sim files delete [options] | Argument | Required | Description | | --- | --- | --- | -| `fileId` | Yes | File identifier, or the file’s VFS path: `files//`. | +| `fileId` | Yes | File identifier, or the VFS path of a live file: `files//`. Pass the id when two files in a folder have names that display alike. | @@ -1426,7 +1426,7 @@ sim files describe [options] | Argument | Required | Description | | --- | --- | --- | -| `fileId` | Yes | File identifier, or the file’s VFS path: `files//`. | +| `fileId` | Yes | File identifier, or the VFS path of a live file: `files//`. Pass the id when two files in a folder have names that display alike. | @@ -1591,7 +1591,7 @@ sim files rename [options] | Argument | Required | Description | | --- | --- | --- | -| `fileId` | Yes | File identifier, or the file’s VFS path: `files//`. | +| `fileId` | Yes | File identifier, or the VFS path of a live file: `files//`. Pass the id when two files in a folder have names that display alike. | diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index 85d2765c10a..dab0fa456b1 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -2151,12 +2151,12 @@ "name": "fileId", "in": "path", "required": true, - "description": "File identifier, or the file’s VFS path: `files//`.", + "description": "File identifier, or the VFS path of a live file: `files//`. Pass the id when two files in a folder have names that display alike.", "schema": { "type": "string", "minLength": 1, "maxLength": 4096, - "description": "File identifier, or the file’s VFS path: `files//`." + "description": "File identifier, or the VFS path of a live file: `files//`. Pass the id when two files in a folder have names that display alike." } }, { @@ -2237,12 +2237,12 @@ "name": "fileId", "in": "path", "required": true, - "description": "File identifier, or the file’s VFS path: `files//`.", + "description": "File identifier, or the VFS path of a live file: `files//`. Pass the id when two files in a folder have names that display alike.", "schema": { "type": "string", "minLength": 1, "maxLength": 4096, - "description": "File identifier, or the file’s VFS path: `files//`." + "description": "File identifier, or the VFS path of a live file: `files//`. Pass the id when two files in a folder have names that display alike." } } ], @@ -2487,12 +2487,12 @@ "name": "fileId", "in": "path", "required": true, - "description": "File identifier, or the file’s VFS path: `files//`.", + "description": "File identifier, or the VFS path of a live file: `files//`. Pass the id when two files in a folder have names that display alike.", "schema": { "type": "string", "minLength": 1, "maxLength": 4096, - "description": "File identifier, or the file’s VFS path: `files//`." + "description": "File identifier, or the VFS path of a live file: `files//`. Pass the id when two files in a folder have names that display alike." } }, { 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 697796ca2ce..80a3a0cb24c 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 @@ -113,7 +113,12 @@ describe('GET /api/v2/files/[fileId]/metadata', () => { expect((await response.json()).error.code).toBe('NOT_FOUND') expect(readMetadata).toHaveBeenCalledWith( expect.objectContaining({ - input: { fileId: FILE_ID, assertedWorkspaceId: WORKSPACE_ID, includeDeleted: false }, + input: { + fileId: FILE_ID, + acceptVfsPath: true, + assertedWorkspaceId: WORKSPACE_ID, + includeDeleted: false, + }, }) ) }) @@ -127,7 +132,12 @@ describe('GET /api/v2/files/[fileId]/metadata', () => { expect((await response.json()).error.code).toBe('NOT_FOUND') expect(readMetadata).toHaveBeenCalledWith( expect.objectContaining({ - input: { fileId: FILE_ID, assertedWorkspaceId: WORKSPACE_ID, includeDeleted: false }, + input: { + fileId: FILE_ID, + acceptVfsPath: true, + assertedWorkspaceId: WORKSPACE_ID, + includeDeleted: false, + }, }) ) }) @@ -159,7 +169,12 @@ describe('GET /api/v2/files/[fileId]/metadata', () => { expect(readMetadata).toHaveBeenCalledWith( expect.objectContaining({ principal: auth.principal, - input: { fileId: FILE_ID, assertedWorkspaceId: WORKSPACE_ID, includeDeleted: true }, + input: { + fileId: FILE_ID, + acceptVfsPath: true, + assertedWorkspaceId: WORKSPACE_ID, + includeDeleted: true, + }, }) ) }) 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 02a57449037..d7d018d7b48 100644 --- a/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts @@ -26,6 +26,7 @@ export const GET = defineV2JsonRoute({ errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, mapInput: ({ params, query }) => ({ fileId: params.fileId, + acceptVfsPath: true, assertedWorkspaceId: query.workspaceId, includeDeleted: query.scope === 'archived', }), diff --git a/apps/sim/app/api/v2/files/[fileId]/route.ts b/apps/sim/app/api/v2/files/[fileId]/route.ts index 983f1c7b082..8cec04f28f5 100644 --- a/apps/sim/app/api/v2/files/[fileId]/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/route.ts @@ -63,6 +63,7 @@ export const PATCH = defineV2JsonRoute({ errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, mapInput: ({ params, body }) => ({ fileId: params.fileId, + acceptVfsPath: true, assertedWorkspaceId: body.workspaceId, name: body.name, }), @@ -85,6 +86,7 @@ export const DELETE = defineV2JsonRoute({ errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, mapInput: ({ params, query }) => ({ fileId: params.fileId, + acceptVfsPath: true, assertedWorkspaceId: query.workspaceId, }), useCase: deleteWorkspaceFileOperation, diff --git a/apps/sim/lib/api/contracts/v2/files.ts b/apps/sim/lib/api/contracts/v2/files.ts index 326864468d3..364b6ba9633 100644 --- a/apps/sim/lib/api/contracts/v2/files.ts +++ b/apps/sim/lib/api/contracts/v2/files.ts @@ -311,7 +311,9 @@ const v2FileIdOrPathParamsSchema = z.object({ (value) => isWorkspaceFileVfsPath(value) || workspaceFileIdSchema.safeParse(value).success, 'Invalid file id or path: pass a file id or its `files//` VFS path' ) - .describe('File identifier, or the file’s VFS path: `files//`.'), + .describe( + 'File identifier, or the VFS path of a live file: `files//`. Pass the id when two files in a folder have names that display alike.' + ), }) export const v2CreateFileBodySchema = z diff --git a/apps/sim/lib/uploads/contexts/workspace/file-manager.ts b/apps/sim/lib/uploads/contexts/workspace/file-manager.ts index c905e6d3a6e..c0b6c9078f4 100644 --- a/apps/sim/lib/uploads/contexts/workspace/file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/file-manager.ts @@ -2342,40 +2342,6 @@ async function getWorkspaceFileByExactReference( return folderId ? getWorkspaceFileByName(workspaceId, segments.at(-1) ?? '', { folderId }) : null } -/** - * The live workspace file whose canonical `files/…` path — as listings print it — is `vfsPath`, - * or null. Unlike {@link findWorkspaceFileRecord} there is no virtual `/content` or `/meta.json` - * suffix and no leaf-name fallback, so a mutation addressed by path lands only on the file it - * names. Two names that display alike are ambiguous and refused. Throws `VfsPathError` if - * malformed. - */ -export async function getWorkspaceFileByVfsPath( - workspaceId: string, - vfsPath: string -): Promise { - const segments = decodeVfsReferenceSegments(vfsPath.slice('files/'.length)) - const leaf = segments.at(-1) - if (leaf === undefined) return null - const pathKey = segments.map(normalizeVfsSegment).join('/') - const candidates = await listWorkspaceFileCandidates(workspaceId, { - ids: [], - leafNames: [normalizeDisplaySegment(leaf)], - }) - const matches = candidates.filter( - (file) => - canonicalWorkspaceFilePath({ folderPath: file.folderPath, name: file.name }).slice( - 'files/'.length - ) === pathKey - ) - if (matches.length > 1) { - throw new OrchestrationError( - 'validation', - `"${vfsPath}" names more than one file whose names display alike; pass the file id instead` - ) - } - return matches[0] ?? null -} - /** * Resolve a workspace file record from either its id or a VFS/name reference. * diff --git a/apps/sim/lib/vfs/path.ts b/apps/sim/lib/vfs/path.ts index a77037db694..b7a9cb5e186 100644 --- a/apps/sim/lib/vfs/path.ts +++ b/apps/sim/lib/vfs/path.ts @@ -10,9 +10,12 @@ export class VfsPathError extends Error { } } -/** A `files/…` workspace-file VFS path. File ids are drawn from `[A-Za-z0-9_-]`, so no id is one. */ +/** + * A `files/…` workspace-file VFS path naming something under the prefix. File ids are drawn from + * `[A-Za-z0-9_-]`, so no id is one. + */ export function isWorkspaceFileVfsPath(reference: string): boolean { - return reference.startsWith('files/') + return reference.startsWith('files/') && reference.length > 'files/'.length } /** diff --git a/apps/sim/lib/workspace-files/__integration__/file-path-references.integration.ts b/apps/sim/lib/workspace-files/__integration__/file-path-references.integration.ts index d5516584d40..cdbcac85ec9 100644 --- a/apps/sim/lib/workspace-files/__integration__/file-path-references.integration.ts +++ b/apps/sim/lib/workspace-files/__integration__/file-path-references.integration.ts @@ -11,6 +11,7 @@ import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/ import { generateId } from '@sim/utils/id' import { eq, inArray, sql } from 'drizzle-orm' import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' import { workspaceFileVfsPath } from '@/lib/uploads/contexts/workspace/file-manager' import { deleteWorkspaceFileOperation } from '@/lib/workspace-files/application/delete-workspace-file' import { readWorkspaceFileMetadataWithVersion } from '@/lib/workspace-files/application/read-workspace-file-metadata' @@ -21,6 +22,7 @@ const bob = generateId() const carol = generateId() const workspaceId = generateId() const otherWorkspaceId = generateId() +const overLimitWorkspaceId = generateId() const folders = { docs: generateId(), media: generateId(), foreign: generateId() } const session = (userId: string) => ({ kind: 'session' as const, userId, sessionId: generateId() }) @@ -53,13 +55,13 @@ async function row(id: string) { const describeFile = (fileId: string, userId = alice, assertedWorkspaceId = workspaceId) => readWorkspaceFileMetadataWithVersion.execute({ principal: session(userId), - input: { fileId, assertedWorkspaceId }, + input: { fileId, acceptVfsPath: true, assertedWorkspaceId }, }) const deleteFile = (fileId: string, userId = alice) => deleteWorkspaceFileOperation.execute({ principal: session(userId), - input: { fileId, assertedWorkspaceId: workspaceId }, + input: { fileId, acceptVfsPath: true, assertedWorkspaceId: workspaceId }, }) /** The `{ code, message }` each path is refused with, or null where it resolved. */ @@ -70,7 +72,10 @@ const refusalsFor = ( Promise.all( paths.map((fileId) => readWorkspaceFileMetadataWithVersion - .execute({ principal, input: { fileId, assertedWorkspaceId: workspaceId } }) + .execute({ + principal, + input: { fileId, acceptVfsPath: true, assertedWorkspaceId: workspaceId }, + }) .then( () => null, (error: { code?: string; message?: string }) => ({ @@ -160,7 +165,10 @@ describe('single-file operations addressed by VFS path', () => { }) afterAll(async () => { - await deleteWorkspaceFixture(db, inArray(workspace.id, [workspaceId, otherWorkspaceId])) + await deleteWorkspaceFixture( + db, + inArray(workspace.id, [workspaceId, otherWorkspaceId, overLimitWorkspaceId]) + ) await db.delete(user).where(inArray(user.id, [alice, bob, carol])) }) @@ -171,6 +179,7 @@ describe('single-file operations addressed by VFS path', () => { code: 'not_found', }) await expect(describeFile('files/Docs')).rejects.toMatchObject({ code: 'not_found' }) + await expect(describeFile('files/')).rejects.toMatchObject({ code: 'not_found' }) await expect(describeFile('files/only-here.csv')).rejects.toMatchObject({ code: 'not_found', }) @@ -203,7 +212,12 @@ describe('single-file operations addressed by VFS path', () => { it('renames and deletes only the file the path names', async () => { const renamed = await renameWorkspaceFile.execute({ principal: session(alice), - input: { fileId: 'files/Docs/report.csv', assertedWorkspaceId: workspaceId, name: 'q3.csv' }, + input: { + fileId: 'files/Docs/report.csv', + acceptVfsPath: true, + assertedWorkspaceId: workspaceId, + name: 'q3.csv', + }, }) expect(renamed.file.id).toBe(ids.docsReport) expect((await row(ids.docsReport))?.name).toBe('q3.csv') @@ -226,7 +240,7 @@ describe('single-file operations addressed by VFS path', () => { await expect( readWorkspaceFileMetadataWithVersion.execute({ principal: session(alice), - input: { fileId: 'files/report.csv' }, + input: { fileId: 'files/report.csv', acceptVfsPath: true }, }) ).rejects.toMatchObject({ code: 'not_found' }) }) @@ -274,7 +288,11 @@ describe('single-file operations addressed by VFS path', () => { await expect( readWorkspaceFileMetadataWithVersion.execute({ principal: delegated, - input: { fileId: 'files/report.csv', assertedWorkspaceId: workspaceId }, + input: { + fileId: 'files/report.csv', + acceptVfsPath: true, + assertedWorkspaceId: workspaceId, + }, }) ).resolves.toMatchObject({ file: { id: ids.rootReport } }) const outcomes = await refusalsFor(delegated, [ @@ -287,6 +305,68 @@ describe('single-file operations addressed by VFS path', () => { expect(outcomes[2]).toEqual(outcomes[0]) }) + it('answers a non-member personal key the same for every path where the workspace disables keys', async () => { + await db + .update(workspace) + .set({ allowPersonalApiKeys: false }) + .where(eq(workspace.id, workspaceId)) + try { + const outcomes = await refusalsFor( + { kind: 'personal_api_key', userId: carol, keyId: generateId() }, + [await ambiguousPath(), 'files/missing.csv', 'files/report.csv'] + ) + expect(outcomes[0]).not.toBeNull() + expect(outcomes[1]).toEqual(outcomes[0]) + expect(outcomes[2]).toEqual(outcomes[0]) + } finally { + await db + .update(workspace) + .set({ allowPersonalApiKeys: true }) + .where(eq(workspace.id, workspaceId)) + } + }) + + it('answers a non-member the same for a workspace whose folder tree is too large to read', async () => { + await insertWorkspaceFixture(db, [ + { id: overLimitWorkspaceId, name: 'Over limit', ownerId: alice, billedAccountUserId: alice }, + ]) + await db.insert(permissions).values({ + id: generateId(), + userId: alice, + entityType: 'workspace', + entityId: overLimitWorkspaceId, + permissionType: 'admin', + }) + await db.execute(sql` + INSERT INTO folder (id, name, user_id, workspace_id, resource_type, parent_id) + SELECT ${generateId()} || '-' || g, 'Folder ' || g, ${alice}, ${overLimitWorkspaceId}, 'file', NULL + FROM generate_series(1, ${MAX_FOLDERS_PER_WORKSPACE + 1}) AS g`) + const answer = (userId: string, fileId: string) => + describeFile(fileId, userId, overLimitWorkspaceId).then( + () => null, + (error: { code?: string; message?: string }) => ({ + code: error.code, + message: error.message, + }) + ) + const nested = await answer(carol, 'files/Folder 1/report.csv') + expect(nested).toMatchObject({ code: 'forbidden' }) + expect(await answer(carol, 'files/missing.csv')).toEqual(nested) + expect(await answer(alice, 'files/Folder 1/report.csv')).toMatchObject({ + code: 'payload_too_large', + }) + }) + + it('resolves no path for a caller that did not opt in', async () => { + await expect( + deleteWorkspaceFileOperation.execute({ + principal: session(alice), + input: { fileId: 'files/report.csv', assertedWorkspaceId: workspaceId }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + expect((await row(ids.rootReport))?.deletedAt).toBeNull() + }) + it('rejects a dot-segment path instead of walking out of its folder', async () => { await expect(deleteFile('files/Docs/../report.csv')).rejects.toMatchObject({ code: 'validation', 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 d32481db2b3..561c9f00401 100644 --- a/apps/sim/lib/workspace-files/application/delete-workspace-file.ts +++ b/apps/sim/lib/workspace-files/application/delete-workspace-file.ts @@ -7,15 +7,15 @@ import { deleteWorkspaceFile, } from '@/lib/uploads/contexts/workspace/file-manager' import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' -import { resolveActiveWorkspaceFileContext } from '@/lib/workspace-files/application/file-context' +import { + resolveActiveWorkspaceFileContext, + type WorkspaceFileTargetInput, +} from '@/lib/workspace-files/application/file-context' import { fileOperations } from '@/lib/workspace-files/application/operations' const logger = createLogger('DeleteWorkspaceFile') -export interface DeleteWorkspaceFileInput { - fileId: string - assertedWorkspaceId?: string -} +export type DeleteWorkspaceFileInput = WorkspaceFileTargetInput export interface DeleteWorkspaceFileResult { id: string diff --git a/apps/sim/lib/workspace-files/application/file-context.ts b/apps/sim/lib/workspace-files/application/file-context.ts index a05fb082c7a..0f2474d87ec 100644 --- a/apps/sim/lib/workspace-files/application/file-context.ts +++ b/apps/sim/lib/workspace-files/application/file-context.ts @@ -3,20 +3,29 @@ import { OrchestrationError } from '@/lib/core/orchestration/types' import type { DbOrTx } from '@/lib/db/types' import { type ActiveWorkspaceFileContext, - getWorkspaceFileByVfsPath, loadActiveWorkspaceFileContext, loadWorkspaceFileLifecycleContext, + resolveFileReference, type WorkspaceFileLifecycleContext, } from '@/lib/uploads/contexts/workspace/file-manager' -import { isWorkspaceFileVfsPath, VfsPathError } from '@/lib/vfs/path' +import { isWorkspaceFileVfsPath } from '@/lib/vfs/path' import { UnresolvedWorkspaceFileVfsPathError } from '@/lib/workspace-files/application/unresolved-vfs-path' import { ownedFileKind } from '@/lib/workspace-files/owned-files' import { matchesFileOwner } from '@/lib/workspace-files/ownership' -export interface WorkspaceFileContextInput { - /** A file id, or — with `assertedWorkspaceId` — the file's exact `files/…` VFS path. */ +/** How a use case names one workspace file: its id, or with `acceptVfsPath` its exact VFS path. */ +export interface WorkspaceFileTargetInput { fileId: string assertedWorkspaceId?: string + /** + * Also accept the file's exact `files/…` VFS path as `fileId`, resolved inside + * `assertedWorkspaceId` to a live workspace file. Only the public v2 surface sets it; every + * other caller passes ids, and a path-shaped string there stays an unknown id. + */ + acceptVfsPath?: boolean +} + +export interface WorkspaceFileContextInput extends WorkspaceFileTargetInput { includeDeleted?: boolean /** * Admit a chat upload (`context = 'mothership'`) addressed by its own id. Only read @@ -78,35 +87,40 @@ function requireWorkspaceFileContext( return canonical } -/** - * A malformed path is decided before any read and says nothing about the workspace, so it stays a - * plain validation error. The lookup's own refusal (two names that display alike) does not. - */ -function rethrowVfsLookupError(vfsPath: string, workspaceId: string, error: unknown): never { - if (error instanceof VfsPathError) { - throw new OrchestrationError( - 'validation', - `"${vfsPath}" is not a valid workspace file path: ${error.message}` - ) - } - if (error instanceof OrchestrationError && error.code === 'validation') { - throw new UnresolvedWorkspaceFileVfsPathError(workspaceId, 'validation', error.message) - } - throw error +function namesVfsPath(input: WorkspaceFileTargetInput): boolean { + return input.acceptVfsPath === true && isWorkspaceFileVfsPath(input.fileId) } -/** A path becomes an id first, so the canonical load and every authorization check run unchanged. */ +/** + * A path becomes an id first, so the canonical load and every authorization check run unchanged. + * The resolver's `validation` is decided from the reference alone, before any read, and says + * nothing about the workspace. Every other refusal it raises was read from the workspace, as is a + * path that names no single live file, so each leaves as an + * {@link UnresolvedWorkspaceFileVfsPathError} that only a caller the operation admits sees. + */ async function resolveFileId(input: WorkspaceFileContextInput): Promise { - if (!isWorkspaceFileVfsPath(input.fileId)) return input.fileId + if (!namesVfsPath(input)) return input.fileId const workspaceId = input.assertedWorkspaceId if (workspaceId === undefined) { throw new OrchestrationError('not_found', 'File not found') } - const file = await getWorkspaceFileByVfsPath(workspaceId, input.fileId).catch((error: unknown) => - rethrowVfsLookupError(input.fileId, workspaceId, error) - ) - if (!file) + const file = await resolveFileReference( + { entityType: 'workspace', entityId: workspaceId }, + input.fileId + ).catch((error: unknown) => { + if (!(error instanceof OrchestrationError) || error.code === 'validation') throw error + if (error.code === 'conflict') { + throw new UnresolvedWorkspaceFileVfsPathError( + workspaceId, + 'validation', + `"${input.fileId}" names more than one file whose names display alike; pass the file id instead` + ) + } + throw new UnresolvedWorkspaceFileVfsPathError(workspaceId, error.code, error.message) + }) + if (!file) { throw new UnresolvedWorkspaceFileVfsPathError(workspaceId, 'not_found', 'File not found') + } return file.id } @@ -118,7 +132,7 @@ export async function resolveActiveWorkspaceFileContext( includeChatUploads: input.includeChatUploads, includeOwnedFiles: Boolean(input.ownedFilePrincipal), }) - if (!loaded && isWorkspaceFileVfsPath(input.fileId) && input.assertedWorkspaceId) { + if (!loaded && namesVfsPath(input) && input.assertedWorkspaceId) { throw new UnresolvedWorkspaceFileVfsPathError( input.assertedWorkspaceId, 'not_found', @@ -131,7 +145,7 @@ export async function resolveActiveWorkspaceFileContext( } export async function resolveWorkspaceFileLifecycleContext( - input: WorkspaceFileContextInput + input: Pick ): Promise { return requireWorkspaceFileContext( await loadWorkspaceFileLifecycleContext(input.fileId), 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 76ff769ff0c..f6bdb981468 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 @@ -11,12 +11,13 @@ import { type WorkspaceFileRecord, } from '@/lib/uploads/contexts/workspace/file-manager' import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' -import { resolveActiveWorkspaceFileContext } from '@/lib/workspace-files/application/file-context' +import { + resolveActiveWorkspaceFileContext, + type WorkspaceFileTargetInput, +} from '@/lib/workspace-files/application/file-context' import { fileOperations } from '@/lib/workspace-files/application/operations' -export interface ReadWorkspaceFileMetadataInput { - fileId: string - assertedWorkspaceId?: string +export interface ReadWorkspaceFileMetadataInput extends WorkspaceFileTargetInput { /** * Opt into the archived lifecycle set. It relaxes only the `deleted_at` predicate on the * canonical row lookup — the workspace the file resolves to, the asserted-workspace check, 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 1f2e8e678f2..25a73c73611 100644 --- a/apps/sim/lib/workspace-files/application/rename-workspace-file.ts +++ b/apps/sim/lib/workspace-files/application/rename-workspace-file.ts @@ -8,14 +8,15 @@ import { type WorkspaceFileRecord, } from '@/lib/uploads/contexts/workspace/file-manager' import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' -import { resolveActiveWorkspaceFileContext } from '@/lib/workspace-files/application/file-context' +import { + resolveActiveWorkspaceFileContext, + type WorkspaceFileTargetInput, +} from '@/lib/workspace-files/application/file-context' import { fileOperations } from '@/lib/workspace-files/application/operations' const logger = createLogger('RenameWorkspaceFile') -export interface RenameWorkspaceFileInput { - fileId: string - assertedWorkspaceId?: string +export interface RenameWorkspaceFileInput extends WorkspaceFileTargetInput { name: string } diff --git a/apps/sim/lib/workspace-files/application/unresolved-vfs-path.ts b/apps/sim/lib/workspace-files/application/unresolved-vfs-path.ts index 23e07df4b49..16cd8c4e625 100644 --- a/apps/sim/lib/workspace-files/application/unresolved-vfs-path.ts +++ b/apps/sim/lib/workspace-files/application/unresolved-vfs-path.ts @@ -1,14 +1,15 @@ -import { OrchestrationError } from '@/lib/core/orchestration/types' +import { OrchestrationError, type OrchestrationErrorCode } from '@/lib/core/orchestration/types' /** - * A `files/…` path that names no single live file in the asserted workspace. Whether it is missing - * or names two files whose names display alike describes that workspace's contents, so - * `defineAuthorizedWorkspaceFileUseCase` admits the caller to the workspace before letting it out. + * A `files/…` path that names no single live file in the asserted workspace, or a refusal its + * lookup read from the workspace (two names that display alike, a folder tree too large to read). + * Each describes that workspace's contents, so `defineAuthorizedWorkspaceFileUseCase` admits the + * caller to the workspace before letting it out. */ export class UnresolvedWorkspaceFileVfsPathError extends OrchestrationError { constructor( readonly workspaceId: string, - code: 'not_found' | 'validation', + code: OrchestrationErrorCode, message: string ) { super(code, message) diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index 73a894aa959..1b87843ded4 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -17680,7 +17680,10 @@ export const V2_OPERATIONS = { method: 'DELETE', path: '/api/v2/files/[fileId]', pathParams: ['fileId'] as const, - pathParamDocs: { fileId: 'File identifier, or the file’s VFS path: `files//`.' }, + pathParamDocs: { + fileId: + 'File identifier, or the VFS path of a live file: `files//`. Pass the id when two files in a folder have names that display alike.', + }, responseMode: 'json', summary: 'Delete file', query: { @@ -18673,7 +18676,10 @@ export const V2_OPERATIONS = { method: 'GET', path: '/api/v2/files/[fileId]/metadata', pathParams: ['fileId'] as const, - pathParamDocs: { fileId: 'File identifier, or the file’s VFS path: `files//`.' }, + pathParamDocs: { + fileId: + 'File identifier, or the VFS path of a live file: `files//`. Pass the id when two files in a folder have names that display alike.', + }, responseMode: 'json', summary: 'Get file metadata', query: { @@ -22959,7 +22965,10 @@ export const V2_OPERATIONS = { method: 'PATCH', path: '/api/v2/files/[fileId]', pathParams: ['fileId'] as const, - pathParamDocs: { fileId: 'File identifier, or the file’s VFS path: `files//`.' }, + pathParamDocs: { + fileId: + 'File identifier, or the VFS path of a live file: `files//`. Pass the id when two files in a folder have names that display alike.', + }, responseMode: 'json', summary: 'Rename file', body: {