From 6bb9e7e33497f2ee780fa0832ba07a0e7ab60e0d Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 1 Aug 2026 01:57:21 -0700 Subject: [PATCH 1/5] feat(api): expand the public v2 files surface Adds folder support, rename/restore, move, bulk archive, share, and content replace to /api/v2/files, so managing files by API no longer stops at upload + download + archive-one. Routes are thin: auth -> parse -> perform* -> serialize. Share and content replace get their orchestration extracted first so the session routes and the public ones cannot diverge on the effective-authType resolution, the EE public-sharing gate, or the storage-quota classification. Presigned upload stays session-only: presign does an advisory quota check and the real debit happens in the separate register step, so a caller that never registers leaves unaccounted bytes with no reaper. The buffered multipart path debits inside uploadWorkspaceFile's own transaction. --- .../content/docs/de/api-reference/meta.json | 1 + .../content/docs/en/api-reference/meta.json | 1 + .../content/docs/es/api-reference/meta.json | 1 + .../content/docs/fr/api-reference/meta.json | 1 + .../content/docs/ja/api-reference/meta.json | 1 + .../content/docs/zh/api-reference/meta.json | 1 + apps/docs/openapi-v2-files-audit.json | 1920 ++++++++++++++++- apps/sim/app/api/v1/middleware.ts | 6 + .../v2/files/[fileId]/content/route.test.ts | 186 ++ .../api/v2/files/[fileId]/content/route.ts | 80 + .../v2/files/[fileId]/restore/route.test.ts | 130 ++ .../api/v2/files/[fileId]/restore/route.ts | 71 + .../app/api/v2/files/[fileId]/route.test.ts | 333 +++ apps/sim/app/api/v2/files/[fileId]/route.ts | 72 +- .../api/v2/files/[fileId]/share/route.test.ts | 271 +++ .../app/api/v2/files/[fileId]/share/route.ts | 130 ++ .../api/v2/files/bulk-archive/route.test.ts | 127 ++ .../app/api/v2/files/bulk-archive/route.ts | 75 + .../folders/[folderId]/restore/route.test.ts | 156 ++ .../files/folders/[folderId]/restore/route.ts | 74 + .../v2/files/folders/[folderId]/route.test.ts | 236 ++ .../api/v2/files/folders/[folderId]/route.ts | 135 ++ .../app/api/v2/files/folders/route.test.ts | 242 +++ apps/sim/app/api/v2/files/folders/route.ts | 112 + apps/sim/app/api/v2/files/move/route.test.ts | 139 ++ apps/sim/app/api/v2/files/move/route.ts | 76 + apps/sim/app/api/v2/files/route.test.ts | 297 +++ apps/sim/app/api/v2/files/route.ts | 67 +- apps/sim/app/api/v2/files/utils.ts | 43 + .../[id]/files/[fileId]/content/route.ts | 109 +- .../[id]/files/[fileId]/share/route.ts | 176 +- .../files/folders/[folderId]/restore/route.ts | 8 +- .../[id]/files/folders/[folderId]/route.ts | 4 +- .../workspaces/[id]/files/folders/route.ts | 8 +- apps/sim/lib/api/contracts/v2/files.ts | 407 +++- .../workspace/workspace-file-manager.ts | 7 +- .../workspace-files/orchestration/content.ts | 98 + .../orchestration/file-folder-lifecycle.ts | 40 +- .../workspace-files/orchestration/index.ts | 16 +- .../workspace-files/orchestration/share.ts | 165 ++ scripts/check-api-validation-contracts.ts | 4 +- 41 files changed, 5640 insertions(+), 386 deletions(-) create mode 100644 apps/sim/app/api/v2/files/[fileId]/content/route.test.ts create mode 100644 apps/sim/app/api/v2/files/[fileId]/content/route.ts create mode 100644 apps/sim/app/api/v2/files/[fileId]/restore/route.test.ts create mode 100644 apps/sim/app/api/v2/files/[fileId]/restore/route.ts create mode 100644 apps/sim/app/api/v2/files/[fileId]/route.test.ts create mode 100644 apps/sim/app/api/v2/files/[fileId]/share/route.test.ts create mode 100644 apps/sim/app/api/v2/files/[fileId]/share/route.ts create mode 100644 apps/sim/app/api/v2/files/bulk-archive/route.test.ts create mode 100644 apps/sim/app/api/v2/files/bulk-archive/route.ts create mode 100644 apps/sim/app/api/v2/files/folders/[folderId]/restore/route.test.ts create mode 100644 apps/sim/app/api/v2/files/folders/[folderId]/restore/route.ts create mode 100644 apps/sim/app/api/v2/files/folders/[folderId]/route.test.ts create mode 100644 apps/sim/app/api/v2/files/folders/[folderId]/route.ts create mode 100644 apps/sim/app/api/v2/files/folders/route.test.ts create mode 100644 apps/sim/app/api/v2/files/folders/route.ts create mode 100644 apps/sim/app/api/v2/files/move/route.test.ts create mode 100644 apps/sim/app/api/v2/files/move/route.ts create mode 100644 apps/sim/app/api/v2/files/route.test.ts create mode 100644 apps/sim/app/api/v2/files/utils.ts create mode 100644 apps/sim/lib/workspace-files/orchestration/content.ts create mode 100644 apps/sim/lib/workspace-files/orchestration/share.ts diff --git a/apps/docs/content/docs/de/api-reference/meta.json b/apps/docs/content/docs/de/api-reference/meta.json index d2e994fef8e..48cd9959de5 100644 --- a/apps/docs/content/docs/de/api-reference/meta.json +++ b/apps/docs/content/docs/de/api-reference/meta.json @@ -14,6 +14,7 @@ "(generated)/audit-logs", "(generated)/tables", "(generated)/files", + "(generated)/file-folders", "(generated)/knowledge-bases", "(generated)/mcp-servers", "(generated)/skills", diff --git a/apps/docs/content/docs/en/api-reference/meta.json b/apps/docs/content/docs/en/api-reference/meta.json index d2e994fef8e..48cd9959de5 100644 --- a/apps/docs/content/docs/en/api-reference/meta.json +++ b/apps/docs/content/docs/en/api-reference/meta.json @@ -14,6 +14,7 @@ "(generated)/audit-logs", "(generated)/tables", "(generated)/files", + "(generated)/file-folders", "(generated)/knowledge-bases", "(generated)/mcp-servers", "(generated)/skills", diff --git a/apps/docs/content/docs/es/api-reference/meta.json b/apps/docs/content/docs/es/api-reference/meta.json index d2e994fef8e..48cd9959de5 100644 --- a/apps/docs/content/docs/es/api-reference/meta.json +++ b/apps/docs/content/docs/es/api-reference/meta.json @@ -14,6 +14,7 @@ "(generated)/audit-logs", "(generated)/tables", "(generated)/files", + "(generated)/file-folders", "(generated)/knowledge-bases", "(generated)/mcp-servers", "(generated)/skills", diff --git a/apps/docs/content/docs/fr/api-reference/meta.json b/apps/docs/content/docs/fr/api-reference/meta.json index d2e994fef8e..48cd9959de5 100644 --- a/apps/docs/content/docs/fr/api-reference/meta.json +++ b/apps/docs/content/docs/fr/api-reference/meta.json @@ -14,6 +14,7 @@ "(generated)/audit-logs", "(generated)/tables", "(generated)/files", + "(generated)/file-folders", "(generated)/knowledge-bases", "(generated)/mcp-servers", "(generated)/skills", diff --git a/apps/docs/content/docs/ja/api-reference/meta.json b/apps/docs/content/docs/ja/api-reference/meta.json index d2e994fef8e..48cd9959de5 100644 --- a/apps/docs/content/docs/ja/api-reference/meta.json +++ b/apps/docs/content/docs/ja/api-reference/meta.json @@ -14,6 +14,7 @@ "(generated)/audit-logs", "(generated)/tables", "(generated)/files", + "(generated)/file-folders", "(generated)/knowledge-bases", "(generated)/mcp-servers", "(generated)/skills", diff --git a/apps/docs/content/docs/zh/api-reference/meta.json b/apps/docs/content/docs/zh/api-reference/meta.json index d2e994fef8e..48cd9959de5 100644 --- a/apps/docs/content/docs/zh/api-reference/meta.json +++ b/apps/docs/content/docs/zh/api-reference/meta.json @@ -14,6 +14,7 @@ "(generated)/audit-logs", "(generated)/tables", "(generated)/files", + "(generated)/file-folders", "(generated)/knowledge-bases", "(generated)/mcp-servers", "(generated)/skills", diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index 402866bc262..35a8b7c55d7 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -23,7 +23,11 @@ "tags": [ { "name": "Files", - "description": "Upload, download, list, and archive workspace files (v2). Workspace-scoped via the required workspaceId query parameter." + "description": "Upload, download, list, rename, archive, restore, share, and replace the contents of workspace files (v2). Workspace-scoped via the required workspaceId query parameter or body field." + }, + { + "name": "File Folders", + "description": "Organize workspace files into folders (v2). File folders have their own endpoints rather than being served by the generic folders surface." }, { "name": "Audit Logs", @@ -40,7 +44,7 @@ "get": { "operationId": "listFiles", "summary": "List Files", - "description": "List the active files in a workspace with opaque cursor pagination. Results are ordered by upload time. Pass the `nextCursor` from a previous response to fetch the next page; a `null` `nextCursor` means there are no more results.", + "description": "List a workspace's files with opaque cursor pagination. Results are ordered by upload time. Pass the `nextCursor` from a previous response to fetch the next page; a `null` `nextCursor` means there are no more results. Use `scope=archived` to page through Recently Deleted — that is how you find the id of a file to restore.", "tags": ["Files"], "x-codeSamples": [ { @@ -54,6 +58,17 @@ { "$ref": "#/components/parameters/WorkspaceIdQuery" }, + { + "name": "scope", + "in": "query", + "required": false, + "description": "`active` (the default) lists live files; `archived` lists the ones in Recently Deleted, which is how you find an id to restore.", + "schema": { + "type": "string", + "enum": ["active", "archived"], + "default": "active" + } + }, { "name": "limit", "in": "query", @@ -97,8 +112,11 @@ "size": 1024, "type": "text/csv", "key": "workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/1709571234-xyz-data.csv", + "folderId": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu", + "folderPath": "Reports/Q1", "uploadedBy": "user_abc123", - "uploadedAt": "2026-01-15T10:30:00Z" + "uploadedAt": "2026-01-15T10:30:00Z", + "updatedAt": "2026-01-15T10:30:00Z" } ], "nextCursor": "eyJ1cGxvYWRlZEF0IjoiMjAyNi0wMS0xNVQxMDozMDowMFoiLCJpZCI6IndmX1YxU3RHWFI4ejVqZEhpNkJteVQ5MSJ9" @@ -126,7 +144,7 @@ "post": { "operationId": "uploadFile", "summary": "Upload File", - "description": "Upload a file to a workspace as `multipart/form-data` with a single `file` field. The workspace is supplied as the `workspaceId` query parameter (not a form field) so authorization runs before the request body is buffered. Maximum file size is 100MB. Duplicate filenames within a workspace are rejected. Returns `201 Created`.", + "description": "Upload a file to a workspace as `multipart/form-data` with a single `file` field. The workspace — and the optional target `folderId` — are supplied as query parameters (not form fields) so authorization runs before the request body is buffered. Maximum file size is 100MB. A name already taken in the destination folder is rejected with `409`; the in-app uploader instead auto-suffixes, so the two surfaces differ here on purpose. Returns `201 Created`.\n\nPresigned upload is not part of the public API: it debits the storage quota only in a separate register step, so a caller that never registers would leave unaccounted bytes in storage. This buffered path debits inside the upload transaction.", "tags": ["Files"], "x-codeSamples": [ { @@ -139,6 +157,16 @@ "parameters": [ { "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "name": "folderId", + "in": "query", + "required": false, + "description": "Target file folder. Omit to upload to the workspace root. Supplied as a query parameter, like `workspaceId`, so authorization runs before the multipart body is buffered.", + "schema": { + "type": "string", + "example": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu" + } } ], "requestBody": { @@ -186,8 +214,11 @@ "size": 1024, "type": "text/csv", "key": "workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/1709571234-xyz-data.csv", + "folderId": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu", + "folderPath": "Reports/Q1", "uploadedBy": "user_abc123", - "uploadedAt": "2026-01-15T10:30:00Z" + "uploadedAt": "2026-01-15T10:30:00Z", + "updatedAt": "2026-01-15T10:30:00Z" } } } @@ -426,6 +457,112 @@ "$ref": "#/components/responses/InternalError" } } + }, + "patch": { + "operationId": "renameFile", + "summary": "Rename File", + "description": "Rename a file. Renaming only — use `POST /api/v2/files/move` to change which folder a file lives in. A name already taken in the same folder is rejected with `409`.", + "tags": ["Files"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X PATCH \\\\\n \"https://www.sim.ai/api/v2/files/wf_V1StGXR8z5jdHi6BmyT91\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\" \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\", \"name\": \"renamed.csv\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/FileIdPath" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["workspaceId", "name"], + "properties": { + "workspaceId": { + "type": "string", + "description": "The workspace that owns the file.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "The new filename. Cannot contain `/`, `\\`, or be `.` / `..`.", + "example": "renamed.csv" + } + } + }, + "example": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "name": "renamed.csv" + } + } + } + }, + "responses": { + "200": { + "description": "The renamed file.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2FileResponse" + }, + "example": { + "data": { + "id": "wf_V1StGXR8z5jdHi6BmyT91", + "name": "renamed.csv", + "size": 1024, + "type": "text/csv", + "key": "workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/1709571234-xyz-data.csv", + "folderId": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu", + "folderPath": "Reports/Q1", + "uploadedBy": "user_abc123", + "uploadedAt": "2026-01-15T10:30:00Z", + "updatedAt": "2026-01-15T10:30:00Z" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } } }, "/api/v2/audit-logs": { @@ -694,113 +831,1320 @@ } } } - } - }, - "components": { - "securitySchemes": { - "apiKey": { - "type": "apiKey", - "in": "header", - "name": "X-API-Key", - "description": "Your Sim API key (personal or workspace). Generate one from the Sim dashboard under Settings > API Keys." - } - }, - "parameters": { - "WorkspaceIdQuery": { - "name": "workspaceId", - "in": "query", - "required": true, - "description": "The unique identifier of the workspace.", - "schema": { - "type": "string", - "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" - } - }, - "FileIdPath": { - "name": "fileId", - "in": "path", - "required": true, - "description": "The unique identifier of the file.", - "schema": { - "type": "string", - "example": "wf_V1StGXR8z5jdHi6BmyT91" - } - }, - "Cursor": { - "name": "cursor", - "in": "query", - "required": false, - "description": "Opaque pagination cursor. Pass the `nextCursor` value from a previous response to fetch the next page.", - "schema": { - "type": "string" - } - } - }, - "headers": { - "X-RateLimit-Limit": { - "description": "The maximum number of requests permitted in the current rate-limit window.", - "schema": { - "type": "integer", - "example": 100 - } - }, - "X-RateLimit-Remaining": { - "description": "The number of requests remaining in the current rate-limit window.", - "schema": { - "type": "integer", - "example": 95 - } - }, - "X-RateLimit-Reset": { - "description": "ISO 8601 timestamp at which the current rate-limit window resets.", - "schema": { - "type": "string", - "format": "date-time", - "example": "2026-01-15T11:00:00Z" - } - } }, - "schemas": { - "V2File": { - "type": "object", - "description": "A workspace file as exposed by the v2 surface.", - "required": ["id", "name", "size", "type", "key", "uploadedBy", "uploadedAt"], - "properties": { - "id": { - "type": "string", - "description": "Unique file identifier.", - "example": "wf_V1StGXR8z5jdHi6BmyT91" + "/api/v2/files/{fileId}/restore": { + "post": { + "operationId": "restoreFile", + "summary": "Restore File", + "description": "Restore an archived file. Find archived ids with `GET /api/v2/files?scope=archived`. If the original name has since been taken by a live file, the file is restored under a suffixed name.", + "tags": ["Files"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\\\n \"https://www.sim.ai/api/v2/files/wf_V1StGXR8z5jdHi6BmyT91/restore\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\" \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/FileIdPath" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["workspaceId"], + "properties": { + "workspaceId": { + "type": "string", + "description": "The workspace that owns the file.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + } + } + }, + "example": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + } + } + } + }, + "responses": { + "200": { + "description": "The file was restored.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2RestoreFileResponse" + }, + "example": { + "data": { + "id": "wf_V1StGXR8z5jdHi6BmyT91", + "restored": true + } + } + } + } }, - "name": { - "type": "string", - "description": "Original filename.", - "example": "data.csv" + "400": { + "$ref": "#/components/responses/BadRequest" }, - "size": { - "type": "integer", - "minimum": 0, - "description": "File size in bytes.", - "example": 1024 + "401": { + "$ref": "#/components/responses/Unauthorized" }, - "type": { - "type": "string", - "description": "MIME type of the file.", - "example": "text/csv" + "403": { + "$ref": "#/components/responses/Forbidden" }, - "key": { - "type": "string", - "description": "Storage key for the file.", - "example": "workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/1709571234-xyz-data.csv" + "404": { + "$ref": "#/components/responses/NotFound" }, - "uploadedBy": { - "type": "string", - "description": "User ID of the uploader.", - "example": "user_abc123" + "409": { + "$ref": "#/components/responses/Conflict" }, - "uploadedAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp of when the file was uploaded.", + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/files/move": { + "post": { + "operationId": "moveFileItems", + "summary": "Move Files and Folders", + "description": "Move files and/or folders into a folder. `targetFolderId: null` — or omitting it — moves the selection to the workspace root. At least one of `fileIds` or `folderIds` must be non-empty. The whole selection moves under one lock, so a name collision at the destination fails the request with `409` instead of applying part of it.", + "tags": ["Files"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\\\n \"https://www.sim.ai/api/v2/files/move\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\" \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\", \"fileIds\": [\"wf_V1StGXR8z5jdHi6BmyT91\"], \"targetFolderId\": \"fold_9Kq2mZ7pR4tLxWc0Ye3Nu\"}'" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["workspaceId"], + "properties": { + "workspaceId": { + "type": "string", + "description": "The workspace that owns the items.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "fileIds": { + "type": "array", + "maxItems": 1000, + "default": [], + "items": { + "type": "string" + }, + "description": "Files to move." + }, + "folderIds": { + "type": "array", + "maxItems": 1000, + "default": [], + "items": { + "type": "string" + }, + "description": "Folders to move. Descendants follow their folder." + }, + "targetFolderId": { + "type": ["string", "null"], + "description": "Destination folder. `null` or omitted moves to the workspace root.", + "example": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu" + } + } + }, + "examples": { + "intoFolder": { + "summary": "Move two files into a folder", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "fileIds": ["wf_V1StGXR8z5jdHi6BmyT91", "wf_2QrTb9xLm4PvZc7Ns1Ka"], + "targetFolderId": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu" + } + }, + "toRoot": { + "summary": "Move a folder back to the workspace root", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "folderIds": ["fold_9Kq2mZ7pR4tLxWc0Ye3Nu"], + "targetFolderId": null + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The items were moved.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2MoveFileItemsResponse" + }, + "example": { + "data": { + "movedItems": { + "files": 2, + "folders": 0 + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/files/bulk-archive": { + "post": { + "operationId": "bulkArchiveFileItems", + "summary": "Archive Files and Folders", + "description": "Archive (soft delete) files and/or folders in one call. Archiving a folder cascades to everything under it, so `deletedItems` reports totals larger than the selection. Archived items remain listable via `scope=archived` and can be restored.", + "tags": ["Files"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\\\n \"https://www.sim.ai/api/v2/files/bulk-archive\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\" \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\", \"fileIds\": [\"wf_V1StGXR8z5jdHi6BmyT91\"], \"folderIds\": [\"fold_9Kq2mZ7pR4tLxWc0Ye3Nu\"]}'" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["workspaceId"], + "properties": { + "workspaceId": { + "type": "string", + "description": "The workspace that owns the items.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "fileIds": { + "type": "array", + "maxItems": 1000, + "default": [], + "items": { + "type": "string" + }, + "description": "Files to archive." + }, + "folderIds": { + "type": "array", + "maxItems": 1000, + "default": [], + "items": { + "type": "string" + }, + "description": "Folders to archive, together with their contents." + } + } + }, + "example": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "fileIds": ["wf_V1StGXR8z5jdHi6BmyT91"], + "folderIds": ["fold_9Kq2mZ7pR4tLxWc0Ye3Nu"] + } + } + } + }, + "responses": { + "200": { + "description": "The items were archived.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2BulkArchiveFileItemsResponse" + }, + "example": { + "data": { + "deletedItems": { + "files": 3, + "folders": 1 + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/files/folders": { + "get": { + "operationId": "listFileFolders", + "summary": "List File Folders", + "description": "List a workspace's file-folder tree. The tree is bounded, so it is returned as a single page and `nextCursor` is always `null`. File folders are managed only here — the generic `/api/v2/folders` surface does not serve them, because every file-folder mutation runs behind a per-workspace lock and file-folder names are path segments.", + "tags": ["File Folders"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\\\n \"https://www.sim.ai/api/v2/files/folders?workspaceId=YOUR_WORKSPACE_ID\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + }, + { + "name": "scope", + "in": "query", + "required": false, + "description": "`active` (the default) lists live folders; `archived` lists the ones in Recently Deleted, which is how you find an id to restore.", + "schema": { + "type": "string", + "enum": ["active", "archived"], + "default": "active" + } + } + ], + "responses": { + "200": { + "description": "The workspace's file folders.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2FileFolderListResponse" + }, + "example": { + "data": [ + { + "id": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu", + "name": "Q1", + "parentId": "fold_3Bd8vT1yQ6sKzMn5Ap7Rw", + "path": "Reports/Q1", + "sortOrder": 0, + "createdAt": "2026-01-15T10:30:00Z", + "updatedAt": "2026-01-15T10:30:00Z", + "deletedAt": null + } + ], + "nextCursor": null + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "post": { + "operationId": "createFileFolder", + "summary": "Create File Folder", + "description": "Create a file folder. `parentId: null` — or omitting it — creates the folder at the workspace root. The name becomes a path segment, so `/`, `\\`, `.` and `..` are rejected. A sibling with the same name is rejected with `409`. Returns `201 Created`.", + "tags": ["File Folders"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\\\n \"https://www.sim.ai/api/v2/files/folders\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\" \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\", \"name\": \"Q1\"}'" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["workspaceId", "name"], + "properties": { + "workspaceId": { + "type": "string", + "description": "The workspace to create the folder in.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "Folder name. Cannot contain `/`, `\\`, or be `.` / `..`.", + "example": "Q1" + }, + "parentId": { + "type": ["string", "null"], + "description": "Parent folder. `null` or omitted creates the folder at the workspace root.", + "example": "fold_3Bd8vT1yQ6sKzMn5Ap7Rw" + } + } + }, + "example": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "name": "Q1", + "parentId": "fold_3Bd8vT1yQ6sKzMn5Ap7Rw" + } + } + } + }, + "responses": { + "201": { + "description": "The folder was created.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2FileFolderResponse" + }, + "example": { + "data": { + "id": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu", + "name": "Q1", + "parentId": "fold_3Bd8vT1yQ6sKzMn5Ap7Rw", + "path": "Reports/Q1", + "sortOrder": 0, + "createdAt": "2026-01-15T10:30:00Z", + "updatedAt": "2026-01-15T10:30:00Z", + "deletedAt": null + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/files/folders/{folderId}": { + "patch": { + "operationId": "updateFileFolder", + "summary": "Update File Folder", + "description": "Rename, reparent, or reorder a file folder. At least one of `name`, `parentId`, or `sortOrder` is required; omitted fields keep their stored values. Reparenting is cycle-checked, so making a folder its own descendant is rejected with `400`.", + "tags": ["File Folders"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X PATCH \\\\\n \"https://www.sim.ai/api/v2/files/folders/fold_9Kq2mZ7pR4tLxWc0Ye3Nu\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\" \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\", \"name\": \"Q2\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/FileFolderIdPath" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["workspaceId"], + "properties": { + "workspaceId": { + "type": "string", + "description": "The workspace that owns the folder.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "New folder name. Cannot contain `/`, `\\`, or be `.` / `..`.", + "example": "Q2" + }, + "parentId": { + "type": ["string", "null"], + "description": "New parent folder. `null` moves the folder to the workspace root." + }, + "sortOrder": { + "type": "integer", + "minimum": 0, + "description": "New ordering position among siblings." + } + } + }, + "examples": { + "rename": { + "summary": "Rename", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "name": "Q2" + } + }, + "reparent": { + "summary": "Move to the workspace root", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "parentId": null + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The updated folder.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2FileFolderResponse" + }, + "example": { + "data": { + "id": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu", + "name": "Q2", + "parentId": "fold_3Bd8vT1yQ6sKzMn5Ap7Rw", + "path": "Reports/Q2", + "sortOrder": 0, + "createdAt": "2026-01-15T10:30:00Z", + "updatedAt": "2026-01-15T10:30:00Z", + "deletedAt": null + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "delete": { + "operationId": "deleteFileFolder", + "summary": "Archive File Folder", + "description": "Archive (soft delete) a folder and everything under it. `deletedItems` counts the whole cascade. Restore it with `POST /api/v2/files/folders/{folderId}/restore`.", + "tags": ["File Folders"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X DELETE \\\\\n \"https://www.sim.ai/api/v2/files/folders/fold_9Kq2mZ7pR4tLxWc0Ye3Nu?workspaceId=YOUR_WORKSPACE_ID\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/FileFolderIdPath" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The folder and its contents were archived.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2DeleteFileFolderResponse" + }, + "example": { + "data": { + "id": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu", + "deleted": true, + "deletedItems": { + "files": 3, + "folders": 1 + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/files/folders/{folderId}/restore": { + "post": { + "operationId": "restoreFileFolder", + "summary": "Restore File Folder", + "description": "Restore an archived folder together with the subtree that was archived with it. `restoredItems.folders` counts the folder itself. Find archived ids with `GET /api/v2/files/folders?scope=archived`.", + "tags": ["File Folders"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X POST \\\\\n \"https://www.sim.ai/api/v2/files/folders/fold_9Kq2mZ7pR4tLxWc0Ye3Nu/restore\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\" \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/FileFolderIdPath" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["workspaceId"], + "properties": { + "workspaceId": { + "type": "string", + "description": "The workspace that owns the folder.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + } + } + }, + "example": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + } + } + } + }, + "responses": { + "200": { + "description": "The folder and its subtree were restored.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2RestoreFileFolderResponse" + }, + "example": { + "data": { + "folder": { + "id": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu", + "name": "Q1", + "parentId": "fold_3Bd8vT1yQ6sKzMn5Ap7Rw", + "path": "Reports/Q1", + "sortOrder": 0, + "createdAt": "2026-01-15T10:30:00Z", + "updatedAt": "2026-01-15T10:30:00Z", + "deletedAt": null + }, + "restoredItems": { + "files": 3, + "folders": 1 + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/files/{fileId}/share": { + "get": { + "operationId": "getFileShare", + "summary": "Get File Share", + "description": "Read a file's public share state. `share` is `null` when the file has never been shared. The encrypted password is never returned — `hasPassword` is the only password signal.\n\n**Disabling is not revoking.** Setting `isActive: false` preserves the token and the stored password / allow-list, so re-enabling later resurrects the identical URL. To make a link permanently unreachable, archive the file instead.", + "tags": ["Files"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X GET \\\\\n \"https://www.sim.ai/api/v2/files/wf_V1StGXR8z5jdHi6BmyT91/share?workspaceId=YOUR_WORKSPACE_ID\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\"" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/FileIdPath" + }, + { + "$ref": "#/components/parameters/WorkspaceIdQuery" + } + ], + "responses": { + "200": { + "description": "The file's share state.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2GetFileShareResponse" + }, + "example": { + "data": { + "share": { + "id": "shr_8Hf3kL9wQ2mNpXr6Tz1Vb", + "token": "share-token-example", + "url": "https://www.sim.ai/f/share-token-example", + "isActive": true, + "resourceType": "file", + "resourceId": "wf_V1StGXR8z5jdHi6BmyT91", + "authType": "public", + "hasPassword": false, + "allowedEmails": [] + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + }, + "put": { + "operationId": "upsertFileShare", + "summary": "Enable or Disable File Share", + "description": "Enable or disable a file's public share. Requires workspace `write`.\n\nThe share token is always server-generated; there is no way to supply one. `authType` selects how the link is gated: `public` (anyone with the link), `password` (requires `password` on first enable), or `email` / `sso` (requires a non-empty `allowedEmails`). Omitting `authType` on a re-enable keeps the stored mode, and the org access-control policy is evaluated against that stored mode rather than against `public`. Disabling is never blocked by the policy.\n\n**Disabling is not revoking.** Setting `isActive: false` preserves the token and the stored password / allow-list, so re-enabling later resurrects the identical URL. To make a link permanently unreachable, archive the file instead.", + "tags": ["Files"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X PUT \\\\\n \"https://www.sim.ai/api/v2/files/wf_V1StGXR8z5jdHi6BmyT91/share\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\" \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\", \"isActive\": true, \"authType\": \"public\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/FileIdPath" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["workspaceId", "isActive"], + "properties": { + "workspaceId": { + "type": "string", + "description": "The workspace that owns the file.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "isActive": { + "type": "boolean", + "description": "Whether the share should resolve. `false` disables without revoking." + }, + "authType": { + "type": "string", + "enum": ["public", "password", "email", "sso"], + "description": "How the link is gated. Omit on a re-enable to keep the stored mode." + }, + "password": { + "type": "string", + "minLength": 1, + "maxLength": 1024, + "description": "Plaintext password for a `password` share. Required on first enable; omit to keep the stored one." + }, + "allowedEmails": { + "type": "array", + "maxItems": 200, + "items": { + "type": "string", + "minLength": 1, + "maxLength": 320 + }, + "description": "Allowed addresses or `@domain` patterns for an `email` / `sso` share. Must be non-empty when enabling one." + } + } + }, + "examples": { + "publicLink": { + "summary": "Enable a public link", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "isActive": true, + "authType": "public" + } + }, + "passwordProtected": { + "summary": "Enable a password-protected link", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "isActive": true, + "authType": "password", + "password": "EXAMPLE_PASSWORD" + } + }, + "disable": { + "summary": "Disable (keeps the token and stored config)", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "isActive": false + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The share after the update.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2UpsertFileShareResponse" + }, + "example": { + "data": { + "share": { + "id": "shr_8Hf3kL9wQ2mNpXr6Tz1Vb", + "token": "share-token-example", + "url": "https://www.sim.ai/f/share-token-example", + "isActive": true, + "resourceType": "file", + "resourceId": "wf_V1StGXR8z5jdHi6BmyT91", + "authType": "public", + "hasPassword": false, + "allowedEmails": [] + } + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + }, + "/api/v2/files/{fileId}/content": { + "put": { + "operationId": "updateFileContent", + "summary": "Replace File Content", + "description": "Replace a file's bytes. This is a full replace, not an append: `content` becomes the entire body of the file. Use `encoding: \"base64\"` for non-UTF-8 bytes. The decoded body is capped at 50MB and still debits the workspace storage quota, so a write that would push the payer past its limit fails with `413`.", + "tags": ["Files"], + "x-codeSamples": [ + { + "id": "curl", + "label": "cURL", + "lang": "bash", + "source": "curl -X PUT \\\\\n \"https://www.sim.ai/api/v2/files/wf_V1StGXR8z5jdHi6BmyT91/content\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\" \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\", \"content\": \"id,name\\\\n1,alpha\\\\n\"}'" + } + ], + "parameters": [ + { + "$ref": "#/components/parameters/FileIdPath" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["workspaceId", "content"], + "properties": { + "workspaceId": { + "type": "string", + "description": "The workspace that owns the file.", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + }, + "content": { + "type": "string", + "description": "The file's new full contents, interpreted per `encoding`." + }, + "encoding": { + "type": "string", + "enum": ["utf-8", "base64"], + "default": "utf-8", + "description": "How to decode `content` into bytes." + } + } + }, + "examples": { + "text": { + "summary": "Replace with UTF-8 text", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "content": "id,name\n1,alpha\n" + } + }, + "binary": { + "summary": "Replace with base64-encoded bytes", + "value": { + "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", + "content": "aWQsbmFtZQoxLGFscGhhCg==", + "encoding": "base64" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "The updated file.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2FileResponse" + }, + "example": { + "data": { + "id": "wf_V1StGXR8z5jdHi6BmyT91", + "name": "data.csv", + "size": 16, + "type": "text/csv", + "key": "workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/1709571234-xyz-data.csv", + "folderId": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu", + "folderPath": "Reports/Q1", + "uploadedBy": "user_abc123", + "uploadedAt": "2026-01-15T10:30:00Z", + "updatedAt": "2026-01-15T11:05:00Z" + } + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + } + } + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Your Sim API key (personal or workspace). Generate one from the Sim dashboard under Settings > API Keys." + } + }, + "parameters": { + "WorkspaceIdQuery": { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "The unique identifier of the workspace.", + "schema": { + "type": "string", + "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" + } + }, + "FileIdPath": { + "name": "fileId", + "in": "path", + "required": true, + "description": "The unique identifier of the file.", + "schema": { + "type": "string", + "example": "wf_V1StGXR8z5jdHi6BmyT91" + } + }, + "Cursor": { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque pagination cursor. Pass the `nextCursor` value from a previous response to fetch the next page.", + "schema": { + "type": "string" + } + }, + "FileFolderIdPath": { + "name": "folderId", + "in": "path", + "required": true, + "description": "The unique identifier of the file folder.", + "schema": { + "type": "string", + "example": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "description": "The maximum number of requests permitted in the current rate-limit window.", + "schema": { + "type": "integer", + "example": 100 + } + }, + "X-RateLimit-Remaining": { + "description": "The number of requests remaining in the current rate-limit window.", + "schema": { + "type": "integer", + "example": 95 + } + }, + "X-RateLimit-Reset": { + "description": "ISO 8601 timestamp at which the current rate-limit window resets.", + "schema": { + "type": "string", + "format": "date-time", + "example": "2026-01-15T11:00:00Z" + } + } + }, + "schemas": { + "V2File": { + "type": "object", + "description": "A workspace file as exposed by the v2 surface.", + "required": [ + "id", + "name", + "size", + "type", + "key", + "folderId", + "folderPath", + "uploadedBy", + "uploadedAt", + "updatedAt" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique file identifier.", + "example": "wf_V1StGXR8z5jdHi6BmyT91" + }, + "name": { + "type": "string", + "description": "Original filename.", + "example": "data.csv" + }, + "size": { + "type": "integer", + "minimum": 0, + "description": "File size in bytes.", + "example": 1024 + }, + "type": { + "type": "string", + "description": "MIME type of the file.", + "example": "text/csv" + }, + "key": { + "type": "string", + "description": "Storage key for the file.", + "example": "workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/1709571234-xyz-data.csv" + }, + "uploadedBy": { + "type": "string", + "description": "User ID of the uploader.", + "example": "user_abc123" + }, + "uploadedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp of when the file was uploaded.", + "example": "2026-01-15T10:30:00Z" + }, + "folderId": { + "type": ["string", "null"], + "description": "The containing file folder, or null when the file sits at the workspace root.", + "example": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu" + }, + "folderPath": { + "type": ["string", "null"], + "description": "Slash-joined folder names for `folderId`, or null at the workspace root.", + "example": "Reports/Q1" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp of the last write, content or metadata.", "example": "2026-01-15T10:30:00Z" } } @@ -993,6 +2337,340 @@ } } } + }, + "V2FileFolder": { + "type": "object", + "description": "A file folder as exposed by the v2 surface. File folders are managed only through `/api/v2/files/folders/**`; the generic `/api/v2/folders` surface does not serve them.", + "required": [ + "id", + "name", + "parentId", + "path", + "sortOrder", + "createdAt", + "updatedAt", + "deletedAt" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique folder identifier.", + "example": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu" + }, + "name": { + "type": "string", + "description": "Folder name. Becomes a path segment, so it cannot contain `/`, `\\`, `.` or `..`.", + "example": "Q1" + }, + "parentId": { + "type": ["string", "null"], + "description": "The parent folder, or null at the workspace root.", + "example": "fold_3Bd8vT1yQ6sKzMn5Ap7Rw" + }, + "path": { + "type": "string", + "description": "Slash-joined names from the workspace root down to and including this folder.", + "example": "Reports/Q1" + }, + "sortOrder": { + "type": "integer", + "description": "Manual ordering position among siblings.", + "example": 0 + }, + "createdAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 creation timestamp.", + "example": "2026-01-15T10:30:00Z" + }, + "updatedAt": { + "type": "string", + "format": "date-time", + "description": "ISO 8601 timestamp of the last update.", + "example": "2026-01-15T10:30:00Z" + }, + "deletedAt": { + "type": ["string", "null"], + "format": "date-time", + "description": "Set when the folder is archived (in Recently Deleted) rather than live." + } + } + }, + "V2FileItemCounts": { + "type": "object", + "description": "Counts of what an operation actually touched. A folder cascades to its descendants, so these exceed the size of the selection.", + "required": ["files", "folders"], + "properties": { + "files": { + "type": "integer", + "description": "Number of files affected.", + "example": 3 + }, + "folders": { + "type": "integer", + "description": "Number of folders affected.", + "example": 1 + } + } + }, + "V2FileShare": { + "type": "object", + "description": "A file's public share. Never carries the storage key or the encrypted password — `hasPassword` is the only password signal exposed.", + "required": [ + "id", + "token", + "url", + "isActive", + "resourceType", + "resourceId", + "authType", + "hasPassword", + "allowedEmails" + ], + "properties": { + "id": { + "type": "string", + "description": "Unique share identifier.", + "example": "shr_8Hf3kL9wQ2mNpXr6Tz1Vb" + }, + "token": { + "type": "string", + "description": "The public token embedded in the share URL. Always server-generated.", + "example": "share-token-example" + }, + "url": { + "type": "string", + "format": "uri", + "description": "The public share URL.", + "example": "https://www.sim.ai/f/share-token-example" + }, + "isActive": { + "type": "boolean", + "description": "Whether the share currently resolves. Disabling does not revoke — see the endpoint description." + }, + "resourceType": { + "type": "string", + "enum": ["file", "folder"], + "description": "The kind of resource shared. Always `file` on this surface." + }, + "resourceId": { + "type": "string", + "description": "The shared resource id.", + "example": "wf_V1StGXR8z5jdHi6BmyT91" + }, + "authType": { + "type": "string", + "enum": ["public", "password", "email", "sso"], + "description": "How the share is gated." + }, + "hasPassword": { + "type": "boolean", + "description": "Whether a password is stored for this share." + }, + "allowedEmails": { + "type": "array", + "items": { + "type": "string" + }, + "description": "Allow-list of addresses or `@domain` patterns for `email`/`sso` shares. Empty otherwise." + } + } + }, + "V2RestoreFileResult": { + "type": "object", + "description": "Acknowledgement returned by a successful file restore.", + "required": ["id", "restored"], + "properties": { + "id": { + "type": "string", + "description": "The unique identifier of the restored file.", + "example": "wf_V1StGXR8z5jdHi6BmyT91" + }, + "restored": { + "type": "boolean", + "const": true, + "description": "Always true on a successful restore." + } + } + }, + "V2MoveFileItemsResult": { + "type": "object", + "description": "What the move actually relocated.", + "required": ["movedItems"], + "properties": { + "movedItems": { + "$ref": "#/components/schemas/V2FileItemCounts" + } + } + }, + "V2BulkArchiveFileItemsResult": { + "type": "object", + "description": "What the archive actually soft-deleted, including the cascade.", + "required": ["deletedItems"], + "properties": { + "deletedItems": { + "$ref": "#/components/schemas/V2FileItemCounts" + } + } + }, + "V2DeleteFileFolderResult": { + "type": "object", + "description": "Acknowledgement returned by archiving a folder, with the cascade totals.", + "required": ["id", "deleted", "deletedItems"], + "properties": { + "id": { + "type": "string", + "description": "The unique identifier of the archived folder.", + "example": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu" + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "Always true on a successful archive." + }, + "deletedItems": { + "$ref": "#/components/schemas/V2FileItemCounts" + } + } + }, + "V2RestoreFileFolderResult": { + "type": "object", + "description": "The restored folder plus what its subtree restore touched. `restoredItems.folders` counts the folder itself.", + "required": ["folder", "restoredItems"], + "properties": { + "folder": { + "$ref": "#/components/schemas/V2FileFolder" + }, + "restoredItems": { + "$ref": "#/components/schemas/V2FileItemCounts" + } + } + }, + "V2GetFileShareResult": { + "type": "object", + "description": "The file's share state, or null when the file has never been shared.", + "required": ["share"], + "properties": { + "share": { + "oneOf": [ + { + "$ref": "#/components/schemas/V2FileShare" + }, + { + "type": "null" + } + ], + "description": "The share, or null when the file has never been shared." + } + } + }, + "V2UpsertFileShareResult": { + "type": "object", + "description": "The share after the upsert.", + "required": ["share"], + "properties": { + "share": { + "$ref": "#/components/schemas/V2FileShare" + } + } + }, + "V2FileFolderResponse": { + "type": "object", + "description": "A single file folder.", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/V2FileFolder" + } + } + }, + "V2FileFolderListResponse": { + "type": "object", + "description": "A workspace's file-folder tree. The tree is bounded, so it is always returned as one page and `nextCursor` is always null.", + "required": ["data", "nextCursor"], + "properties": { + "data": { + "type": "array", + "description": "The folders.", + "items": { + "$ref": "#/components/schemas/V2FileFolder" + } + }, + "nextCursor": { + "type": ["string", "null"], + "description": "Always null on this endpoint; present so every v2 list shares one envelope." + } + } + }, + "V2RestoreFileResponse": { + "type": "object", + "description": "The result of restoring a file.", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/V2RestoreFileResult" + } + } + }, + "V2MoveFileItemsResponse": { + "type": "object", + "description": "The result of a move.", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/V2MoveFileItemsResult" + } + } + }, + "V2BulkArchiveFileItemsResponse": { + "type": "object", + "description": "The result of a bulk archive.", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/V2BulkArchiveFileItemsResult" + } + } + }, + "V2DeleteFileFolderResponse": { + "type": "object", + "description": "The result of archiving a folder.", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/V2DeleteFileFolderResult" + } + } + }, + "V2RestoreFileFolderResponse": { + "type": "object", + "description": "The result of restoring a folder.", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/V2RestoreFileFolderResult" + } + } + }, + "V2GetFileShareResponse": { + "type": "object", + "description": "The file's public share state.", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/V2GetFileShareResult" + } + } + }, + "V2UpsertFileShareResponse": { + "type": "object", + "description": "The share after enabling or disabling it.", + "required": ["data"], + "properties": { + "data": { + "$ref": "#/components/schemas/V2UpsertFileShareResult" + } + } } }, "responses": { @@ -1119,6 +2797,38 @@ } } } + }, + "Conflict": { + "description": "The request conflicts with existing state — most often a name already taken in the destination folder.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "CONFLICT", + "message": "A file named \"data.csv\" already exists in this workspace" + } + } + } + } + }, + "PayloadTooLarge": { + "description": "The body exceeds the per-request size limit, or accepting it would push the workspace past its storage quota.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "PAYLOAD_TOO_LARGE", + "message": "Storage limit exceeded" + } + } + } + } } } } diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index 97f7372f7a6..50272b153a6 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -40,6 +40,12 @@ export type ApiEndpoint = | 'table-columns' | 'files' | 'file-detail' + | 'file-folders' + | 'file-folder-detail' + | 'file-share' + | 'file-content' + | 'file-move' + | 'file-bulk-archive' | 'knowledge' | 'knowledge-detail' | 'knowledge-search' 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 new file mode 100644 index 00000000000..b2fd918c924 --- /dev/null +++ b/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts @@ -0,0 +1,186 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCheckRateLimit, mockResolveWorkspaceAccess, mockPerformUpdateContent } = vi.hoisted( + () => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockPerformUpdateContent: 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/workspace-files/orchestration', () => ({ + performUpdateWorkspaceFileContent: mockPerformUpdateContent, +})) + +import { PUT } from '@/app/api/v2/files/[fileId]/content/route' + +const WS = '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 RECORD = { + id: FILE_ID, + workspaceId: WS, + name: 'data.csv', + key: 'workspace/ws/1-x-data.csv', + path: '/api/files/serve/x', + 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-03T00:00:00Z'), +} + +const callPut = (body: unknown) => + PUT( + new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}/content`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + { params: Promise.resolve({ fileId: FILE_ID }) } + ) + +describe('PUT /api/v2/files/[fileId]/content', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockPerformUpdateContent.mockResolvedValue({ success: true, file: RECORD }) + }) + + 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() + }) + + 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, 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', + folderId: null, + folderPath: null, + uploadedBy: 'user-1', + 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(), + }) + }) + + it('forwards base64 encoding through to the orchestration', async () => { + await callPut({ workspaceId: WS, content: 'aWQsbmFtZQo=', encoding: 'base64' }) + expect(mockPerformUpdateContent).toHaveBeenCalledWith( + expect.objectContaining({ encoding: 'base64' }) + ) + }) + + 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', + }) + + const res = await callPut({ workspaceId: WS, content: 'id,name\n' }) + + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + }) +}) diff --git a/apps/sim/app/api/v2/files/[fileId]/content/route.ts b/apps/sim/app/api/v2/files/[fileId]/content/route.ts new file mode 100644 index 00000000000..fb310ec2e94 --- /dev/null +++ b/apps/sim/app/api/v2/files/[fileId]/content/route.ts @@ -0,0 +1,80 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2UpdateFileContentContract } from '@/lib/api/contracts/v2/files' +import { parseRequest } from '@/lib/api/server' +import { messageForOrchestrationError } from '@/lib/core/orchestration/types' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { performUpdateWorkspaceFileContent } from '@/lib/workspace-files/orchestration' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { toV2File } from '@/app/api/v2/files/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2ErrorForOrchestration, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2FileContentAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface FileRouteParams { + params: Promise<{ fileId: string }> +} + +/** + * 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 = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'file-content') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2UpdateFileContentContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { fileId } = parsed.data.params + const { workspaceId, content, encoding } = parsed.data.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') + ) + } + + return v2Data(toV2File(result.file), { rateLimit }) + } catch (error) { + logger.error('Error updating file content', { error: getErrorMessage(error, 'Unknown error') }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/files/[fileId]/restore/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/restore/route.test.ts new file mode 100644 index 00000000000..0610ef9649e --- /dev/null +++ b/apps/sim/app/api/v2/files/[fileId]/restore/route.test.ts @@ -0,0 +1,130 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCheckRateLimit, mockResolveWorkspaceAccess, mockPerformRestore } = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockPerformRestore: 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/workspace-files/orchestration', () => ({ + performRestoreWorkspaceFile: mockPerformRestore, +})) + +import { POST } from '@/app/api/v2/files/[fileId]/restore/route' + +const WS = '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 callRestore = (body: unknown) => + POST( + new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}/restore`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + { params: Promise.resolve({ fileId: FILE_ID }) } + ) + +describe('POST /api/v2/files/[fileId]/restore', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockPerformRestore.mockResolvedValue({ success: true }) + }) + + 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 callRestore({ workspaceId: WS }) + + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + expect(mockPerformRestore).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callRestore({}) + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockPerformRestore).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 callRestore({ workspaceId: WS }) + expect(res.status).toBe(403) + expect(mockPerformRestore).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callRestore({ workspaceId: WS }) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('restores the file and acknowledges', async () => { + const res = await callRestore({ workspaceId: WS }) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data).toEqual({ id: FILE_ID, restored: true }) + expect(mockPerformRestore).toHaveBeenCalledWith({ + workspaceId: WS, + fileId: FILE_ID, + userId: 'user-1', + }) + }) + + it('maps a not_found errorCode to 404 rather than a blanket 500', async () => { + mockPerformRestore.mockResolvedValue({ + success: false, + error: 'File not found', + errorCode: 'not_found', + }) + + const res = await callRestore({ workspaceId: WS }) + const body = await res.json() + + expect(res.status).toBe(404) + expect(body.error.code).toBe('NOT_FOUND') + expect(body.error.message).toBe('File not found') + }) +}) diff --git a/apps/sim/app/api/v2/files/[fileId]/restore/route.ts b/apps/sim/app/api/v2/files/[fileId]/restore/route.ts new file mode 100644 index 00000000000..0b559b9ed90 --- /dev/null +++ b/apps/sim/app/api/v2/files/[fileId]/restore/route.ts @@ -0,0 +1,71 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2RestoreFileContract } from '@/lib/api/contracts/v2/files' +import { parseRequest } from '@/lib/api/server' +import { messageForOrchestrationError } from '@/lib/core/orchestration/types' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { performRestoreWorkspaceFile } from '@/lib/workspace-files/orchestration' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2ErrorForOrchestration, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2FileRestoreAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface FileRouteParams { + params: Promise<{ fileId: string }> +} + +/** + * POST /api/v2/files/[fileId]/restore — Restore an archived file. + * + * Find archived ids with `GET /api/v2/files?scope=archived`. A name collision + * with a live file is resolved by the manager's restore-name suffix, so the + * restored file may come back under a different name. + */ +export const POST = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'file-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2RestoreFileContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { fileId } = parsed.data.params + const { workspaceId } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const result = await performRestoreWorkspaceFile({ workspaceId, fileId, userId }) + + if (!result.success) { + return v2ErrorForOrchestration( + result.errorCode, + messageForOrchestrationError(result, 'Failed to restore file') + ) + } + + return v2Data({ id: fileId, restored: true as const }, { rateLimit }) + } catch (error) { + logger.error('Error restoring file', { error: getErrorMessage(error, 'Unknown error') }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/files/[fileId]/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/route.test.ts new file mode 100644 index 00000000000..1afe6c6b97b --- /dev/null +++ b/apps/sim/app/api/v2/files/[fileId]/route.test.ts @@ -0,0 +1,333 @@ +/** + * @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, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockGetWorkspaceFile: vi.fn(), + mockFetchWorkspaceFileBuffer: vi.fn(), + mockPerformRename: vi.fn(), + mockPerformDelete: 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/contexts/workspace', () => ({ + getWorkspaceFile: mockGetWorkspaceFile, + fetchWorkspaceFileBuffer: mockFetchWorkspaceFileBuffer, +})) + +vi.mock('@/lib/workspace-files/orchestration', () => ({ + performRenameWorkspaceFile: mockPerformRename, + performDeleteWorkspaceFileItems: mockPerformDelete, +})) + +import { DELETE, GET, PATCH } from '@/app/api/v2/files/[fileId]/route' + +const WS = '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, +} + +function buildRecord(overrides: Record = {}) { + return { + id: FILE_ID, + 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, + } +} + +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]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockGetWorkspaceFile.mockResolvedValue(buildRecord()) + mockFetchWorkspaceFileBuffer.mockResolvedValue(Buffer.from('id,name\n')) + }) + + 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) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockPerformRename.mockResolvedValue({ + success: true, + 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('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callRename({ workspaceId: WS, name: 'renamed.csv' }) + expect(res.status).toBe(403) + expect(mockPerformRename).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + 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({ + id: FILE_ID, + name: 'renamed.csv', + size: 1024, + type: 'text/csv', + key: 'workspace/ws/1-x-data.csv', + folderId: null, + folderPath: null, + uploadedBy: 'user-1', + uploadedAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + }) + expect(mockPerformRename).toHaveBeenCalledWith({ + workspaceId: WS, + fileId: FILE_ID, + name: 'renamed.csv', + userId: 'user-1', + }) + }) + + 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', + }) + + 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.mockResolvedValue({ + success: false, + error: 'update "workspace_files" set ... failed', + errorCode: 'internal', + }) + + 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 } }) + }) + + 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}`) + + expect(res.status).toBe(404) + expect(mockPerformDelete).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callDelete('') + expect(res.status).toBe(400) + expect(mockPerformDelete).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 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() + + 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(), + }) + }) + + it('maps a not_found errorCode to 404', async () => { + mockPerformDelete.mockResolvedValue({ + success: false, + error: 'File not found', + errorCode: 'not_found', + }) + + 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 d168015d793..e85ccd4b923 100644 --- a/apps/sim/app/api/v2/files/[fileId]/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/route.ts @@ -1,18 +1,27 @@ import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import type { NextRequest } from 'next/server' -import { v2DeleteFileContract, v2DownloadFileContract } from '@/lib/api/contracts/v2/files' +import { + v2DeleteFileContract, + v2DownloadFileContract, + v2RenameFileContract, +} from '@/lib/api/contracts/v2/files' import { parseRequest } from '@/lib/api/server' +import { messageForOrchestrationError } from '@/lib/core/orchestration/types' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { fetchWorkspaceFileBuffer, getWorkspaceFile } from '@/lib/uploads/contexts/workspace' -import { performDeleteWorkspaceFileItems } from '@/lib/workspace-files/orchestration' +import { + performDeleteWorkspaceFileItems, + performRenameWorkspaceFile, +} from '@/lib/workspace-files/orchestration' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { toV2File } from '@/app/api/v2/files/utils' import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import type { V2ErrorCode } from '@/app/api/v2/lib/response' import { rateLimitHeaders, v2Data, v2Error, + v2ErrorForOrchestration, v2RateLimitError, v2ValidationError, v2WorkspaceAccessError, @@ -75,6 +84,50 @@ export const GET = withRouteHandler(async (request: NextRequest, context: FileRo } }) +/** + * PATCH /api/v2/files/[fileId] — Rename a file. + * + * Renaming only; use `POST /api/v2/files/move` to change a file's folder. + * Names that collide within the destination folder are rejected as `CONFLICT` — + * unlike upload, which auto-suffixes on the internal surface. + */ +export const PATCH = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'file-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2RenameFileContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { fileId } = parsed.data.params + const { workspaceId, name } = parsed.data.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(toV2File(result.file), { rateLimit }) + } catch (error) { + logger.error('Error renaming file', { error: getErrorMessage(error, 'Unknown error') }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + /** * DELETE /api/v2/files/[fileId] — Archive (soft delete) a file. * @@ -112,15 +165,10 @@ export const DELETE = withRouteHandler(async (request: NextRequest, context: Fil }) if (!result.success) { - const code: V2ErrorCode = - result.errorCode === 'not_found' - ? 'NOT_FOUND' - : result.errorCode === 'validation' - ? 'BAD_REQUEST' - : result.errorCode === 'conflict' - ? 'CONFLICT' - : 'INTERNAL_ERROR' - return v2Error(code, result.error || 'Failed to delete file') + return v2ErrorForOrchestration( + result.errorCode, + messageForOrchestrationError(result, 'Failed to delete file') + ) } logger.info(`Archived file ${fileId} from workspace ${workspaceId}`) 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 new file mode 100644 index 00000000000..25b15d2f47b --- /dev/null +++ b/apps/sim/app/api/v2/files/[fileId]/share/route.test.ts @@ -0,0 +1,271 @@ +/** + * @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, +})) + +vi.mock('@/app/api/v2/lib/gate', () => ({ + v2ApiGateError: vi.fn().mockResolvedValue(null), +})) + +vi.mock('@/lib/workspace-files/orchestration', () => ({ + performGetWorkspaceFileShare: mockPerformGetShare, + performUpsertWorkspaceFileShare: mockPerformUpsert, +})) + +import { GET, PUT } from '@/app/api/v2/files/[fileId]/share/route' + +const WS = '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 SHARE = { + id: 'shr_1', + token: 'existing-token-abcd', + url: 'https://www.sim.ai/f/existing-token-abcd', + isActive: true, + resourceType: 'file' as const, + resourceId: FILE_ID, + authType: 'public' as const, + hasPassword: false, + allowedEmails: [] as string[], +} + +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) + +const callPut = (body: unknown) => + PUT( + new NextRequest(`http://localhost:3000/api/v2/files/${FILE_ID}/share`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + ctx + ) + +describe('GET /api/v2/files/[fileId]/share', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockPerformGetShare.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 callGet(`workspaceId=${WS}`) + + expect(res.status).toBe(404) + expect(mockPerformGetShare).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callGet('') + expect(res.status).toBe(400) + expect(mockPerformGetShare).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 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('reads at workspace read level and returns the share', async () => { + const res = await callGet(`workspaceId=${WS}`) + const body = await res.json() + + 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 }) + }) + + 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 }) + }) +}) + +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() + }) + + it('rejects a caller-supplied token instead of minting a predictable URL', async () => { + const res = await callPut({ + workspaceId: WS, + isActive: true, + token: 'attacker-chosen-token', + }) + + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockPerformUpsert).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('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') + }) + + it('enables the share at workspace write level and never forwards a token', async () => { + const res = await callPut({ + workspaceId: WS, + 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, + 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', + }) + + const res = await callPut({ workspaceId: WS, isActive: true }) + const body = await res.json() + + expect(res.status).toBe(403) + expect(body.error.code).toBe('FORBIDDEN') + expect(body.error.message).toContain('not allowed') + }) + + it('maps a validation errorCode to 400', async () => { + mockPerformUpsert.mockResolvedValue({ + success: false, + error: 'Password is required for password-protected shares', + errorCode: 'validation', + }) + + const res = await callPut({ workspaceId: WS, isActive: true, authType: 'password' }) + + expect(res.status).toBe(400) + expect((await res.json()).error.message).toBe( + 'Password is required for password-protected shares' + ) + }) +}) diff --git a/apps/sim/app/api/v2/files/[fileId]/share/route.ts b/apps/sim/app/api/v2/files/[fileId]/share/route.ts new file mode 100644 index 00000000000..088672432c5 --- /dev/null +++ b/apps/sim/app/api/v2/files/[fileId]/share/route.ts @@ -0,0 +1,130 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2GetFileShareContract, v2UpsertFileShareContract } from '@/lib/api/contracts/v2/files' +import { parseRequest } from '@/lib/api/server' +import { messageForOrchestrationError } from '@/lib/core/orchestration/types' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + performGetWorkspaceFileShare, + performUpsertWorkspaceFileShare, +} from '@/lib/workspace-files/orchestration' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2ErrorForOrchestration, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2FileShareAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface FileRouteParams { + params: Promise<{ fileId: string }> +} + +/** + * 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 = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'file-share') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2GetFileShareContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { fileId } = parsed.data.params + const { workspaceId } = parsed.data.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 }) + } catch (error) { + logger.error('Error fetching file share', { error: getErrorMessage(error, 'Unknown error') }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** + * 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 = withRouteHandler(async (request: NextRequest, context: FileRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'file-share') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2UpsertFileShareContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { fileId } = parsed.data.params + const { workspaceId, isActive, authType, password, allowedEmails } = parsed.data.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 }) + } catch (error) { + logger.error('Error updating file share', { error: getErrorMessage(error, 'Unknown error') }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/files/bulk-archive/route.test.ts b/apps/sim/app/api/v2/files/bulk-archive/route.test.ts new file mode 100644 index 00000000000..9d4982abc62 --- /dev/null +++ b/apps/sim/app/api/v2/files/bulk-archive/route.test.ts @@ -0,0 +1,127 @@ +/** + * @vitest-environment node + */ +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(), +})) + +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/workspace-files/orchestration', () => ({ + performDeleteWorkspaceFileItems: mockPerformDelete, +})) + +import { POST } from '@/app/api/v2/files/bulk-archive/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, +} + +const callArchive = (body: unknown) => + POST( + new NextRequest('http://localhost:3000/api/v2/files/bulk-archive', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + ) + +describe('POST /api/v2/files/bulk-archive', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockPerformDelete.mockResolvedValue({ success: true, deletedItems: { files: 3, folders: 1 } }) + }) + + 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 callArchive({ workspaceId: WS, fileIds: ['wf_1'] }) + + expect(res.status).toBe(404) + expect(mockPerformDelete).not.toHaveBeenCalled() + }) + + it('400s when the selection is empty', async () => { + const res = await callArchive({ workspaceId: WS, fileIds: [], folderIds: [] }) + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockPerformDelete).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 callArchive({ 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) + const res = await callArchive({ workspaceId: WS, fileIds: ['wf_1'] }) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('archives the selection and reports the full cascade', async () => { + const res = await callArchive({ workspaceId: WS, fileIds: ['wf_1'], folderIds: ['fold_1'] }) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data).toEqual({ deletedItems: { files: 3, folders: 1 } }) + expect(mockPerformDelete).toHaveBeenCalledWith({ + workspaceId: WS, + userId: 'user-1', + fileIds: ['wf_1'], + folderIds: ['fold_1'], + request: expect.anything(), + }) + }) + + it('maps a not_found errorCode to 404', async () => { + mockPerformDelete.mockResolvedValue({ + success: false, + error: 'File not found', + errorCode: 'not_found', + }) + + const res = await callArchive({ 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-archive/route.ts b/apps/sim/app/api/v2/files/bulk-archive/route.ts new file mode 100644 index 00000000000..3f8ff8c256f --- /dev/null +++ b/apps/sim/app/api/v2/files/bulk-archive/route.ts @@ -0,0 +1,75 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2BulkArchiveFileItemsContract } from '@/lib/api/contracts/v2/files' +import { parseRequest } from '@/lib/api/server' +import { messageForOrchestrationError } from '@/lib/core/orchestration/types' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { performDeleteWorkspaceFileItems } from '@/lib/workspace-files/orchestration' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2ErrorForOrchestration, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2FileBulkArchiveAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * POST /api/v2/files/bulk-archive — Archive (soft delete) files and folders. + * + * Archiving a folder cascades to its descendants; `deletedItems` reports the + * totals actually archived, which therefore exceed the selection size. Archived + * items stay listable via `scope=archived` and can be restored. + */ +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const rateLimit = await checkRateLimit(request, 'file-bulk-archive') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2BulkArchiveFileItemsContract, + request, + {}, + { validationErrorResponse: v2ValidationError } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, fileIds, folderIds } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const result = await performDeleteWorkspaceFileItems({ + workspaceId, + userId, + fileIds, + folderIds, + request, + }) + + if (!result.success || !result.deletedItems) { + return v2ErrorForOrchestration( + result.errorCode, + messageForOrchestrationError(result, 'Failed to archive file items') + ) + } + + return v2Data({ deletedItems: result.deletedItems }, { rateLimit }) + } catch (error) { + logger.error('Error archiving file items', { error: getErrorMessage(error, 'Unknown error') }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/files/folders/[folderId]/restore/route.test.ts b/apps/sim/app/api/v2/files/folders/[folderId]/restore/route.test.ts new file mode 100644 index 00000000000..ce9d026e6ac --- /dev/null +++ b/apps/sim/app/api/v2/files/folders/[folderId]/restore/route.test.ts @@ -0,0 +1,156 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCheckRateLimit, mockResolveWorkspaceAccess, mockPerformRestore } = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockPerformRestore: 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/workspace-files/orchestration', () => ({ + performRestoreWorkspaceFileFolder: mockPerformRestore, +})) + +import { POST } from '@/app/api/v2/files/folders/[folderId]/restore/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 FOLDER = { + id: FOLDER_ID, + workspaceId: WS, + userId: 'user-1', + name: 'Q1', + parentId: null, + path: 'Q1', + sortOrder: 0, + deletedAt: null, + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), +} + +const callRestore = (body: unknown) => + POST( + new NextRequest(`http://localhost:3000/api/v2/files/folders/${FOLDER_ID}/restore`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }), + { params: Promise.resolve({ folderId: FOLDER_ID }) } + ) + +describe('POST /api/v2/files/folders/[folderId]/restore', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockPerformRestore.mockResolvedValue({ + success: true, + folder: FOLDER, + restoredItems: { files: 3, folders: 1 }, + }) + }) + + 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 callRestore({ workspaceId: WS }) + + expect(res.status).toBe(404) + expect(mockPerformRestore).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callRestore({}) + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockPerformRestore).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 callRestore({ workspaceId: WS }) + expect(res.status).toBe(403) + expect(mockPerformRestore).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callRestore({ workspaceId: WS }) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('restores the folder and its subtree', async () => { + const res = await callRestore({ workspaceId: WS }) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data).toEqual({ + folder: { + id: FOLDER_ID, + name: 'Q1', + parentId: null, + path: 'Q1', + sortOrder: 0, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + deletedAt: null, + }, + restoredItems: { files: 3, folders: 1 }, + }) + expect(mockPerformRestore).toHaveBeenCalledWith({ + workspaceId: WS, + folderId: FOLDER_ID, + userId: 'user-1', + }) + }) + + it('maps a conflict errorCode to 409 when the name is taken again', async () => { + mockPerformRestore.mockResolvedValue({ + success: false, + error: 'A folder with this name already exists in this location', + errorCode: 'conflict', + }) + + const res = await callRestore({ workspaceId: WS }) + + expect(res.status).toBe(409) + expect((await res.json()).error.code).toBe('CONFLICT') + }) +}) diff --git a/apps/sim/app/api/v2/files/folders/[folderId]/restore/route.ts b/apps/sim/app/api/v2/files/folders/[folderId]/restore/route.ts new file mode 100644 index 00000000000..9c185da6c38 --- /dev/null +++ b/apps/sim/app/api/v2/files/folders/[folderId]/restore/route.ts @@ -0,0 +1,74 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2RestoreFileFolderContract } from '@/lib/api/contracts/v2/files' +import { parseRequest } from '@/lib/api/server' +import { messageForOrchestrationError } from '@/lib/core/orchestration/types' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { performRestoreWorkspaceFileFolder } from '@/lib/workspace-files/orchestration' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { toV2FileFolder } from '@/app/api/v2/files/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2ErrorForOrchestration, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2FileFolderRestoreAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface FolderRouteParams { + params: Promise<{ folderId: string }> +} + +/** + * POST /api/v2/files/folders/[folderId]/restore — Restore an archived folder. + * + * Restores the whole subtree that was archived with it; `restoredItems` counts + * the folder itself among `folders`. + */ +export const POST = withRouteHandler(async (request: NextRequest, context: FolderRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'file-folder-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2RestoreFileFolderContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { folderId } = parsed.data.params + const { workspaceId } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const result = await performRestoreWorkspaceFileFolder({ workspaceId, folderId, userId }) + + if (!result.success || !result.folder || !result.restoredItems) { + return v2ErrorForOrchestration( + result.errorCode, + messageForOrchestrationError(result, 'Failed to restore folder') + ) + } + + return v2Data( + { folder: toV2FileFolder(result.folder), restoredItems: result.restoredItems }, + { rateLimit } + ) + } catch (error) { + logger.error('Error restoring file folder', { error: getErrorMessage(error, 'Unknown error') }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/files/folders/[folderId]/route.test.ts b/apps/sim/app/api/v2/files/folders/[folderId]/route.test.ts new file mode 100644 index 00000000000..601c5e1ae96 --- /dev/null +++ b/apps/sim/app/api/v2/files/folders/[folderId]/route.test.ts @@ -0,0 +1,236 @@ +/** + * @vitest-environment node + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCheckRateLimit, mockResolveWorkspaceAccess, mockPerformUpdate, mockPerformDelete } = + vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockPerformUpdate: vi.fn(), + mockPerformDelete: 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/workspace-files/orchestration', () => ({ + performUpdateWorkspaceFileFolder: mockPerformUpdate, + performDeleteWorkspaceFileItems: mockPerformDelete, +})) + +import { DELETE, PATCH } from '@/app/api/v2/files/folders/[folderId]/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 FOLDER = { + id: FOLDER_ID, + workspaceId: WS, + userId: 'user-1', + name: 'Q2', + parentId: null, + path: 'Q2', + sortOrder: 0, + deletedAt: null, + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), +} + +const ctx = { params: Promise.resolve({ folderId: FOLDER_ID }) } + +const callPatch = (body: unknown) => + PATCH( + new NextRequest(`http://localhost:3000/api/v2/files/folders/${FOLDER_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/folders/${FOLDER_ID}?${query}`), ctx) + +describe('PATCH /api/v2/files/folders/[folderId]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockPerformUpdate.mockResolvedValue({ success: true, folder: FOLDER }) + }) + + 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 callPatch({ workspaceId: WS, name: 'Q2' }) + + expect(res.status).toBe(404) + expect(mockPerformUpdate).not.toHaveBeenCalled() + }) + + it('400s when no updatable field is supplied', async () => { + const res = await callPatch({ workspaceId: WS }) + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockPerformUpdate).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 callPatch({ workspaceId: WS, name: 'Q2' }) + expect(res.status).toBe(403) + expect(mockPerformUpdate).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callPatch({ workspaceId: WS, name: 'Q2' }) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('renames the folder and returns the public shape', async () => { + const res = await callPatch({ workspaceId: WS, name: 'Q2' }) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data).toEqual({ + id: FOLDER_ID, + name: 'Q2', + parentId: null, + path: 'Q2', + sortOrder: 0, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + deletedAt: null, + }) + expect(mockPerformUpdate).toHaveBeenCalledWith({ + workspaceId: WS, + folderId: FOLDER_ID, + userId: 'user-1', + name: 'Q2', + parentId: undefined, + sortOrder: undefined, + }) + }) + + it('maps a validation errorCode (e.g. a reparent cycle) to 400', async () => { + mockPerformUpdate.mockResolvedValue({ + success: false, + error: 'A folder cannot be moved into its own descendant', + errorCode: 'validation', + }) + + const res = await callPatch({ workspaceId: WS, parentId: 'fold_child' }) + const body = await res.json() + + expect(res.status).toBe(400) + expect(body.error.message).toContain('descendant') + }) +}) + +describe('DELETE /api/v2/files/folders/[folderId]', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockPerformDelete.mockResolvedValue({ success: true, deletedItems: { files: 3, folders: 1 } }) + }) + + 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}`) + + expect(res.status).toBe(404) + expect(mockPerformDelete).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callDelete('') + expect(res.status).toBe(400) + expect(mockPerformDelete).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 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 folder and reports the cascade', async () => { + const res = await callDelete(`workspaceId=${WS}`) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data).toEqual({ + id: FOLDER_ID, + deleted: true, + deletedItems: { files: 3, folders: 1 }, + }) + expect(mockPerformDelete).toHaveBeenCalledWith({ + workspaceId: WS, + userId: 'user-1', + folderIds: [FOLDER_ID], + request: expect.anything(), + }) + }) + + it('maps a not_found errorCode to 404', async () => { + mockPerformDelete.mockResolvedValue({ + success: false, + error: 'Folder not found', + errorCode: 'not_found', + }) + + 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/folders/[folderId]/route.ts b/apps/sim/app/api/v2/files/folders/[folderId]/route.ts new file mode 100644 index 00000000000..2239b8eb4a6 --- /dev/null +++ b/apps/sim/app/api/v2/files/folders/[folderId]/route.ts @@ -0,0 +1,135 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { + v2DeleteFileFolderContract, + v2UpdateFileFolderContract, +} from '@/lib/api/contracts/v2/files' +import { parseRequest } from '@/lib/api/server' +import { messageForOrchestrationError } from '@/lib/core/orchestration/types' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + performDeleteWorkspaceFileItems, + performUpdateWorkspaceFileFolder, +} from '@/lib/workspace-files/orchestration' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { toV2FileFolder } from '@/app/api/v2/files/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2ErrorForOrchestration, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2FileFolderDetailAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +interface FolderRouteParams { + params: Promise<{ folderId: string }> +} + +/** + * PATCH /api/v2/files/folders/[folderId] — Rename, reparent, or reorder a folder. + * + * Reparenting is cycle-checked inside the workspace's file-folder advisory lock, + * so making a folder its own descendant fails as `BAD_REQUEST` rather than + * corrupting the tree. + */ +export const PATCH = withRouteHandler(async (request: NextRequest, context: FolderRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'file-folder-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2UpdateFileFolderContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { folderId } = parsed.data.params + const { workspaceId, name, parentId, sortOrder } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const result = await performUpdateWorkspaceFileFolder({ + workspaceId, + folderId, + userId, + name, + parentId, + sortOrder, + }) + + if (!result.success || !result.folder) { + return v2ErrorForOrchestration( + result.errorCode, + messageForOrchestrationError(result, 'Failed to update folder') + ) + } + + return v2Data(toV2FileFolder(result.folder), { rateLimit }) + } catch (error) { + logger.error('Error updating file folder', { error: getErrorMessage(error, 'Unknown error') }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** + * DELETE /api/v2/files/folders/[folderId] — Archive a folder and its contents. + * + * `deletedItems` counts the whole cascade, not just this folder. + */ +export const DELETE = withRouteHandler(async (request: NextRequest, context: FolderRouteParams) => { + try { + const rateLimit = await checkRateLimit(request, 'file-folder-detail') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest(v2DeleteFileFolderContract, request, context, { + validationErrorResponse: v2ValidationError, + }) + if (!parsed.success) return parsed.response + + const { folderId } = parsed.data.params + const { workspaceId } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const result = await performDeleteWorkspaceFileItems({ + workspaceId, + userId, + folderIds: [folderId], + request, + }) + + if (!result.success || !result.deletedItems) { + return v2ErrorForOrchestration( + result.errorCode, + messageForOrchestrationError(result, 'Failed to delete folder') + ) + } + + return v2Data( + { id: folderId, deleted: true as const, deletedItems: result.deletedItems }, + { rateLimit } + ) + } catch (error) { + logger.error('Error deleting file folder', { error: getErrorMessage(error, 'Unknown error') }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) 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..28d6cb6c9a5 --- /dev/null +++ b/apps/sim/app/api/v2/files/folders/route.test.ts @@ -0,0 +1,242 @@ +/** + * @vitest-environment node + * + * Public v2 file folders. These are a separate surface from `/api/v2/folders` + * on purpose, so the name rules the generic folder engine does not enforce + * (path separators, dot segments) are pinned here. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { mockCheckRateLimit, mockResolveWorkspaceAccess, mockListFolders, mockPerformCreate } = + vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockListFolders: vi.fn(), + mockPerformCreate: 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/contexts/workspace', () => ({ + listWorkspaceFileFolders: mockListFolders, +})) + +vi.mock('@/lib/workspace-files/orchestration', () => ({ + performCreateWorkspaceFileFolder: mockPerformCreate, +})) + +import { GET, POST } from '@/app/api/v2/files/folders/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, +} + +function buildFolder(overrides: Record = {}) { + return { + id: 'fold_1', + workspaceId: WS, + userId: 'user-1', + name: 'Q1', + parentId: null, + path: 'Q1', + sortOrder: 0, + deletedAt: null, + createdAt: new Date('2024-01-01T00:00:00Z'), + updatedAt: new Date('2024-01-02T00:00:00Z'), + ...overrides, + } +} + +const callList = (query: string) => + GET(new NextRequest(`http://localhost:3000/api/v2/files/folders?${query}`)) + +const callCreate = (body: unknown) => + POST( + new NextRequest('http://localhost:3000/api/v2/files/folders', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + ) + +describe('GET /api/v2/files/folders', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockListFolders.mockResolvedValue([buildFolder()]) + }) + + 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(mockListFolders).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callList('scope=active') + expect(res.status).toBe(400) + expect((await res.json()).error.code).toBe('BAD_REQUEST') + expect(mockListFolders).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 callList(`workspaceId=${WS}`) + expect(res.status).toBe(403) + expect(mockListFolders).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 folder shape without internal scoping columns', async () => { + 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: 'fold_1', + name: 'Q1', + parentId: null, + path: 'Q1', + sortOrder: 0, + createdAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + deletedAt: null, + }, + ]) + expect(mockListFolders).toHaveBeenCalledWith(WS, { scope: 'active' }) + }) + + it('passes scope=archived through to the manager', async () => { + mockListFolders.mockResolvedValue([ + buildFolder({ id: 'fold_gone', deletedAt: new Date('2024-02-01T00:00:00Z') }), + ]) + + const res = await callList(`workspaceId=${WS}&scope=archived`) + const body = await res.json() + + expect(mockListFolders).toHaveBeenCalledWith(WS, { scope: 'archived' }) + expect(body.data[0].deletedAt).toBe('2024-02-01T00:00:00.000Z') + }) +}) + +describe('POST /api/v2/files/folders', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockPerformCreate.mockResolvedValue({ success: true, folder: buildFolder() }) + }) + + 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 callCreate({ workspaceId: WS, name: 'Q1' }) + + expect(res.status).toBe(404) + expect(mockPerformCreate).not.toHaveBeenCalled() + }) + + it('400s when name is missing', async () => { + const res = await callCreate({ workspaceId: WS }) + expect(res.status).toBe(400) + expect(mockPerformCreate).not.toHaveBeenCalled() + }) + + it('400s on a name containing a path separator or a dot segment', async () => { + for (const name of ['Reports/Q1', 'Reports\\Q1', '..', '.']) { + const res = await callCreate({ workspaceId: WS, name }) + expect(res.status).toBe(400) + } + expect(mockPerformCreate).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 callCreate({ workspaceId: WS, name: 'Q1' }) + expect(res.status).toBe(403) + expect(mockPerformCreate).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callCreate({ workspaceId: WS, name: 'Q1' }) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('creates the folder and returns 201', async () => { + const res = await callCreate({ workspaceId: WS, name: 'Q1' }) + const body = await res.json() + + expect(res.status).toBe(201) + expect(body.data.id).toBe('fold_1') + expect(body.data).not.toHaveProperty('workspaceId') + expect(body.data).not.toHaveProperty('userId') + expect(mockPerformCreate).toHaveBeenCalledWith({ + workspaceId: WS, + userId: 'user-1', + name: 'Q1', + parentId: null, + }) + }) + + it('maps a conflict errorCode to 409', async () => { + mockPerformCreate.mockResolvedValue({ + success: false, + error: 'A folder named "Q1" already exists in this location', + errorCode: 'conflict', + }) + + const res = await callCreate({ workspaceId: WS, name: 'Q1' }) + + expect(res.status).toBe(409) + expect((await res.json()).error.code).toBe('CONFLICT') + }) +}) diff --git a/apps/sim/app/api/v2/files/folders/route.ts b/apps/sim/app/api/v2/files/folders/route.ts new file mode 100644 index 00000000000..60952579e76 --- /dev/null +++ b/apps/sim/app/api/v2/files/folders/route.ts @@ -0,0 +1,112 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2CreateFileFolderContract, v2ListFileFoldersContract } from '@/lib/api/contracts/v2/files' +import { parseRequest } from '@/lib/api/server' +import { messageForOrchestrationError } from '@/lib/core/orchestration/types' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { listWorkspaceFileFolders } from '@/lib/uploads/contexts/workspace' +import { performCreateWorkspaceFileFolder } from '@/lib/workspace-files/orchestration' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { toV2FileFolder } from '@/app/api/v2/files/utils' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2CursorList, + v2Data, + v2Error, + v2ErrorForOrchestration, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2FileFoldersAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * GET /api/v2/files/folders — List a workspace's file-folder tree. + * + * File folders live in the `folder` table but are deliberately not served by + * `/api/v2/folders`: their mutations run behind a per-workspace advisory lock + * and their names are path segments, neither of which the generic folder engine + * enforces. + */ +export const GET = withRouteHandler(async (request: NextRequest) => { + try { + const rateLimit = await checkRateLimit(request, 'file-folders') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2ListFileFoldersContract, + request, + {}, + { validationErrorResponse: v2ValidationError } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, scope } = parsed.data.query + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') + if (access) return v2WorkspaceAccessError(access) + + const folders = await listWorkspaceFileFolders(workspaceId, { scope }) + + // One workspace's file-folder tree is bounded → a single full page. + return v2CursorList(folders.map(toV2FileFolder), null, { rateLimit }) + } catch (error) { + logger.error('Error listing file folders', { error: getErrorMessage(error, 'Unknown error') }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) + +/** POST /api/v2/files/folders — Create a file folder. */ +export const POST = withRouteHandler(async (request: NextRequest) => { + try { + const rateLimit = await checkRateLimit(request, 'file-folders') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2CreateFileFolderContract, + request, + {}, + { validationErrorResponse: v2ValidationError } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, name, parentId } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const result = await performCreateWorkspaceFileFolder({ + workspaceId, + userId, + name, + parentId: parentId ?? null, + }) + + if (!result.success || !result.folder) { + return v2ErrorForOrchestration( + result.errorCode, + messageForOrchestrationError(result, 'Failed to create folder') + ) + } + + return v2Data(toV2FileFolder(result.folder), { rateLimit, status: 201 }) + } catch (error) { + logger.error('Error creating file folder', { error: getErrorMessage(error, 'Unknown error') }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/files/move/route.test.ts b/apps/sim/app/api/v2/files/move/route.test.ts new file mode 100644 index 00000000000..02c232ba4a7 --- /dev/null +++ b/apps/sim/app/api/v2/files/move/route.test.ts @@ -0,0 +1,139 @@ +/** + * @vitest-environment node + */ +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(), +})) + +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/workspace-files/orchestration', () => ({ + performMoveWorkspaceFileItems: mockPerformMove, +})) + +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'), +} + +const RATE_LIMIT_DENIED = { + allowed: false, + limit: 100, + remaining: 0, + resetAt: new Date('2024-01-01T01:00:00Z'), + retryAfterMs: 1000, +} + +const callMove = (body: unknown) => + POST( + new NextRequest('http://localhost:3000/api/v2/files/move', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + ) + +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 } }) + }) + + 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 callMove({ workspaceId: WS, fileIds: ['wf_1'] }) + + expect(res.status).toBe(404) + expect(mockPerformMove).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() + }) + + it('surfaces an access-denied failure in the v2 error envelope', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callMove({ workspaceId: WS, fileIds: ['wf_1'] }) + expect(res.status).toBe(403) + expect(mockPerformMove).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.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') + }) + + it('moves the selection into the target folder', async () => { + const res = await callMove({ + workspaceId: WS, + fileIds: ['wf_1', 'wf_2'], + targetFolderId: 'fold_1', + }) + const body = await res.json() + + expect(res.status).toBe(200) + expect(body.data).toEqual({ movedItems: { files: 2, folders: 0 } }) + expect(mockPerformMove).toHaveBeenCalledWith({ + workspaceId: WS, + userId: 'user-1', + fileIds: ['wf_1', 'wf_2'], + folderIds: [], + targetFolderId: 'fold_1', + }) + }) + + it('treats an omitted targetFolderId as the workspace root', async () => { + await callMove({ workspaceId: WS, folderIds: ['fold_2'] }) + expect(mockPerformMove).toHaveBeenCalledWith( + expect.objectContaining({ folderIds: ['fold_2'], targetFolderId: null }) + ) + }) + + 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'], targetFolderId: 'fold_1' }) + const body = await res.json() + + expect(res.status).toBe(409) + expect(body.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 new file mode 100644 index 00000000000..bd48c7c55e7 --- /dev/null +++ b/apps/sim/app/api/v2/files/move/route.ts @@ -0,0 +1,76 @@ +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import type { NextRequest } from 'next/server' +import { v2MoveFileItemsContract } from '@/lib/api/contracts/v2/files' +import { parseRequest } from '@/lib/api/server' +import { messageForOrchestrationError } from '@/lib/core/orchestration/types' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { performMoveWorkspaceFileItems } from '@/lib/workspace-files/orchestration' +import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { v2ApiGateError } from '@/app/api/v2/lib/gate' +import { + v2Data, + v2Error, + v2ErrorForOrchestration, + v2RateLimitError, + v2ValidationError, + v2WorkspaceAccessError, +} from '@/app/api/v2/lib/response' + +const logger = createLogger('V2FileMoveAPI') + +export const dynamic = 'force-dynamic' +export const revalidate = 0 + +/** + * POST /api/v2/files/move — Move files and/or folders into a folder. + * + * `targetFolderId: null` (or an omitted field) 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 = withRouteHandler(async (request: NextRequest) => { + try { + const rateLimit = await checkRateLimit(request, 'file-move') + if (!rateLimit.allowed) return v2RateLimitError(rateLimit) + + const userId = rateLimit.userId! + + const gate = await v2ApiGateError(userId) + if (gate) return gate + + const parsed = await parseRequest( + v2MoveFileItemsContract, + request, + {}, + { validationErrorResponse: v2ValidationError } + ) + if (!parsed.success) return parsed.response + + const { workspaceId, fileIds, folderIds, targetFolderId } = parsed.data.body + + const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') + if (access) return v2WorkspaceAccessError(access) + + const result = await performMoveWorkspaceFileItems({ + workspaceId, + userId, + fileIds, + folderIds, + targetFolderId: targetFolderId ?? null, + }) + + if (!result.success || !result.movedItems) { + return v2ErrorForOrchestration( + result.errorCode, + messageForOrchestrationError(result, 'Failed to move file items') + ) + } + + return v2Data({ movedItems: result.movedItems }, { rateLimit }) + } catch (error) { + logger.error('Error moving file items', { error: getErrorMessage(error, 'Unknown error') }) + return v2Error('INTERNAL_ERROR', 'Internal server error') + } +}) diff --git a/apps/sim/app/api/v2/files/route.test.ts b/apps/sim/app/api/v2/files/route.test.ts new file mode 100644 index 00000000000..c7295c4ac25 --- /dev/null +++ b/apps/sim/app/api/v2/files/route.test.ts @@ -0,0 +1,297 @@ +/** + * @vitest-environment node + * + * Public v2 files list/upload: gate ordering, the `scope` split that makes + * Recently Deleted reachable, and folder-targeted upload. + */ +import { NextRequest } from 'next/server' +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockCheckRateLimit, + mockResolveWorkspaceAccess, + mockListWorkspaceFiles, + mockUploadWorkspaceFile, + mockGetWorkspaceFile, + mockReadFormDataWithLimit, + mockReadFileToBufferWithLimit, +} = vi.hoisted(() => ({ + mockCheckRateLimit: vi.fn(), + mockResolveWorkspaceAccess: vi.fn(), + mockListWorkspaceFiles: vi.fn(), + mockUploadWorkspaceFile: vi.fn(), + mockGetWorkspaceFile: vi.fn(), + mockReadFormDataWithLimit: vi.fn(), + mockReadFileToBufferWithLimit: 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/contexts/workspace', () => ({ + listWorkspaceFiles: mockListWorkspaceFiles, + uploadWorkspaceFile: mockUploadWorkspaceFile, + getWorkspaceFile: mockGetWorkspaceFile, + FileConflictError: class FileConflictError extends Error {}, +})) + +vi.mock('@/lib/core/utils/stream-limits', () => ({ + readFormDataWithLimit: mockReadFormDataWithLimit, + readFileToBufferWithLimit: mockReadFileToBufferWithLimit, + isPayloadSizeLimitError: () => false, +})) + +vi.mock('@sim/audit', () => ({ + recordAudit: vi.fn(), + AuditAction: { FILE_UPLOADED: 'file.uploaded' }, + AuditResourceType: { FILE: 'file' }, +})) + +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, +} + +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, + } +} + +const callList = (query: string) => + GET(new NextRequest(`http://localhost:3000/api/v2/files?${query}`)) + +const callUpload = (query: string) => + POST( + new NextRequest(`http://localhost:3000/api/v2/files?${query}`, { + method: 'POST', + headers: { 'Content-Type': 'multipart/form-data; boundary=x' }, + body: 'x', + }) + ) + +describe('GET /api/v2/files', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockListWorkspaceFiles.mockResolvedValue([buildRecord()]) + }) + + 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(mockListWorkspaceFiles).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(mockListWorkspaceFiles).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(mockListWorkspaceFiles).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 callList(`workspaceId=${WS}`) + expect(res.status).toBe(403) + expect(mockListWorkspaceFiles).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 () => { + mockListWorkspaceFiles.mockResolvedValue([ + buildRecord({ folderId: FOLDER_ID, folderPath: 'Reports/Q1' }), + ]) + + 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', + folderId: FOLDER_ID, + folderPath: 'Reports/Q1', + uploadedBy: 'user-1', + uploadedAt: '2024-01-01T00:00:00.000Z', + updatedAt: '2024-01-02T00:00:00.000Z', + }, + ]) + expect(mockListWorkspaceFiles).toHaveBeenCalledWith(WS, { scope: 'active' }) + }) + + it('defaults to the active scope and passes archived through', async () => { + await callList(`workspaceId=${WS}`) + expect(mockListWorkspaceFiles).toHaveBeenCalledWith(WS, { scope: 'active' }) + + const archived = buildRecord({ id: 'wf_gone', name: 'gone.csv' }) + mockListWorkspaceFiles.mockResolvedValue([archived]) + + const res = await callList(`workspaceId=${WS}&scope=archived`) + const body = await res.json() + + expect(mockListWorkspaceFiles).toHaveBeenLastCalledWith(WS, { scope: 'archived' }) + expect(body.data.map((f: { id: string }) => f.id)).toEqual(['wf_gone']) + }) +}) + +describe('POST /api/v2/files', () => { + beforeEach(() => { + vi.clearAllMocks() + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) + mockResolveWorkspaceAccess.mockResolvedValue(null) + mockReadFileToBufferWithLimit.mockResolvedValue(Buffer.from('id,name\n')) + mockUploadWorkspaceFile.mockResolvedValue({ id: 'wf_1' }) + mockGetWorkspaceFile.mockResolvedValue(buildRecord()) + + const form = new FormData() + form.set('file', new File(['id,name\n'], 'data.csv', { type: 'text/csv' })) + mockReadFormDataWithLimit.mockResolvedValue(form) + }) + + 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 callUpload(`workspaceId=${WS}`) + + expect(res.status).toBe(404) + expect(mockUploadWorkspaceFile).not.toHaveBeenCalled() + }) + + it('400s when workspaceId is missing', async () => { + const res = await callUpload('folderId=fold_1') + expect(res.status).toBe(400) + expect(mockUploadWorkspaceFile).not.toHaveBeenCalled() + }) + + it('surfaces an access-denied failure before buffering the body', async () => { + mockResolveWorkspaceAccess.mockResolvedValue({ + status: 403, + code: 'FORBIDDEN', + message: 'Access denied', + }) + const res = await callUpload(`workspaceId=${WS}`) + expect(res.status).toBe(403) + expect(mockReadFormDataWithLimit).not.toHaveBeenCalled() + expect(mockUploadWorkspaceFile).not.toHaveBeenCalled() + }) + + it('returns the rate-limit response when denied', async () => { + mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) + const res = await callUpload(`workspaceId=${WS}`) + expect(res.status).toBe(429) + expect((await res.json()).error.code).toBe('RATE_LIMITED') + }) + + it('uploads to the workspace root and returns 201 with the stored record', async () => { + const res = await callUpload(`workspaceId=${WS}`) + const body = await res.json() + + expect(res.status).toBe(201) + expect(body.data.id).toBe('wf_1') + expect(body.data.folderId).toBeNull() + expect(mockUploadWorkspaceFile).toHaveBeenCalledWith( + WS, + 'user-1', + expect.any(Buffer), + 'data.csv', + 'text/csv', + { folderId: null } + ) + }) + + it('lands the upload in the folder named by folderId', async () => { + mockGetWorkspaceFile.mockResolvedValue( + buildRecord({ folderId: FOLDER_ID, folderPath: 'Reports/Q1' }) + ) + + const res = await callUpload(`workspaceId=${WS}&folderId=${FOLDER_ID}`) + const body = await res.json() + + expect(res.status).toBe(201) + expect(mockUploadWorkspaceFile).toHaveBeenCalledWith( + WS, + 'user-1', + expect.any(Buffer), + 'data.csv', + 'text/csv', + { folderId: FOLDER_ID } + ) + expect(body.data.folderId).toBe(FOLDER_ID) + expect(body.data.folderPath).toBe('Reports/Q1') + }) + + it('404s when the target folder does not exist', async () => { + mockUploadWorkspaceFile.mockRejectedValue(new Error('Target folder not found')) + + const res = await callUpload(`workspaceId=${WS}&folderId=missing`) + + expect(res.status).toBe(404) + expect((await res.json()).error.code).toBe('NOT_FOUND') + }) +}) diff --git a/apps/sim/app/api/v2/files/route.ts b/apps/sim/app/api/v2/files/route.ts index 47055ab713b..9b46bc63f8a 100644 --- a/apps/sim/app/api/v2/files/route.ts +++ b/apps/sim/app/api/v2/files/route.ts @@ -21,6 +21,7 @@ import { uploadWorkspaceFile, } from '@/lib/uploads/contexts/workspace' import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' +import { toV2File } from '@/app/api/v2/files/utils' import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { decodeCursor, @@ -56,9 +57,13 @@ function compareFiles(a: V2File, b: V2File): number { /** * GET /api/v2/files — List files in a workspace with cursor pagination. * - * The shared {@link listWorkspaceFiles} manager returns the full active set - * ordered by `uploadedAt`; v2 applies a bounded keyset slice over that result in - * the route. Pushing `limit`/`cursor` down into the manager query is a follow-up. + * `scope=archived` reads Recently Deleted, which is what makes the restore + * endpoints usable — a caller can find the id of something it deleted. + * + * The shared {@link listWorkspaceFiles} manager returns the full set for the + * requested scope ordered by `uploadedAt`; v2 applies a bounded keyset slice + * over that result in the route. Pushing `limit`/`cursor` down into the manager + * query is a follow-up, and `scope` makes it more valuable, not less. */ export const GET = withRouteHandler(async (request: NextRequest) => { try { @@ -80,25 +85,14 @@ export const GET = withRouteHandler(async (request: NextRequest) => { ) if (!parsed.success) return parsed.response - const { workspaceId, limit, cursor } = parsed.data.query + const { workspaceId, scope, limit, cursor } = parsed.data.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') if (access) return v2WorkspaceAccessError(access) - const files = await listWorkspaceFiles(workspaceId) - - const items: V2File[] = files - .map((f) => ({ - id: f.id, - name: f.name, - size: f.size, - type: f.type, - key: f.key, - uploadedBy: f.uploadedBy, - uploadedAt: - f.uploadedAt instanceof Date ? f.uploadedAt.toISOString() : String(f.uploadedAt), - })) - .sort(compareFiles) + const files = await listWorkspaceFiles(workspaceId, { scope }) + + const items: V2File[] = files.map(toV2File).sort(compareFiles) const decoded = cursor ? decodeCursor(cursor) : null const afterCursor = decoded @@ -126,8 +120,9 @@ export const GET = withRouteHandler(async (request: NextRequest) => { * POST /api/v2/files — Upload a file to a workspace. * * Authorization runs fully (rate limit → workspace write access) before the - * multipart body is buffered: the workspace is a contract-validated query param, - * so an unauthorized caller never streams a 100 MB body into memory. + * multipart body is buffered: the workspace and the optional target `folderId` + * are contract-validated query params, so an unauthorized caller never streams a + * 100 MB body into memory. */ export const POST = withRouteHandler(async (request: NextRequest) => { try { @@ -149,7 +144,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { ) if (!parsed.success) return parsed.response - const { workspaceId } = parsed.data.query + const { workspaceId, folderId } = parsed.data.query const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') if (access) return v2WorkspaceAccessError(access) @@ -190,7 +185,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => { userId, buffer, file.name, - file.type || 'application/octet-stream' + file.type || 'application/octet-stream', + { folderId: folderId ?? null } ) logger.info(`Uploaded file: ${file.name} to workspace ${workspaceId}`) @@ -207,25 +203,17 @@ export const POST = withRouteHandler(async (request: NextRequest) => { request, }) + /** + * `uploadWorkspaceFile` returns the executor-facing `UserFile`, which carries + * neither the folder path nor the persisted timestamps, so the stored record + * is the source for the response projection. + */ const fileRecord = await getWorkspaceFile(workspaceId, userFile.id) - const uploadedAt = - fileRecord?.uploadedAt instanceof Date - ? fileRecord.uploadedAt.toISOString() - : fileRecord?.uploadedAt - ? String(fileRecord.uploadedAt) - : new Date().toISOString() - - const responseFile: V2File = { - id: userFile.id, - name: userFile.name, - size: userFile.size, - type: userFile.type, - key: userFile.key, - uploadedBy: userId, - uploadedAt, + if (!fileRecord) { + throw new Error(`Uploaded file ${userFile.id} could not be read back`) } - return v2Data(responseFile, { rateLimit, status: 201 }) + return v2Data(toV2File(fileRecord), { rateLimit, status: 201 }) } catch (error) { if (isPayloadSizeLimitError(error)) { return v2Error('PAYLOAD_TOO_LARGE', error.message) @@ -235,6 +223,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { if (error instanceof FileConflictError || message.includes('already exists')) { return v2Error('CONFLICT', message) } + if (message === 'Target folder not found') { + return v2Error('NOT_FOUND', message) + } if (message.includes('Storage limit') || message.includes('storage limit')) { return v2Error('PAYLOAD_TOO_LARGE', 'Storage limit exceeded') } diff --git a/apps/sim/app/api/v2/files/utils.ts b/apps/sim/app/api/v2/files/utils.ts new file mode 100644 index 00000000000..43b855415ed --- /dev/null +++ b/apps/sim/app/api/v2/files/utils.ts @@ -0,0 +1,43 @@ +import type { V2File, V2FileFolder } from '@/lib/api/contracts/v2/files' +import type { + WorkspaceFileFolderRecord, + WorkspaceFileRecord, +} from '@/lib/uploads/contexts/workspace' + +/** Shared serialization for the v2 files surface. */ + +/** + * Public file projection. `workspaceId` (already known to the caller, who + * supplied it) and the internal storage/versioning columns are not exposed. + */ +export function toV2File(record: WorkspaceFileRecord): V2File { + return { + id: record.id, + name: record.name, + size: record.size, + type: record.type, + key: record.key, + folderId: record.folderId ?? null, + folderPath: record.folderPath ?? null, + uploadedBy: record.uploadedBy, + uploadedAt: record.uploadedAt.toISOString(), + updatedAt: record.updatedAt.toISOString(), + } +} + +/** + * Public file-folder projection, mirroring {@link toV2File}: `workspaceId` and + * `userId` (the creator) are internal scoping columns and stay off the wire. + */ +export function toV2FileFolder(record: WorkspaceFileFolderRecord): V2FileFolder { + return { + id: record.id, + name: record.name, + parentId: record.parentId, + path: record.path, + sortOrder: record.sortOrder, + createdAt: record.createdAt.toISOString(), + updatedAt: record.updatedAt.toISOString(), + deletedAt: record.deletedAt?.toISOString() ?? null, + } +} 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 beece206917..e2963b00f94 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,12 +1,14 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' -import { toError } from '@sim/utils/errors' import { type NextRequest, NextResponse } from 'next/server' import { updateWorkspaceFileContentContract } from '@/lib/api/contracts/workspace-files' import { 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 { updateWorkspaceFileContent } from '@/lib/uploads/contexts/workspace' +import { performUpdateWorkspaceFileContent } from '@/lib/workspace-files/orchestration' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' export const dynamic = 'force-dynamic' @@ -19,78 +21,43 @@ const logger = createLogger('WorkspaceFileContentAPI') */ export const PUT = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ id: string; fileId: string }> }) => { - try { - const session = await getSession() - if (!session?.user?.id) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) - } - - const parsed = await parseRequest(updateWorkspaceFileContentContract, request, context) - if (!parsed.success) return parsed.response - const { id: workspaceId, fileId } = parsed.data.params - const { content, encoding } = parsed.data.body - - 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 session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - const buffer = - encoding === 'base64' ? Buffer.from(content, 'base64') : Buffer.from(content, 'utf-8') + const parsed = await parseRequest(updateWorkspaceFileContentContract, request, context) + if (!parsed.success) return parsed.response + const { id: workspaceId, fileId } = parsed.data.params + const { content, encoding } = parsed.data.body - const maxFileSizeBytes = 50 * 1024 * 1024 - if (buffer.length > maxFileSizeBytes) { - return NextResponse.json( - { error: `File size exceeds ${maxFileSizeBytes / 1024 / 1024}MB limit` }, - { status: 413 } - ) - } + 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 updatedFile = await updateWorkspaceFileContent( - workspaceId, - fileId, - session.user.id, - buffer + 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) } ) - - logger.info(`Updated content for workspace file: ${updatedFile.name}`) - - recordAudit({ - workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: AuditAction.FILE_UPDATED, - resourceType: AuditResourceType.FILE, - resourceId: fileId, - resourceName: updatedFile.name, - description: `Updated content of file "${updatedFile.name}"`, - metadata: { contentSize: buffer.length }, - request, - }) - - return NextResponse.json({ - success: true, - file: updatedFile, - }) - } catch (error) { - const errorMessage = toError(error).message || 'Failed to update file content' - const isNotFound = errorMessage.includes('File not found') - const isQuotaExceeded = errorMessage.includes('Storage limit exceeded') - const status = isNotFound ? 404 : isQuotaExceeded ? 402 : 500 - - if (status === 500) { - logger.error('Error updating file content:', error) - } else { - logger.warn(errorMessage) - } - - return NextResponse.json({ success: false, error: errorMessage }, { status }) } + + return NextResponse.json({ success: true, file: result.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 0d6a09d6361..f5810627a1b 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,23 +1,19 @@ -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 { 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' import { - getShareForResource, - ShareValidationError, - upsertFileShare, -} from '@/lib/public-shares/share-manager' -import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace' + performGetWorkspaceFileShare, + performUpsertWorkspaceFileShare, +} from '@/lib/workspace-files/orchestration' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' -import { - PublicFileSharingNotAllowedError, - validatePublicFileSharing, -} from '@/ee/access-control/utils/permission-check' export const dynamic = 'force-dynamic' @@ -31,40 +27,30 @@ export const GET = 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(getFileShareContract, 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 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 parsed = await parseRequest(getFileShareContract, request, context) + if (!parsed.success) return parsed.response + const { id: workspaceId, fileId } = parsed.data.params - const file = await getWorkspaceFile(workspaceId, fileId) - if (!file) { - return NextResponse.json({ error: 'File not found' }, { status: 404 }) - } + 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 share = await getShareForResource('file', fileId) - return NextResponse.json({ share }) - } catch (error) { - logger.error(`[${requestId}] Error fetching file share:`, error) + const result = await performGetWorkspaceFileShare({ workspaceId, fileId }) + if (!result.success) { return NextResponse.json( - { error: getErrorMessage(error, 'Failed to fetch share') }, - { - status: 500, - } + { error: messageForOrchestrationError(result, 'Failed to fetch share') }, + { status: statusForOrchestrationError(result.errorCode) } ) } + + return NextResponse.json({ share: result.share ?? null }) } ) @@ -76,89 +62,45 @@ export const PUT = 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(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 file = await getWorkspaceFile(workspaceId, fileId) - if (!file) { - return NextResponse.json({ error: 'File not found' }, { status: 404 }) - } - - // 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 existingShare = await getShareForResource('file', fileId) - const effectiveAuthType = authType ?? existingShare?.authType ?? 'public' - try { - await validatePublicFileSharing(session.user.id, workspaceId, effectiveAuthType) - } catch (error) { - if (error instanceof PublicFileSharingNotAllowedError) { - logger.warn(`[${requestId}] Public file sharing disabled for workspace ${workspaceId}`) - return NextResponse.json({ error: error.message }, { status: 403 }) - } - throw error - } - } - - const share = await upsertFileShare({ - workspaceId, - fileId, - userId: session.user.id, - isActive, - authType, - password, - allowedEmails, - token, - }) + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + } - logger.info(`[${requestId}] ${isActive ? 'Enabled' : 'Disabled'} share for file ${fileId}`) + 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 - recordAudit({ - workspaceId, - actorId: session.user.id, - actorName: session.user.name, - actorEmail: session.user.email, - action: isActive ? AuditAction.FILE_SHARED : AuditAction.FILE_SHARE_DISABLED, - resourceType: AuditResourceType.FILE, - resourceId: fileId, - resourceName: file.name, - description: `${isActive ? 'Enabled' : 'Disabled'} public share for "${file.name}"`, - request, - }) + 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 }) + } - return NextResponse.json({ share }) - } catch (error) { - if (error instanceof ShareValidationError) { - return NextResponse.json({ error: error.message }, { status: 400 }) - } - logger.error(`[${requestId}] Error updating file share:`, error) + 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: getErrorMessage(error, 'Failed to update share') }, - { - status: 500, - } + { error: messageForOrchestrationError(result, 'Failed to update share') }, + { status: statusForOrchestrationError(result.errorCode) } ) } + + return NextResponse.json({ share: result.share }) } ) 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 86df6e83f91..ecf7b17b281 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 @@ -3,12 +3,10 @@ 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 { captureServerEvent } from '@/lib/posthog/server' -import { - performRestoreWorkspaceFileFolder, - workspaceFilesOrchestrationStatus, -} from '@/lib/workspace-files/orchestration' +import { performRestoreWorkspaceFileFolder } from '@/lib/workspace-files/orchestration' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('WorkspaceFileFolderRestoreAPI') @@ -38,7 +36,7 @@ export const POST = withRouteHandler( if (!result.success) { return NextResponse.json( { success: false, error: result.error }, - { status: workspaceFilesOrchestrationStatus(result.errorCode) } + { status: statusForOrchestrationError(result.errorCode) } ) } const { folder, restoredItems } = result 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 78232e8a704..079f25e8459 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 @@ -6,12 +6,12 @@ import { } 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 { captureServerEvent } from '@/lib/posthog/server' import { performDeleteWorkspaceFileItems, performUpdateWorkspaceFileFolder, - workspaceFilesOrchestrationStatus, } from '@/lib/workspace-files/orchestration' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' @@ -47,7 +47,7 @@ export const PATCH = withRouteHandler( if (!result.success || !result.folder) { return NextResponse.json( { success: false, error: result.error }, - { status: workspaceFilesOrchestrationStatus(result.errorCode) } + { status: statusForOrchestrationError(result.errorCode) } ) } captureServerEvent( 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 02de14dcf1e..ba3180cb609 100644 --- a/apps/sim/app/api/workspaces/[id]/files/folders/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/folders/route.ts @@ -6,13 +6,11 @@ import { } 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 { captureServerEvent } from '@/lib/posthog/server' import { listWorkspaceFileFolders } from '@/lib/uploads/contexts/workspace' -import { - performCreateWorkspaceFileFolder, - workspaceFilesOrchestrationStatus, -} from '@/lib/workspace-files/orchestration' +import { performCreateWorkspaceFileFolder } from '@/lib/workspace-files/orchestration' import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' const logger = createLogger('WorkspaceFileFoldersAPI') @@ -70,7 +68,7 @@ export const POST = withRouteHandler( if (!result.success || !result.folder) { return NextResponse.json( { success: false, error: result.error }, - { status: workspaceFilesOrchestrationStatus(result.errorCode) } + { status: statusForOrchestrationError(result.errorCode) } ) } captureServerEvent( diff --git a/apps/sim/lib/api/contracts/v2/files.ts b/apps/sim/lib/api/contracts/v2/files.ts index 040ffa4dc80..c365ece3313 100644 --- a/apps/sim/lib/api/contracts/v2/files.ts +++ b/apps/sim/lib/api/contracts/v2/files.ts @@ -1,5 +1,6 @@ import { z } from 'zod' import { workspaceFileIdSchema, workspaceIdSchema } from '@/lib/api/contracts/primitives' +import { shareAuthTypeSchema, shareRecordSchema } from '@/lib/api/contracts/public-shares' import { defineRouteContract } from '@/lib/api/contracts/types' import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' @@ -9,6 +10,19 @@ import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/sha * adds cursor pagination to the list. The workspace is always carried as a query * param — including on upload — so the route can authorize before reading the * multipart body. + * + * File folders are NOT served by the generic `/api/v2/folders` surface: every + * file-folder mutation runs behind the `workspace_file_folders:${workspaceId}` + * advisory lock that makes its cycle and name checks atomic, and a file-folder + * name becomes a path segment so `/`, `\`, `.` and `..` are forbidden. Neither + * holds for the generic folder engine, so file folders get their own routes + * under `/api/v2/files/folders/**`. + * + * Presigned upload is deliberately absent. Presign only performs an advisory + * quota pre-check; the storage debit happens in the separate register step, so + * a caller that presigns, PUTs bytes, and never registers leaves unaccounted + * bytes in the bucket. The buffered multipart upload debits inside + * `uploadWorkspaceFile`'s own transaction, so it is the only public path. */ /** A workspace file as exposed by the v2 surface. */ @@ -18,13 +32,39 @@ export const v2FileSchema = z.object({ size: z.number().nonnegative(), type: z.string(), key: z.string(), + /** Containing file folder, or `null` when the file sits at the workspace root. */ + folderId: z.string().nullable(), + /** Slash-joined folder names for {@link v2FileSchema.folderId}; `null` at the root. */ + folderPath: z.string().nullable(), uploadedBy: z.string(), /** ISO-8601 timestamp. */ uploadedAt: z.string(), + /** ISO-8601 timestamp; advances on content and metadata writes alike. */ + updatedAt: z.string(), }) export type V2File = z.output +/** + * A file folder as exposed by the v2 surface. `workspaceId` (already known to + * the caller, who supplied it) and `userId` (the creator) are internal scoping + * columns and are not exposed, matching the v2 folders projection. + */ +export const v2FileFolderSchema = z.object({ + id: z.string(), + name: z.string(), + parentId: z.string().nullable(), + /** Slash-joined names from the workspace root down to and including this folder. */ + path: z.string(), + sortOrder: z.number(), + createdAt: z.string(), + updatedAt: z.string(), + /** Set when the folder is archived (in Recently Deleted) rather than live. */ + deletedAt: z.string().nullable(), +}) + +export type V2FileFolder = z.output + /** Acknowledgement returned by a successful archive (soft delete). */ export const v2DeleteFileResultSchema = z.object({ id: z.string(), @@ -33,12 +73,46 @@ export const v2DeleteFileResultSchema = z.object({ export type V2DeleteFileResult = z.output +/** Counts of what a cascading archive or restore touched. */ +export const v2FileItemCountsSchema = z.object({ + files: z.number().int(), + folders: z.number().int(), +}) + +export type V2FileItemCounts = z.output + export const v2FileParamsSchema = z.object({ fileId: workspaceFileIdSchema, }) export type V2FileParams = z.output +export const v2FileFolderParamsSchema = z.object({ + folderId: z.string().min(1, 'Folder ID is required'), +}) + +export type V2FileFolderParams = z.output + +/** `active` lists live items; `archived` lists Recently Deleted. */ +export const v2FileScopeSchema = z.enum(['active', 'archived']) + +export type V2FileScope = 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' + ) + /** * List query: workspace scope plus opaque keyset cursor pagination keyed on * `(uploadedAt, id)`. `limit` clamps to `[1, 1000]` (default 100) to bound the @@ -46,6 +120,7 @@ export type V2FileParams = z.output */ export const v2ListFilesQuerySchema = z.object({ workspaceId: workspaceIdSchema, + scope: v2FileScopeSchema.default('active'), limit: z.coerce .number() .optional() @@ -56,9 +131,15 @@ export const v2ListFilesQuerySchema = z.object({ export type V2ListFilesQuery = z.output -/** Upload carries the workspace as a query param so auth runs before buffering. */ +/** + * Upload carries the workspace as a query param so auth runs before buffering. + * `folderId` is a query param for the same reason — the multipart body is never + * read until the caller is authorized. + */ export const v2UploadFileQuerySchema = z.object({ workspaceId: workspaceIdSchema, + /** Target file folder. Omit to upload to the workspace root. */ + folderId: z.string().min(1, 'folderId cannot be empty').optional(), }) export type V2UploadFileQuery = z.output @@ -70,6 +151,202 @@ export const v2FileWorkspaceQuerySchema = z.object({ export type V2FileWorkspaceQuery = z.output +export const v2RenameFileBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + name: v2FileItemNameSchema, + }) + .strict() + +export type V2RenameFileBody = z.input + +export const v2WorkspaceScopedBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + }) + .strict() + +export type V2WorkspaceScopedBody = z.input + +/** A restore acknowledgement carries no payload beyond the restored id. */ +export const v2RestoreFileResultSchema = z.object({ + id: z.string(), + restored: z.literal(true), +}) + +export type V2RestoreFileResult = z.output + +const fileItemSelectionSchema = { + fileIds: z.array(z.string().min(1, 'fileIds entries cannot be empty')).max(1000).default([]), + folderIds: z.array(z.string().min(1, 'folderIds entries cannot be empty')).max(1000).default([]), +} + +export const v2MoveFileItemsBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + ...fileItemSelectionSchema, + /** Explicit `null` moves the selection to the workspace root. */ + targetFolderId: z.string().min(1, 'targetFolderId cannot be empty').nullable().optional(), + }) + .strict() + .superRefine((body, ctx) => { + if (body.fileIds.length === 0 && body.folderIds.length === 0) { + ctx.addIssue({ + code: 'custom', + path: ['fileIds'], + message: 'At least one of fileIds or folderIds must be non-empty', + }) + } + }) + +export type V2MoveFileItemsBody = z.input + +export const v2MoveFileItemsResultSchema = z.object({ + movedItems: v2FileItemCountsSchema, +}) + +export type V2MoveFileItemsResult = z.output + +export const v2BulkArchiveFileItemsBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + ...fileItemSelectionSchema, + }) + .strict() + .superRefine((body, ctx) => { + if (body.fileIds.length === 0 && body.folderIds.length === 0) { + ctx.addIssue({ + code: 'custom', + path: ['fileIds'], + message: 'At least one of fileIds or folderIds must be non-empty', + }) + } + }) + +export type V2BulkArchiveFileItemsBody = z.input + +export const v2BulkArchiveFileItemsResultSchema = z.object({ + deletedItems: v2FileItemCountsSchema, +}) + +export type V2BulkArchiveFileItemsResult = z.output + +export const v2ListFileFoldersQuerySchema = z.object({ + workspaceId: workspaceIdSchema, + scope: v2FileScopeSchema.default('active'), +}) + +export type V2ListFileFoldersQuery = z.output + +export const v2CreateFileFolderBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + name: v2FileItemNameSchema, + /** Explicit `null` creates the folder at the workspace root. */ + parentId: z.string().min(1, 'parentId cannot be empty').nullable().optional(), + }) + .strict() + +export type V2CreateFileFolderBody = z.input + +/** Update body. Omitted fields keep their stored values. */ +export const v2UpdateFileFolderBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + name: v2FileItemNameSchema.optional(), + parentId: z.string().min(1, 'parentId cannot be empty').nullable().optional(), + sortOrder: z.number().int('sortOrder must be an integer').min(0).optional(), + }) + .strict() + .superRefine((body, ctx) => { + if (body.name === undefined && body.parentId === undefined && body.sortOrder === undefined) { + ctx.addIssue({ + code: 'custom', + path: ['name'], + message: 'At least one of name, parentId, or sortOrder is required', + }) + } + }) + +export type V2UpdateFileFolderBody = z.input + +export const v2DeleteFileFolderResultSchema = z.object({ + id: z.string(), + deleted: z.literal(true), + deletedItems: v2FileItemCountsSchema, +}) + +export type V2DeleteFileFolderResult = z.output + +export const v2RestoreFileFolderResultSchema = z.object({ + folder: v2FileFolderSchema, + restoredItems: v2FileItemCountsSchema, +}) + +export type V2RestoreFileFolderResult = z.output + +/** + * Public share state. Reuses the internal {@link shareRecordSchema}, which is + * already public-safe — `hasPassword` is a boolean and neither the ciphertext + * nor the storage key is carried — with `url` tightened to a real URL. + */ +export const v2FileShareSchema = shareRecordSchema.extend({ + url: z.string().url(), +}) + +export type V2FileShare = z.output + +export const v2GetFileShareResultSchema = z.object({ + share: v2FileShareSchema.nullable(), +}) + +export type V2GetFileShareResult = z.output + +export const v2UpsertFileShareResultSchema = z.object({ + share: v2FileShareSchema, +}) + +export type V2UpsertFileShareResult = z.output + +/** + * Share upsert body. The internal surface accepts a caller-supplied `token` so + * the UI can show a link before saving; v2 drops it. Over an API key it would + * let a caller mint predictable public URLs, and a token collision surfaces as + * an unhandled unique-index violation. v2 tokens are always server-generated. + */ +export const v2UpsertFileShareBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + isActive: z.boolean(), + authType: shareAuthTypeSchema.optional(), + password: z + .string() + .min(1, 'password cannot be empty') + .max(1024, 'password is too long') + .optional(), + allowedEmails: z + .array(z.string().min(1, 'allowedEmails entries cannot be empty').max(320)) + .max(200, 'Too many allowed emails') + .optional(), + }) + .strict() + +export type V2UpsertFileShareBody = z.input + +/** + * Content replace body. `content` is the whole new body of the file — this is a + * replace, not an append. Base64 is the escape hatch for non-UTF-8 bytes. + */ +export const v2UpdateFileContentBodySchema = z + .object({ + workspaceId: workspaceIdSchema, + content: z.string().max(70_000_000, 'content is too large'), + encoding: z.enum(['utf-8', 'base64']).default('utf-8'), + }) + .strict() + +export type V2UpdateFileContentBody = z.input + export const v2ListFilesContract = defineRouteContract({ method: 'GET', path: '/api/v2/files', @@ -100,6 +377,17 @@ export const v2DownloadFileContract = defineRouteContract({ }, }) +export const v2RenameFileContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/files/[fileId]', + params: v2FileParamsSchema, + body: v2RenameFileBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2FileSchema), + }, +}) + export const v2DeleteFileContract = defineRouteContract({ method: 'DELETE', path: '/api/v2/files/[fileId]', @@ -110,3 +398,120 @@ export const v2DeleteFileContract = defineRouteContract({ schema: v2DataResponse(v2DeleteFileResultSchema), }, }) + +export const v2RestoreFileContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/files/[fileId]/restore', + params: v2FileParamsSchema, + body: v2WorkspaceScopedBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2RestoreFileResultSchema), + }, +}) + +export const v2MoveFileItemsContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/files/move', + body: v2MoveFileItemsBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2MoveFileItemsResultSchema), + }, +}) + +export const v2BulkArchiveFileItemsContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/files/bulk-archive', + body: v2BulkArchiveFileItemsBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2BulkArchiveFileItemsResultSchema), + }, +}) + +export const v2ListFileFoldersContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/files/folders', + query: v2ListFileFoldersQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2FileFolderSchema), + }, +}) + +export const v2CreateFileFolderContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/files/folders', + body: v2CreateFileFolderBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2FileFolderSchema), + }, +}) + +export const v2UpdateFileFolderContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/files/folders/[folderId]', + params: v2FileFolderParamsSchema, + body: v2UpdateFileFolderBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2FileFolderSchema), + }, +}) + +export const v2DeleteFileFolderContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/files/folders/[folderId]', + params: v2FileFolderParamsSchema, + query: v2FileWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2DeleteFileFolderResultSchema), + }, +}) + +export const v2RestoreFileFolderContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/files/folders/[folderId]/restore', + params: v2FileFolderParamsSchema, + body: v2WorkspaceScopedBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2RestoreFileFolderResultSchema), + }, +}) + +export const v2GetFileShareContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/files/[fileId]/share', + params: v2FileParamsSchema, + query: v2FileWorkspaceQuerySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2GetFileShareResultSchema), + }, +}) + +export const v2UpsertFileShareContract = defineRouteContract({ + method: 'PUT', + path: '/api/v2/files/[fileId]/share', + params: v2FileParamsSchema, + body: v2UpsertFileShareBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2UpsertFileShareResultSchema), + }, +}) + +export const v2UpdateFileContentContract = defineRouteContract({ + method: 'PUT', + path: '/api/v2/files/[fileId]/content', + params: v2FileParamsSchema, + body: v2UpdateFileContentBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2FileSchema), + }, +}) 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 c0dbbbecce9..9ea377589e7 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -19,6 +19,7 @@ import { } from '@/lib/billing/storage' import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' import { canonicalWorkspaceFilePath, decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { generateRestoreName } from '@/lib/core/utils/restore-name' import type { DbOrTx } from '@/lib/db/types' @@ -1072,7 +1073,7 @@ export async function updateWorkspaceFileContent( const fileRecord = await getWorkspaceFile(workspaceId, fileId) if (!fileRecord) { - throw new Error('File not found') + throw new OrchestrationError('not_found', 'File not found') } const storageBillingContext = await resolveStorageBillingContext(workspaceId) @@ -1275,7 +1276,7 @@ export async function renameWorkspaceFile( const fileRecord = await getWorkspaceFile(workspaceId, fileId) if (!fileRecord) { - throw new Error('File not found') + throw new OrchestrationError('not_found', 'File not found') } if (fileRecord.name === normalizedName) { @@ -1432,7 +1433,7 @@ export async function restoreWorkspaceFile(workspaceId: string, fileId: string): const fileRecord = await findWorkspaceFileForLifecycle(db, workspaceId, fileId) if (!fileRecord) { - throw new Error('File not found') + throw new OrchestrationError('not_found', 'File not found') } if (!fileRecord.deletedAt) { diff --git a/apps/sim/lib/workspace-files/orchestration/content.ts b/apps/sim/lib/workspace-files/orchestration/content.ts new file mode 100644 index 00000000000..dacb48be300 --- /dev/null +++ b/apps/sim/lib/workspace-files/orchestration/content.ts @@ -0,0 +1,98 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { + asOrchestrationError, + type OrchestrationErrorCode, + type OrchestrationRequestContext, +} from '@/lib/core/orchestration/types' +import { + ContentVersionConflictError, + updateWorkspaceFileContent, + type WorkspaceFileRecord, +} from '@/lib/uploads/contexts/workspace' + +const logger = createLogger('WorkspaceFileContentOrchestration') + +/** Ceiling on a single content replace, independent of the workspace quota. */ +export const MAX_WORKSPACE_FILE_CONTENT_BYTES = 50 * 1024 * 1024 + +export interface PerformUpdateWorkspaceFileContentParams { + workspaceId: string + fileId: string + userId: string + content: string + encoding: 'utf-8' | 'base64' + actorName?: string + actorEmail?: string + request?: OrchestrationRequestContext +} + +export interface PerformUpdateWorkspaceFileContentResult { + success: boolean + error?: string + errorCode?: OrchestrationErrorCode + file?: WorkspaceFileRecord +} + +/** + * Replaces a workspace file's bytes. + * + * Failures are classified rather than message-matched: the manager throws a + * classified `not_found`, the storage ledger throws `StorageLimitExceededError` + * (a `payload_too_large` {@link OrchestrationError}), and both reach here through + * `asOrchestrationError`, which walks the `cause` chain past drizzle's + * transaction wrapper. + */ +export async function performUpdateWorkspaceFileContent( + params: PerformUpdateWorkspaceFileContentParams +): Promise { + const { workspaceId, fileId, userId, content, encoding, actorName, actorEmail, request } = params + + const buffer = Buffer.from(content, encoding === 'base64' ? 'base64' : 'utf-8') + + if (buffer.length > MAX_WORKSPACE_FILE_CONTENT_BYTES) { + return { + success: false, + error: `File size exceeds ${MAX_WORKSPACE_FILE_CONTENT_BYTES / 1024 / 1024}MB limit`, + errorCode: 'payload_too_large', + } + } + + try { + const file = await updateWorkspaceFileContent(workspaceId, fileId, userId, buffer) + + logger.info('Updated workspace file content', { workspaceId, fileId, size: buffer.length }) + + recordAudit({ + workspaceId, + actorId: userId, + actorName, + actorEmail, + action: AuditAction.FILE_UPDATED, + resourceType: AuditResourceType.FILE, + resourceId: fileId, + resourceName: file.name, + description: `Updated content of file "${file.name}"`, + metadata: { contentSize: buffer.length }, + request, + }) + + return { success: true, file } + } catch (error) { + const classified = asOrchestrationError(error) + if (classified) { + logger.warn('Workspace file content update rejected', { + workspaceId, + fileId, + errorCode: classified.code, + }) + return { success: false, error: classified.message, errorCode: classified.code } + } + if (error instanceof ContentVersionConflictError) { + return { success: false, error: error.message, errorCode: 'conflict' } + } + logger.error('Failed to update workspace file content', { error }) + return { success: false, error: toError(error).message, errorCode: 'internal' } + } +} diff --git a/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts index 7de50ed8b73..876673c4f82 100644 --- a/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts +++ b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts @@ -1,6 +1,7 @@ import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' import { getPostgresErrorCode, toError } from '@sim/utils/errors' +import { asOrchestrationError, type OrchestrationErrorCode } from '@/lib/core/orchestration/types' import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' import { bulkArchiveWorkspaceFileItems, @@ -22,21 +23,6 @@ import { const logger = createLogger('WorkspaceFileFolderLifecycle') -export type WorkspaceFilesOrchestrationErrorCode = - | 'validation' - | 'not_found' - | 'conflict' - | 'internal' - -export function workspaceFilesOrchestrationStatus( - errorCode: WorkspaceFilesOrchestrationErrorCode | undefined -): number { - if (errorCode === 'validation') return 400 - if (errorCode === 'conflict') return 409 - if (errorCode === 'not_found') return 404 - return 500 -} - export interface PerformDeleteWorkspaceFileItemsParams { workspaceId: string userId: string @@ -53,7 +39,7 @@ export interface PerformDeleteWorkspaceFileItemsParams { export interface PerformDeleteWorkspaceFileItemsResult { success: boolean error?: string - errorCode?: WorkspaceFilesOrchestrationErrorCode + errorCode?: OrchestrationErrorCode deletedItems?: WorkspaceFileArchiveResult } @@ -68,7 +54,7 @@ export interface PerformMoveWorkspaceFileItemsParams { export interface PerformMoveWorkspaceFileItemsResult { success: boolean error?: string - errorCode?: WorkspaceFilesOrchestrationErrorCode + errorCode?: OrchestrationErrorCode movedItems?: { files: number; folders: number } } @@ -82,7 +68,7 @@ export interface PerformRenameWorkspaceFileParams { export interface PerformRenameWorkspaceFileResult { success: boolean error?: string - errorCode?: WorkspaceFilesOrchestrationErrorCode + errorCode?: OrchestrationErrorCode file?: WorkspaceFileRecord } @@ -95,7 +81,7 @@ export interface PerformRestoreWorkspaceFileParams { export interface PerformRestoreWorkspaceFileResult { success: boolean error?: string - errorCode?: WorkspaceFilesOrchestrationErrorCode + errorCode?: OrchestrationErrorCode } export interface PerformCreateWorkspaceFileFolderParams { @@ -108,7 +94,7 @@ export interface PerformCreateWorkspaceFileFolderParams { export interface PerformCreateWorkspaceFileFolderResult { success: boolean error?: string - errorCode?: WorkspaceFilesOrchestrationErrorCode + errorCode?: OrchestrationErrorCode folder?: WorkspaceFileFolderRecord } @@ -124,7 +110,7 @@ export interface PerformUpdateWorkspaceFileFolderParams { export interface PerformUpdateWorkspaceFileFolderResult { success: boolean error?: string - errorCode?: WorkspaceFilesOrchestrationErrorCode + errorCode?: OrchestrationErrorCode folder?: WorkspaceFileFolderRecord } @@ -137,7 +123,7 @@ export interface PerformRestoreWorkspaceFileFolderParams { export interface PerformRestoreWorkspaceFileFolderResult { success: boolean error?: string - errorCode?: WorkspaceFilesOrchestrationErrorCode + errorCode?: OrchestrationErrorCode folder?: WorkspaceFileFolderRecord restoredItems?: WorkspaceFileArchiveResult } @@ -316,6 +302,10 @@ export async function performRenameWorkspaceFile( if (error instanceof FileConflictError || getPostgresErrorCode(error) === '23505') { return { success: false, error: toError(error).message, errorCode: 'conflict' } } + const classified = asOrchestrationError(error) + if (classified) { + return { success: false, error: classified.message, errorCode: classified.code } + } return { success: false, error: toError(error).message, errorCode: 'internal' } } } @@ -331,7 +321,7 @@ export interface PerformMoveRenameWorkspaceFileParams { export interface PerformMoveRenameWorkspaceFileResult { success: boolean error?: string - errorCode?: WorkspaceFilesOrchestrationErrorCode + errorCode?: OrchestrationErrorCode file?: WorkspaceFileRecord } @@ -414,6 +404,10 @@ export async function performRestoreWorkspaceFile( if (error instanceof FileConflictError || getPostgresErrorCode(error) === '23505') { return { success: false, error: toError(error).message, errorCode: 'conflict' } } + const classified = asOrchestrationError(error) + if (classified) { + return { success: false, error: classified.message, errorCode: classified.code } + } return { success: false, error: toError(error).message, errorCode: 'internal' } } } diff --git a/apps/sim/lib/workspace-files/orchestration/index.ts b/apps/sim/lib/workspace-files/orchestration/index.ts index 81c7af23352..1940e6c165c 100644 --- a/apps/sim/lib/workspace-files/orchestration/index.ts +++ b/apps/sim/lib/workspace-files/orchestration/index.ts @@ -1,3 +1,9 @@ +export { + MAX_WORKSPACE_FILE_CONTENT_BYTES, + type PerformUpdateWorkspaceFileContentParams, + type PerformUpdateWorkspaceFileContentResult, + performUpdateWorkspaceFileContent, +} from './content' export { type PerformCreateWorkspaceFileFolderParams, type PerformCreateWorkspaceFileFolderResult, @@ -23,6 +29,12 @@ export { performRestoreWorkspaceFile, performRestoreWorkspaceFileFolder, performUpdateWorkspaceFileFolder, - type WorkspaceFilesOrchestrationErrorCode, - workspaceFilesOrchestrationStatus, } from './file-folder-lifecycle' +export { + type PerformGetWorkspaceFileShareParams, + type PerformGetWorkspaceFileShareResult, + type PerformUpsertWorkspaceFileShareParams, + type PerformUpsertWorkspaceFileShareResult, + performGetWorkspaceFileShare, + performUpsertWorkspaceFileShare, +} from './share' diff --git a/apps/sim/lib/workspace-files/orchestration/share.ts b/apps/sim/lib/workspace-files/orchestration/share.ts new file mode 100644 index 00000000000..350a6d9958a --- /dev/null +++ b/apps/sim/lib/workspace-files/orchestration/share.ts @@ -0,0 +1,165 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import type { ShareAuthType, ShareRecord } from '@/lib/api/contracts/public-shares' +import type { + OrchestrationErrorCode, + OrchestrationRequestContext, +} from '@/lib/core/orchestration/types' +import { + getShareForResource, + ShareValidationError, + upsertFileShare, +} from '@/lib/public-shares/share-manager' +import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace' +import { + PublicFileSharingNotAllowedError, + validatePublicFileSharing, +} from '@/ee/access-control/utils/permission-check' + +const logger = createLogger('WorkspaceFileShareOrchestration') + +export interface PerformGetWorkspaceFileShareParams { + workspaceId: string + fileId: string +} + +export interface PerformGetWorkspaceFileShareResult { + success: boolean + error?: string + errorCode?: OrchestrationErrorCode + share?: ShareRecord | null +} + +export interface PerformUpsertWorkspaceFileShareParams { + workspaceId: string + fileId: string + userId: string + isActive: boolean + authType?: ShareAuthType + password?: string + allowedEmails?: string[] + /** + * Caller-reserved share token. Only the session UI supplies one, so it can + * show the public link before the share is saved; the public API never does, + * because a caller-chosen token is both guessable and able to collide with an + * existing row's unique index. Omitted means the manager generates one. + */ + token?: string + actorName?: string + actorEmail?: string + request?: OrchestrationRequestContext +} + +export interface PerformUpsertWorkspaceFileShareResult { + success: boolean + error?: string + errorCode?: OrchestrationErrorCode + share?: ShareRecord +} + +export async function performGetWorkspaceFileShare( + params: PerformGetWorkspaceFileShareParams +): Promise { + const { workspaceId, fileId } = params + + try { + const file = await getWorkspaceFile(workspaceId, fileId) + if (!file) return { success: false, error: 'File not found', errorCode: 'not_found' } + + const share = await getShareForResource('file', fileId) + return { success: true, share } + } catch (error) { + logger.error('Failed to fetch workspace file share', { error }) + return { success: false, error: toError(error).message, errorCode: 'internal' } + } +} + +/** + * Enables or disables a file's public share. + * + * Both the access-control gate and the effective-authType resolution live here + * rather than in a route, so the session surface and the public API cannot + * diverge on either. Disabling is deliberately never gated — a user must be able + * to un-share after the org policy is turned on. + * + * Note that disabling is not revoking: {@link upsertFileShare} preserves the + * token and the stored password / allow-list, so re-enabling resurrects the + * identical URL. + */ +export async function performUpsertWorkspaceFileShare( + params: PerformUpsertWorkspaceFileShareParams +): Promise { + const { + workspaceId, + fileId, + userId, + isActive, + authType, + password, + allowedEmails, + token, + actorName, + actorEmail, + request, + } = params + + try { + const file = await getWorkspaceFile(workspaceId, fileId) + if (!file) return { success: false, error: 'File not found', errorCode: 'not_found' } + + 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 existingShare = await getShareForResource('file', fileId) + const effectiveAuthType = authType ?? existingShare?.authType ?? 'public' + try { + await validatePublicFileSharing(userId, workspaceId, effectiveAuthType) + } catch (error) { + if (error instanceof PublicFileSharingNotAllowedError) { + logger.warn('Public file sharing disabled for workspace', { workspaceId, fileId }) + return { success: false, error: error.message, errorCode: 'forbidden' } + } + throw error + } + } + + const share = await upsertFileShare({ + workspaceId, + fileId, + userId, + isActive, + authType, + password, + allowedEmails, + token, + }) + + logger.info(`${isActive ? 'Enabled' : 'Disabled'} share for file ${fileId}`) + + recordAudit({ + workspaceId, + actorId: userId, + actorName, + actorEmail, + action: isActive ? AuditAction.FILE_SHARED : AuditAction.FILE_SHARE_DISABLED, + resourceType: AuditResourceType.FILE, + resourceId: fileId, + resourceName: file.name, + description: `${isActive ? 'Enabled' : 'Disabled'} public share for "${file.name}"`, + request, + }) + + return { success: true, share } + } catch (error) { + if (error instanceof ShareValidationError) { + return { success: false, error: error.message, errorCode: 'validation' } + } + logger.error('Failed to update workspace file share', { error }) + return { success: false, error: toError(error).message, errorCode: 'internal' } + } +} diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index eebf44d92e8..17542f3f26c 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 1041, - zodRoutes: 1041, + totalRoutes: 1049, + zodRoutes: 1049, nonZodRoutes: 0, } as const From 3d8e489d752a6f83b0250d08e0a93196b0724273 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 1 Aug 2026 02:12:35 -0700 Subject: [PATCH 2/5] fix(files): classify folder and content failures instead of 500ing them Bugbot round 1. The v2 routes map errorCode straight to a status, so every manager failure that arrived unclassified became a 500 for what is really a caller-fixable 400 or 404. - Folder manager throws OrchestrationError: missing target/folder -> not_found, reparent cycle / self-parent / restore-into-archived-workspace -> validation. - File manager does the same for the in-transaction 'File not found' paths that the earlier pass missed. - updateWorkspaceFileContent's outer catch re-wrapped everything in a bare Error, which stripped the class off StorageLimitExceededError and the new not_found alike. It now rethrows a classified failure untouched and attaches cause to the generic wrap, so asOrchestrationError can still walk the chain. - Every remaining perform* gained the asOrchestrationError branch. - renameWorkspaceFile returned the pre-update read, so the v2 PATCH reported a stale updatedAt; it now returns the timestamp it actually wrote. Docs: upload auto-suffixes a duplicate name rather than rejecting it, matching the in-app uploader. The description claimed 409 and was simply wrong. --- apps/docs/openapi-v2-files-audit.json | 6 +- .../workspace-file-folder-manager.ts | 36 +-- .../workspace/workspace-file-manager.ts | 29 ++- .../file-folder-lifecycle.test.ts | 219 ++++++++++++++++++ .../orchestration/file-folder-lifecycle.ts | 20 ++ 5 files changed, 283 insertions(+), 27 deletions(-) create mode 100644 apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.test.ts diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index 35a8b7c55d7..ead364d38cb 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -144,7 +144,7 @@ "post": { "operationId": "uploadFile", "summary": "Upload File", - "description": "Upload a file to a workspace as `multipart/form-data` with a single `file` field. The workspace — and the optional target `folderId` — are supplied as query parameters (not form fields) so authorization runs before the request body is buffered. Maximum file size is 100MB. A name already taken in the destination folder is rejected with `409`; the in-app uploader instead auto-suffixes, so the two surfaces differ here on purpose. Returns `201 Created`.\n\nPresigned upload is not part of the public API: it debits the storage quota only in a separate register step, so a caller that never registers would leave unaccounted bytes in storage. This buffered path debits inside the upload transaction.", + "description": "Upload a file to a workspace as `multipart/form-data` with a single `file` field. The workspace — and the optional target `folderId` — are supplied as query parameters (not form fields) so authorization runs before the request body is buffered. Maximum file size is 100MB. A name already taken in the destination folder is **not** an error: the name is auto-suffixed (`data.csv` -> `data (1).csv`), matching the in-app uploader, so a `201` can come back with a `name` different from the one you sent — always read `name` from the response rather than assuming it. `409` is returned only if a unique name cannot be allocated after several attempts. Use `PATCH /api/v2/files/{fileId}` if you need a specific name to be exact-or-fail. Returns `201 Created`.\n\nPresigned upload is not part of the public API: it debits the storage quota only in a separate register step, so a caller that never registers would leave unaccounted bytes in storage. This buffered path debits inside the upload transaction.", "tags": ["Files"], "x-codeSamples": [ { @@ -247,7 +247,7 @@ "$ref": "#/components/responses/Forbidden" }, "409": { - "description": "A file with the same name already exists in this workspace.", + "description": "A unique filename could not be allocated in the destination folder after several attempts. An ordinary name collision is auto-suffixed instead, not rejected.", "content": { "application/json": { "schema": { @@ -256,7 +256,7 @@ "example": { "error": { "code": "CONFLICT", - "message": "A file with this name already exists in the workspace" + "message": "A file named \"data.csv\" already exists in this workspace" } } } 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 03f58a0d3a3..ca2b80229cc 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 @@ -4,6 +4,7 @@ import { createLogger } from '@sim/logger' import { getPostgresErrorCode } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' import { and, asc, eq, inArray, isNull, min, type SQL, sql } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { deduplicateFolderName } from '@/lib/folders/naming' import { collectDescendantFolderIds } from '@/lib/folders/subtree' import { getWorkspaceWithOwner } from '@/lib/workspaces/permissions/utils' @@ -346,7 +347,7 @@ export async function assertWorkspaceFileFolderTarget( const folder = await getWorkspaceFileFolder(workspaceId, normalized) if (!folder) { - throw new Error('Target folder not found') + throw new OrchestrationError('not_found', 'Target folder not found') } return normalized @@ -380,7 +381,7 @@ export async function createWorkspaceFileFolder(params: { .limit(1) if (!target) { - throw new Error('Target folder not found') + throw new OrchestrationError('not_found', 'Target folder not found') } } @@ -558,7 +559,7 @@ export async function updateWorkspaceFileFolder(params: { ) .limit(1) - if (!existing) throw new Error('Folder not found') + if (!existing) throw new OrchestrationError('not_found', 'Folder not found') const updates: Partial = { updatedAt: new Date() } const finalName = @@ -568,7 +569,8 @@ export async function updateWorkspaceFileFolder(params: { const finalParentId = params.parentId !== undefined ? normalizeParentId(params.parentId) : existing.parentId - if (finalParentId === params.folderId) throw new Error('Folder cannot be its own parent') + if (finalParentId === params.folderId) + throw new OrchestrationError('validation', 'Folder cannot be its own parent') if (finalParentId) { const [target] = await tx @@ -585,7 +587,7 @@ export async function updateWorkspaceFileFolder(params: { .limit(1) if (!target) { - throw new Error('Target folder not found') + throw new OrchestrationError('not_found', 'Target folder not found') } } @@ -603,7 +605,10 @@ export async function updateWorkspaceFileFolder(params: { const descendants = collectDescendantFolderIds(activeFolders, params.folderId) if (finalParentId && descendants.includes(finalParentId)) { - throw new Error('Cannot move a folder into one of its descendants') + throw new OrchestrationError( + 'validation', + 'Cannot move a folder into one of its descendants' + ) } } @@ -653,7 +658,7 @@ export async function updateWorkspaceFileFolder(params: { ) .returning() - if (!updatedFolder) throw new Error('Folder not found') + if (!updatedFolder) throw new OrchestrationError('not_found', 'Folder not found') return updatedFolder } catch (error) { if (getPostgresErrorCode(error) === '23505') { @@ -717,12 +722,12 @@ export async function moveWorkspaceFileItems(params: { .limit(1) if (!target) { - throw new Error('Target folder not found') + throw new OrchestrationError('not_found', 'Target folder not found') } } if (folderIds.includes(targetFolderId ?? '')) { - throw new Error('Cannot move a folder into itself') + throw new OrchestrationError('validation', 'Cannot move a folder into itself') } if (folderIds.length > 0) { @@ -740,7 +745,10 @@ export async function moveWorkspaceFileItems(params: { for (const folderId of folderIds) { const descendants = collectDescendantFolderIds(activeFolders, folderId) if (targetFolderId && descendants.includes(targetFolderId)) { - throw new Error('Cannot move a folder into one of its descendants') + throw new OrchestrationError( + 'validation', + 'Cannot move a folder into one of its descendants' + ) } } } @@ -891,7 +899,7 @@ export async function archiveWorkspaceFileFolderRecursive( ) .limit(1) - if (!folder) throw new Error('Folder not found') + if (!folder) throw new OrchestrationError('not_found', 'Folder not found') const activeFolders = await tx .select({ id: folderTable.id, parentId: folderTable.parentId }) @@ -944,7 +952,7 @@ export async function restoreWorkspaceFileFolder( ): Promise { const ws = await getWorkspaceWithOwner(workspaceId) if (!ws || ws.archivedAt) { - throw new Error('Cannot restore folder into an archived workspace') + throw new OrchestrationError('validation', 'Cannot restore folder into an archived workspace') } const { restored, restoredItems } = await db.transaction(async (tx) => { @@ -959,8 +967,8 @@ export async function restoreWorkspaceFileFolder( .limit(1) .then((rows) => rows[0] ?? null) - if (!raw) throw new Error('Folder not found') - if (!raw.deletedAt) throw new Error('Folder is not archived') + if (!raw) throw new OrchestrationError('not_found', 'Folder not found') + if (!raw.deletedAt) throw new OrchestrationError('validation', 'Folder is not archived') const folderDeletedAt = raw.deletedAt 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 9ea377589e7..86adc692d47 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -19,7 +19,7 @@ import { } from '@/lib/billing/storage' import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' import { canonicalWorkspaceFilePath, decodeVfsPathSegments } from '@/lib/copilot/vfs/path-utils' -import { OrchestrationError } from '@/lib/core/orchestration/types' +import { asOrchestrationError, OrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import { generateRestoreName } from '@/lib/core/utils/restore-name' import type { DbOrTx } from '@/lib/db/types' @@ -1123,7 +1123,7 @@ export async function updateWorkspaceFileContent( .for('update') .limit(1) if (!currentFile) { - throw new Error('File not found') + throw new OrchestrationError('not_found', 'File not found') } // Optimistic-concurrency guard: the row is `FOR UPDATE`-locked, so comparing its committed @@ -1170,7 +1170,7 @@ export async function updateWorkspaceFileContent( ) .returning() if (!updatedFile) { - throw new Error('File not found or could not be updated') + throw new OrchestrationError('not_found', 'File not found or could not be updated') } let updatedUsage: number | undefined @@ -1256,8 +1256,15 @@ export async function updateWorkspaceFileContent( // the optimistic-concurrency guard, not a failure to wrap. The orphan upload was already cleaned up // by the inner finalization catch before it propagated here. if (error instanceof ContentVersionConflictError) throw error + // Same reasoning for an already-classified failure: a missing file and a blown storage quota are + // caller-fixable outcomes that every surface maps to 404/413 by class. Re-wrapping them in a bare + // Error stripped that classification and turned both into a 500. + const classified = asOrchestrationError(error) + if (classified) throw classified logger.error(`Failed to update workspace file content ${fileId}:`, error) - throw new Error(`Failed to update file content: ${getErrorMessage(error, 'Unknown error')}`) + throw new Error(`Failed to update file content: ${getErrorMessage(error, 'Unknown error')}`, { + cause: error, + }) } } @@ -1289,10 +1296,11 @@ export async function renameWorkspaceFile( } let updated: { id: string }[] + const renamedAt = new Date() try { updated = await db .update(workspaceFiles) - .set({ originalName: normalizedName, updatedAt: new Date() }) + .set({ originalName: normalizedName, updatedAt: renamedAt }) .where( and( eq(workspaceFiles.id, fileId), @@ -1309,7 +1317,7 @@ export async function renameWorkspaceFile( } if (updated.length === 0) { - throw new Error('File not found or could not be renamed') + throw new OrchestrationError('not_found', 'File not found or could not be renamed') } logger.info(`Successfully renamed workspace file ${fileId} to "${normalizedName}"`) @@ -1317,6 +1325,7 @@ export async function renameWorkspaceFile( return { ...fileRecord, name: normalizedName, + updatedAt: renamedAt, } } @@ -1337,7 +1346,7 @@ export async function moveRenameWorkspaceFile(params: { const fileRecord = await getWorkspaceFile(params.workspaceId, params.fileId) if (!fileRecord) { - throw new Error('File not found') + throw new OrchestrationError('not_found', 'File not found') } const targetFolderId = await assertWorkspaceFileFolderTarget( @@ -1377,7 +1386,7 @@ export async function moveRenameWorkspaceFile(params: { } if (updated.length === 0) { - throw new Error('File not found or could not be moved') + throw new OrchestrationError('not_found', 'File not found or could not be moved') } return { @@ -1400,7 +1409,7 @@ export async function deleteWorkspaceFile(workspaceId: string, fileId: string): try { const fileRecord = await findWorkspaceFileForLifecycle(db, workspaceId, fileId) if (!fileRecord) { - throw new Error('File not found') + throw new OrchestrationError('not_found', 'File not found') } if (fileRecord.deletedAt) return @@ -1442,7 +1451,7 @@ export async function restoreWorkspaceFile(workspaceId: string, fileId: string): const ws = await getWorkspaceWithOwner(workspaceId) if (!ws || ws.archivedAt) { - throw new Error('Cannot restore file into an archived workspace') + throw new OrchestrationError('validation', 'Cannot restore file into an archived workspace') } /** diff --git a/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.test.ts b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.test.ts new file mode 100644 index 00000000000..21c8be4f630 --- /dev/null +++ b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.test.ts @@ -0,0 +1,219 @@ +/** + * @vitest-environment node + * + * Failure classification. Every `perform*` here is consumed by a public v2 route + * that maps `errorCode` straight to an HTTP status, so a manager failure that + * arrives unclassified silently becomes a 500 for what is really a caller-fixable + * 400 or 404. These pin the mapping rather than the happy paths. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { + mockMoveWorkspaceFileItems, + mockUpdateWorkspaceFileFolder, + mockCreateWorkspaceFileFolder, + mockRestoreWorkspaceFileFolder, + mockRenameWorkspaceFile, + mockRestoreWorkspaceFile, + mockBulkArchive, +} = vi.hoisted(() => ({ + mockMoveWorkspaceFileItems: vi.fn(), + mockUpdateWorkspaceFileFolder: vi.fn(), + mockCreateWorkspaceFileFolder: vi.fn(), + mockRestoreWorkspaceFileFolder: vi.fn(), + mockRenameWorkspaceFile: vi.fn(), + mockRestoreWorkspaceFile: vi.fn(), + mockBulkArchive: vi.fn(), +})) + +vi.mock('@/lib/uploads/contexts/workspace', () => ({ + moveWorkspaceFileItems: mockMoveWorkspaceFileItems, + updateWorkspaceFileFolder: mockUpdateWorkspaceFileFolder, + createWorkspaceFileFolder: mockCreateWorkspaceFileFolder, + restoreWorkspaceFileFolder: mockRestoreWorkspaceFileFolder, + renameWorkspaceFile: mockRenameWorkspaceFile, + restoreWorkspaceFile: mockRestoreWorkspaceFile, + bulkArchiveWorkspaceFileItems: mockBulkArchive, + moveRenameWorkspaceFile: vi.fn(), + FileConflictError: class FileConflictError extends Error {}, + WorkspaceFileFolderConflictError: class WorkspaceFileFolderConflictError extends Error {}, + WorkspaceFileMoveConflictError: class WorkspaceFileMoveConflictError extends Error {}, + WorkspaceFileItemsNotFoundError: class WorkspaceFileItemsNotFoundError extends Error {}, +})) + +vi.mock('@/lib/realtime/notify', () => ({ + notifyWorkspaceFilesChanged: vi.fn(), +})) + +vi.mock('@sim/audit', () => ({ + recordAudit: vi.fn(), + AuditAction: new Proxy({}, { get: (_t, k) => String(k) }), + AuditResourceType: new Proxy({}, { get: (_t, k) => String(k) }), +})) + +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + performCreateWorkspaceFileFolder, + performMoveWorkspaceFileItems, + performRenameWorkspaceFile, + performRestoreWorkspaceFile, + performRestoreWorkspaceFileFolder, + performUpdateWorkspaceFileFolder, +} from '@/lib/workspace-files/orchestration' + +const WS = 'workspace-1' +const USER = 'user-1' + +describe('workspace file orchestration error classification', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('maps a missing move target to not_found, not internal', async () => { + mockMoveWorkspaceFileItems.mockRejectedValue( + new OrchestrationError('not_found', 'Target folder not found') + ) + + const result = await performMoveWorkspaceFileItems({ + workspaceId: WS, + userId: USER, + fileIds: ['wf_1'], + targetFolderId: 'fold_missing', + }) + + expect(result.success).toBe(false) + expect(result.errorCode).toBe('not_found') + expect(result.error).toBe('Target folder not found') + }) + + it('maps a self-descendant move to validation, not internal', async () => { + mockMoveWorkspaceFileItems.mockRejectedValue( + new OrchestrationError('validation', 'Cannot move a folder into one of its descendants') + ) + + const result = await performMoveWorkspaceFileItems({ + workspaceId: WS, + userId: USER, + folderIds: ['fold_1'], + targetFolderId: 'fold_child', + }) + + expect(result.errorCode).toBe('validation') + }) + + it('maps a folder reparent cycle to validation, not internal', async () => { + mockUpdateWorkspaceFileFolder.mockRejectedValue( + new OrchestrationError('validation', 'Cannot move a folder into one of its descendants') + ) + + const result = await performUpdateWorkspaceFileFolder({ + workspaceId: WS, + folderId: 'fold_1', + userId: USER, + parentId: 'fold_child', + }) + + expect(result.errorCode).toBe('validation') + }) + + it('maps a missing folder on update to not_found', async () => { + mockUpdateWorkspaceFileFolder.mockRejectedValue( + new OrchestrationError('not_found', 'Folder not found') + ) + + const result = await performUpdateWorkspaceFileFolder({ + workspaceId: WS, + folderId: 'fold_missing', + userId: USER, + name: 'Q2', + }) + + expect(result.errorCode).toBe('not_found') + }) + + it('maps a missing parent on create to not_found', async () => { + mockCreateWorkspaceFileFolder.mockRejectedValue( + new OrchestrationError('not_found', 'Target folder not found') + ) + + const result = await performCreateWorkspaceFileFolder({ + workspaceId: WS, + userId: USER, + name: 'Q1', + parentId: 'fold_missing', + }) + + expect(result.errorCode).toBe('not_found') + }) + + it('maps restoring into an archived workspace to validation', async () => { + mockRestoreWorkspaceFileFolder.mockRejectedValue( + new OrchestrationError('validation', 'Cannot restore folder into an archived workspace') + ) + + const result = await performRestoreWorkspaceFileFolder({ + workspaceId: WS, + folderId: 'fold_1', + userId: USER, + }) + + expect(result.errorCode).toBe('validation') + }) + + it('maps a missing file on rename to not_found', async () => { + mockRenameWorkspaceFile.mockRejectedValue(new OrchestrationError('not_found', 'File not found')) + + const result = await performRenameWorkspaceFile({ + workspaceId: WS, + fileId: 'wf_missing', + name: 'renamed.csv', + userId: USER, + }) + + expect(result.errorCode).toBe('not_found') + }) + + it('maps a missing file on restore to not_found', async () => { + mockRestoreWorkspaceFile.mockRejectedValue( + new OrchestrationError('not_found', 'File not found') + ) + + const result = await performRestoreWorkspaceFile({ + workspaceId: WS, + fileId: 'wf_missing', + userId: USER, + }) + + expect(result.errorCode).toBe('not_found') + }) + + it('classifies through a wrapper error chain, as drizzle produces inside a transaction', async () => { + const wrapped = new Error('update "folder" set ... failed', { + cause: new OrchestrationError('validation', 'Folder cannot be its own parent'), + }) + mockUpdateWorkspaceFileFolder.mockRejectedValue(wrapped) + + const result = await performUpdateWorkspaceFileFolder({ + workspaceId: WS, + folderId: 'fold_1', + userId: USER, + parentId: 'fold_1', + }) + + expect(result.errorCode).toBe('validation') + expect(result.error).toBe('Folder cannot be its own parent') + }) + + it('leaves a genuinely unexpected fault as internal', async () => { + mockRenameWorkspaceFile.mockRejectedValue(new Error('connection terminated unexpectedly')) + + const result = await performRenameWorkspaceFile({ + workspaceId: WS, + fileId: 'wf_1', + name: 'renamed.csv', + userId: USER, + }) + + expect(result.errorCode).toBe('internal') + }) +}) diff --git a/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts index 876673c4f82..8057fb8a0c8 100644 --- a/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts +++ b/apps/sim/lib/workspace-files/orchestration/file-folder-lifecycle.ts @@ -193,6 +193,10 @@ export async function performDeleteWorkspaceFileItems( return { success: true, deletedItems } } catch (error) { logger.error('Failed to delete workspace file items', { error }) + const classified = asOrchestrationError(error) + if (classified) { + return { success: false, error: classified.message, errorCode: classified.code } + } return { success: false, error: toError(error).message, errorCode: 'internal' } } } @@ -271,6 +275,10 @@ export async function performMoveWorkspaceFileItems( if (error instanceof WorkspaceFileItemsNotFoundError) { return { success: false, error: error.message, errorCode: 'not_found' } } + const classified = asOrchestrationError(error) + if (classified) { + return { success: false, error: classified.message, errorCode: classified.code } + } return { success: false, error: toError(error).message, errorCode: 'internal' } } } @@ -442,6 +450,10 @@ export async function performCreateWorkspaceFileFolder( ) { return { success: false, error: toError(error).message, errorCode: 'conflict' } } + const classified = asOrchestrationError(error) + if (classified) { + return { success: false, error: classified.message, errorCode: classified.code } + } return { success: false, error: toError(error).message, errorCode: 'internal' } } } @@ -489,6 +501,10 @@ export async function performUpdateWorkspaceFileFolder( errorCode: 'conflict', } } + const classified = asOrchestrationError(error) + if (classified) { + return { success: false, error: classified.message, errorCode: classified.code } + } return { success: false, error: toError(error).message, errorCode: 'internal' } } } @@ -530,6 +546,10 @@ export async function performRestoreWorkspaceFileFolder( errorCode: 'conflict', } } + const classified = asOrchestrationError(error) + if (classified) { + return { success: false, error: classified.message, errorCode: classified.code } + } return { success: false, error: toError(error).message, errorCode: 'internal' } } } From 5bb4c37c0ce2e571459e0618e0ef81809cc0f8f7 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 1 Aug 2026 02:15:22 -0700 Subject: [PATCH 3/5] fix(files): surface a failed upload read-back as the real error getWorkspaceFile swallows a query failure and returns null unless throwOnError is set, so a transient blip on the post-upload read reported as 'file could not be read back'. Distinguish the two: a real null after a just-committed write is an invariant break, a query failure is itself. --- apps/sim/app/api/v2/files/route.ts | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/apps/sim/app/api/v2/files/route.ts b/apps/sim/app/api/v2/files/route.ts index 9b46bc63f8a..09740040cae 100644 --- a/apps/sim/app/api/v2/files/route.ts +++ b/apps/sim/app/api/v2/files/route.ts @@ -207,8 +207,15 @@ export const POST = withRouteHandler(async (request: NextRequest) => { * `uploadWorkspaceFile` returns the executor-facing `UserFile`, which carries * neither the folder path nor the persisted timestamps, so the stored record * is the source for the response projection. + * + * `throwOnError` matters here: by default this reader swallows a query + * failure and returns `null`, which would make a transient blip on the read + * indistinguishable from the row being gone. The row was committed by the + * upload moments earlier on the same primary, so a genuine `null` is an + * invariant break — worth a 500 — while a transient failure should surface + * as itself rather than being reported as a missing file. */ - const fileRecord = await getWorkspaceFile(workspaceId, userFile.id) + const fileRecord = await getWorkspaceFile(workspaceId, userFile.id, { throwOnError: true }) if (!fileRecord) { throw new Error(`Uploaded file ${userFile.id} could not be read back`) } From ff1d96432a6c452b4ebabf95b5d42b10543a2b4f Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 1 Aug 2026 10:32:27 -0700 Subject: [PATCH 4/5] revert(api): drop the dedicated v2 file-folder routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit File folders already live in the shared folder table as resourceType 'file' (#6045 cut them over, #6051 dropped workspace_file_folders), and the remaining file-specific folder machinery is being folded into the generic folder engine. Publishing /api/v2/files/folders/** would pin that transitional split into a public contract we'd then have to keep or break. Files stay folder-aware — folderId/folderPath on the projection, folderId on upload, and the move route — because a folder id is a folder.id and survives the unification untouched. Folder management belongs on /api/v2/folders once that surface serves resourceType 'file'; until then there is no v2 way to enumerate file folders, which is the deliberate gap. The orchestration classification fixes stay: the internal routes and the copilot file-folder tools still call those perform* functions. --- .../content/docs/de/api-reference/meta.json | 1 - .../content/docs/en/api-reference/meta.json | 1 - .../content/docs/es/api-reference/meta.json | 1 - .../content/docs/fr/api-reference/meta.json | 1 - .../content/docs/ja/api-reference/meta.json | 1 - .../content/docs/zh/api-reference/meta.json | 1 - apps/docs/openapi-v2-files-audit.json | 647 ------------------ apps/sim/app/api/v1/middleware.ts | 2 - .../folders/[folderId]/restore/route.test.ts | 156 ----- .../files/folders/[folderId]/restore/route.ts | 74 -- .../v2/files/folders/[folderId]/route.test.ts | 236 ------- .../api/v2/files/folders/[folderId]/route.ts | 135 ---- .../app/api/v2/files/folders/route.test.ts | 242 ------- apps/sim/app/api/v2/files/folders/route.ts | 112 --- apps/sim/app/api/v2/files/utils.ts | 24 +- apps/sim/lib/api/contracts/v2/files.ts | 147 +--- scripts/check-api-validation-contracts.ts | 4 +- 17 files changed, 12 insertions(+), 1773 deletions(-) delete mode 100644 apps/sim/app/api/v2/files/folders/[folderId]/restore/route.test.ts delete mode 100644 apps/sim/app/api/v2/files/folders/[folderId]/restore/route.ts delete mode 100644 apps/sim/app/api/v2/files/folders/[folderId]/route.test.ts delete mode 100644 apps/sim/app/api/v2/files/folders/[folderId]/route.ts delete mode 100644 apps/sim/app/api/v2/files/folders/route.test.ts delete mode 100644 apps/sim/app/api/v2/files/folders/route.ts diff --git a/apps/docs/content/docs/de/api-reference/meta.json b/apps/docs/content/docs/de/api-reference/meta.json index 48cd9959de5..d2e994fef8e 100644 --- a/apps/docs/content/docs/de/api-reference/meta.json +++ b/apps/docs/content/docs/de/api-reference/meta.json @@ -14,7 +14,6 @@ "(generated)/audit-logs", "(generated)/tables", "(generated)/files", - "(generated)/file-folders", "(generated)/knowledge-bases", "(generated)/mcp-servers", "(generated)/skills", diff --git a/apps/docs/content/docs/en/api-reference/meta.json b/apps/docs/content/docs/en/api-reference/meta.json index 48cd9959de5..d2e994fef8e 100644 --- a/apps/docs/content/docs/en/api-reference/meta.json +++ b/apps/docs/content/docs/en/api-reference/meta.json @@ -14,7 +14,6 @@ "(generated)/audit-logs", "(generated)/tables", "(generated)/files", - "(generated)/file-folders", "(generated)/knowledge-bases", "(generated)/mcp-servers", "(generated)/skills", diff --git a/apps/docs/content/docs/es/api-reference/meta.json b/apps/docs/content/docs/es/api-reference/meta.json index 48cd9959de5..d2e994fef8e 100644 --- a/apps/docs/content/docs/es/api-reference/meta.json +++ b/apps/docs/content/docs/es/api-reference/meta.json @@ -14,7 +14,6 @@ "(generated)/audit-logs", "(generated)/tables", "(generated)/files", - "(generated)/file-folders", "(generated)/knowledge-bases", "(generated)/mcp-servers", "(generated)/skills", diff --git a/apps/docs/content/docs/fr/api-reference/meta.json b/apps/docs/content/docs/fr/api-reference/meta.json index 48cd9959de5..d2e994fef8e 100644 --- a/apps/docs/content/docs/fr/api-reference/meta.json +++ b/apps/docs/content/docs/fr/api-reference/meta.json @@ -14,7 +14,6 @@ "(generated)/audit-logs", "(generated)/tables", "(generated)/files", - "(generated)/file-folders", "(generated)/knowledge-bases", "(generated)/mcp-servers", "(generated)/skills", diff --git a/apps/docs/content/docs/ja/api-reference/meta.json b/apps/docs/content/docs/ja/api-reference/meta.json index 48cd9959de5..d2e994fef8e 100644 --- a/apps/docs/content/docs/ja/api-reference/meta.json +++ b/apps/docs/content/docs/ja/api-reference/meta.json @@ -14,7 +14,6 @@ "(generated)/audit-logs", "(generated)/tables", "(generated)/files", - "(generated)/file-folders", "(generated)/knowledge-bases", "(generated)/mcp-servers", "(generated)/skills", diff --git a/apps/docs/content/docs/zh/api-reference/meta.json b/apps/docs/content/docs/zh/api-reference/meta.json index 48cd9959de5..d2e994fef8e 100644 --- a/apps/docs/content/docs/zh/api-reference/meta.json +++ b/apps/docs/content/docs/zh/api-reference/meta.json @@ -14,7 +14,6 @@ "(generated)/audit-logs", "(generated)/tables", "(generated)/files", - "(generated)/file-folders", "(generated)/knowledge-bases", "(generated)/mcp-servers", "(generated)/skills", diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index ead364d38cb..dfa8caed854 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -25,10 +25,6 @@ "name": "Files", "description": "Upload, download, list, rename, archive, restore, share, and replace the contents of workspace files (v2). Workspace-scoped via the required workspaceId query parameter or body field." }, - { - "name": "File Folders", - "description": "Organize workspace files into folders (v2). File folders have their own endpoints rather than being served by the generic folders surface." - }, { "name": "Audit Logs", "description": "Query the organization audit trail (v2). Organization-scoped enterprise endpoints requiring an Enterprise subscription and an organization admin or owner role." @@ -1160,500 +1156,6 @@ } } }, - "/api/v2/files/folders": { - "get": { - "operationId": "listFileFolders", - "summary": "List File Folders", - "description": "List a workspace's file-folder tree. The tree is bounded, so it is returned as a single page and `nextCursor` is always `null`. File folders are managed only here — the generic `/api/v2/folders` surface does not serve them, because every file-folder mutation runs behind a per-workspace lock and file-folder names are path segments.", - "tags": ["File Folders"], - "x-codeSamples": [ - { - "id": "curl", - "label": "cURL", - "lang": "bash", - "source": "curl -X GET \\\\\n \"https://www.sim.ai/api/v2/files/folders?workspaceId=YOUR_WORKSPACE_ID\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\"" - } - ], - "parameters": [ - { - "$ref": "#/components/parameters/WorkspaceIdQuery" - }, - { - "name": "scope", - "in": "query", - "required": false, - "description": "`active` (the default) lists live folders; `archived` lists the ones in Recently Deleted, which is how you find an id to restore.", - "schema": { - "type": "string", - "enum": ["active", "archived"], - "default": "active" - } - } - ], - "responses": { - "200": { - "description": "The workspace's file folders.", - "headers": { - "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" - }, - "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" - }, - "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2FileFolderListResponse" - }, - "example": { - "data": [ - { - "id": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu", - "name": "Q1", - "parentId": "fold_3Bd8vT1yQ6sKzMn5Ap7Rw", - "path": "Reports/Q1", - "sortOrder": 0, - "createdAt": "2026-01-15T10:30:00Z", - "updatedAt": "2026-01-15T10:30:00Z", - "deletedAt": null - } - ], - "nextCursor": null - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "429": { - "$ref": "#/components/responses/RateLimited" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - }, - "post": { - "operationId": "createFileFolder", - "summary": "Create File Folder", - "description": "Create a file folder. `parentId: null` — or omitting it — creates the folder at the workspace root. The name becomes a path segment, so `/`, `\\`, `.` and `..` are rejected. A sibling with the same name is rejected with `409`. Returns `201 Created`.", - "tags": ["File Folders"], - "x-codeSamples": [ - { - "id": "curl", - "label": "cURL", - "lang": "bash", - "source": "curl -X POST \\\\\n \"https://www.sim.ai/api/v2/files/folders\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\" \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\", \"name\": \"Q1\"}'" - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["workspaceId", "name"], - "properties": { - "workspaceId": { - "type": "string", - "description": "The workspace to create the folder in.", - "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" - }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "Folder name. Cannot contain `/`, `\\`, or be `.` / `..`.", - "example": "Q1" - }, - "parentId": { - "type": ["string", "null"], - "description": "Parent folder. `null` or omitted creates the folder at the workspace root.", - "example": "fold_3Bd8vT1yQ6sKzMn5Ap7Rw" - } - } - }, - "example": { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "name": "Q1", - "parentId": "fold_3Bd8vT1yQ6sKzMn5Ap7Rw" - } - } - } - }, - "responses": { - "201": { - "description": "The folder was created.", - "headers": { - "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" - }, - "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" - }, - "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2FileFolderResponse" - }, - "example": { - "data": { - "id": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu", - "name": "Q1", - "parentId": "fold_3Bd8vT1yQ6sKzMn5Ap7Rw", - "path": "Reports/Q1", - "sortOrder": 0, - "createdAt": "2026-01-15T10:30:00Z", - "updatedAt": "2026-01-15T10:30:00Z", - "deletedAt": null - } - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "429": { - "$ref": "#/components/responses/RateLimited" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, - "/api/v2/files/folders/{folderId}": { - "patch": { - "operationId": "updateFileFolder", - "summary": "Update File Folder", - "description": "Rename, reparent, or reorder a file folder. At least one of `name`, `parentId`, or `sortOrder` is required; omitted fields keep their stored values. Reparenting is cycle-checked, so making a folder its own descendant is rejected with `400`.", - "tags": ["File Folders"], - "x-codeSamples": [ - { - "id": "curl", - "label": "cURL", - "lang": "bash", - "source": "curl -X PATCH \\\\\n \"https://www.sim.ai/api/v2/files/folders/fold_9Kq2mZ7pR4tLxWc0Ye3Nu\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\" \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\", \"name\": \"Q2\"}'" - } - ], - "parameters": [ - { - "$ref": "#/components/parameters/FileFolderIdPath" - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["workspaceId"], - "properties": { - "workspaceId": { - "type": "string", - "description": "The workspace that owns the folder.", - "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" - }, - "name": { - "type": "string", - "minLength": 1, - "maxLength": 255, - "description": "New folder name. Cannot contain `/`, `\\`, or be `.` / `..`.", - "example": "Q2" - }, - "parentId": { - "type": ["string", "null"], - "description": "New parent folder. `null` moves the folder to the workspace root." - }, - "sortOrder": { - "type": "integer", - "minimum": 0, - "description": "New ordering position among siblings." - } - } - }, - "examples": { - "rename": { - "summary": "Rename", - "value": { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "name": "Q2" - } - }, - "reparent": { - "summary": "Move to the workspace root", - "value": { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64", - "parentId": null - } - } - } - } - } - }, - "responses": { - "200": { - "description": "The updated folder.", - "headers": { - "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" - }, - "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" - }, - "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2FileFolderResponse" - }, - "example": { - "data": { - "id": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu", - "name": "Q2", - "parentId": "fold_3Bd8vT1yQ6sKzMn5Ap7Rw", - "path": "Reports/Q2", - "sortOrder": 0, - "createdAt": "2026-01-15T10:30:00Z", - "updatedAt": "2026-01-15T10:30:00Z", - "deletedAt": null - } - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "429": { - "$ref": "#/components/responses/RateLimited" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - }, - "delete": { - "operationId": "deleteFileFolder", - "summary": "Archive File Folder", - "description": "Archive (soft delete) a folder and everything under it. `deletedItems` counts the whole cascade. Restore it with `POST /api/v2/files/folders/{folderId}/restore`.", - "tags": ["File Folders"], - "x-codeSamples": [ - { - "id": "curl", - "label": "cURL", - "lang": "bash", - "source": "curl -X DELETE \\\\\n \"https://www.sim.ai/api/v2/files/folders/fold_9Kq2mZ7pR4tLxWc0Ye3Nu?workspaceId=YOUR_WORKSPACE_ID\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\"" - } - ], - "parameters": [ - { - "$ref": "#/components/parameters/FileFolderIdPath" - }, - { - "$ref": "#/components/parameters/WorkspaceIdQuery" - } - ], - "responses": { - "200": { - "description": "The folder and its contents were archived.", - "headers": { - "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" - }, - "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" - }, - "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2DeleteFileFolderResponse" - }, - "example": { - "data": { - "id": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu", - "deleted": true, - "deletedItems": { - "files": 3, - "folders": 1 - } - } - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "429": { - "$ref": "#/components/responses/RateLimited" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, - "/api/v2/files/folders/{folderId}/restore": { - "post": { - "operationId": "restoreFileFolder", - "summary": "Restore File Folder", - "description": "Restore an archived folder together with the subtree that was archived with it. `restoredItems.folders` counts the folder itself. Find archived ids with `GET /api/v2/files/folders?scope=archived`.", - "tags": ["File Folders"], - "x-codeSamples": [ - { - "id": "curl", - "label": "cURL", - "lang": "bash", - "source": "curl -X POST \\\\\n \"https://www.sim.ai/api/v2/files/folders/fold_9Kq2mZ7pR4tLxWc0Ye3Nu/restore\" \\\\\n -H \"X-API-Key: YOUR_API_KEY\" \\\\\n -H \"Content-Type: application/json\" \\\\\n -d '{\"workspaceId\": \"YOUR_WORKSPACE_ID\"}'" - } - ], - "parameters": [ - { - "$ref": "#/components/parameters/FileFolderIdPath" - } - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "type": "object", - "required": ["workspaceId"], - "properties": { - "workspaceId": { - "type": "string", - "description": "The workspace that owns the folder.", - "example": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" - } - } - }, - "example": { - "workspaceId": "a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64" - } - } - } - }, - "responses": { - "200": { - "description": "The folder and its subtree were restored.", - "headers": { - "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" - }, - "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" - }, - "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2RestoreFileFolderResponse" - }, - "example": { - "data": { - "folder": { - "id": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu", - "name": "Q1", - "parentId": "fold_3Bd8vT1yQ6sKzMn5Ap7Rw", - "path": "Reports/Q1", - "sortOrder": 0, - "createdAt": "2026-01-15T10:30:00Z", - "updatedAt": "2026-01-15T10:30:00Z", - "deletedAt": null - }, - "restoredItems": { - "files": 3, - "folders": 1 - } - } - } - } - } - }, - "400": { - "$ref": "#/components/responses/BadRequest" - }, - "401": { - "$ref": "#/components/responses/Unauthorized" - }, - "403": { - "$ref": "#/components/responses/Forbidden" - }, - "404": { - "$ref": "#/components/responses/NotFound" - }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "429": { - "$ref": "#/components/responses/RateLimited" - }, - "500": { - "$ref": "#/components/responses/InternalError" - } - } - } - }, "/api/v2/files/{fileId}/share": { "get": { "operationId": "getFileShare", @@ -2041,16 +1543,6 @@ "schema": { "type": "string" } - }, - "FileFolderIdPath": { - "name": "folderId", - "in": "path", - "required": true, - "description": "The unique identifier of the file folder.", - "schema": { - "type": "string", - "example": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu" - } } }, "headers": { @@ -2338,64 +1830,6 @@ } } }, - "V2FileFolder": { - "type": "object", - "description": "A file folder as exposed by the v2 surface. File folders are managed only through `/api/v2/files/folders/**`; the generic `/api/v2/folders` surface does not serve them.", - "required": [ - "id", - "name", - "parentId", - "path", - "sortOrder", - "createdAt", - "updatedAt", - "deletedAt" - ], - "properties": { - "id": { - "type": "string", - "description": "Unique folder identifier.", - "example": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu" - }, - "name": { - "type": "string", - "description": "Folder name. Becomes a path segment, so it cannot contain `/`, `\\`, `.` or `..`.", - "example": "Q1" - }, - "parentId": { - "type": ["string", "null"], - "description": "The parent folder, or null at the workspace root.", - "example": "fold_3Bd8vT1yQ6sKzMn5Ap7Rw" - }, - "path": { - "type": "string", - "description": "Slash-joined names from the workspace root down to and including this folder.", - "example": "Reports/Q1" - }, - "sortOrder": { - "type": "integer", - "description": "Manual ordering position among siblings.", - "example": 0 - }, - "createdAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 creation timestamp.", - "example": "2026-01-15T10:30:00Z" - }, - "updatedAt": { - "type": "string", - "format": "date-time", - "description": "ISO 8601 timestamp of the last update.", - "example": "2026-01-15T10:30:00Z" - }, - "deletedAt": { - "type": ["string", "null"], - "format": "date-time", - "description": "Set when the folder is archived (in Recently Deleted) rather than live." - } - } - }, "V2FileItemCounts": { "type": "object", "description": "Counts of what an operation actually touched. A folder cascades to its descendants, so these exceed the size of the selection.", @@ -2513,39 +1947,6 @@ } } }, - "V2DeleteFileFolderResult": { - "type": "object", - "description": "Acknowledgement returned by archiving a folder, with the cascade totals.", - "required": ["id", "deleted", "deletedItems"], - "properties": { - "id": { - "type": "string", - "description": "The unique identifier of the archived folder.", - "example": "fold_9Kq2mZ7pR4tLxWc0Ye3Nu" - }, - "deleted": { - "type": "boolean", - "const": true, - "description": "Always true on a successful archive." - }, - "deletedItems": { - "$ref": "#/components/schemas/V2FileItemCounts" - } - } - }, - "V2RestoreFileFolderResult": { - "type": "object", - "description": "The restored folder plus what its subtree restore touched. `restoredItems.folders` counts the folder itself.", - "required": ["folder", "restoredItems"], - "properties": { - "folder": { - "$ref": "#/components/schemas/V2FileFolder" - }, - "restoredItems": { - "$ref": "#/components/schemas/V2FileItemCounts" - } - } - }, "V2GetFileShareResult": { "type": "object", "description": "The file's share state, or null when the file has never been shared.", @@ -2574,34 +1975,6 @@ } } }, - "V2FileFolderResponse": { - "type": "object", - "description": "A single file folder.", - "required": ["data"], - "properties": { - "data": { - "$ref": "#/components/schemas/V2FileFolder" - } - } - }, - "V2FileFolderListResponse": { - "type": "object", - "description": "A workspace's file-folder tree. The tree is bounded, so it is always returned as one page and `nextCursor` is always null.", - "required": ["data", "nextCursor"], - "properties": { - "data": { - "type": "array", - "description": "The folders.", - "items": { - "$ref": "#/components/schemas/V2FileFolder" - } - }, - "nextCursor": { - "type": ["string", "null"], - "description": "Always null on this endpoint; present so every v2 list shares one envelope." - } - } - }, "V2RestoreFileResponse": { "type": "object", "description": "The result of restoring a file.", @@ -2632,26 +2005,6 @@ } } }, - "V2DeleteFileFolderResponse": { - "type": "object", - "description": "The result of archiving a folder.", - "required": ["data"], - "properties": { - "data": { - "$ref": "#/components/schemas/V2DeleteFileFolderResult" - } - } - }, - "V2RestoreFileFolderResponse": { - "type": "object", - "description": "The result of restoring a folder.", - "required": ["data"], - "properties": { - "data": { - "$ref": "#/components/schemas/V2RestoreFileFolderResult" - } - } - }, "V2GetFileShareResponse": { "type": "object", "description": "The file's public share state.", diff --git a/apps/sim/app/api/v1/middleware.ts b/apps/sim/app/api/v1/middleware.ts index 50272b153a6..5fb64caf1df 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -40,8 +40,6 @@ export type ApiEndpoint = | 'table-columns' | 'files' | 'file-detail' - | 'file-folders' - | 'file-folder-detail' | 'file-share' | 'file-content' | 'file-move' diff --git a/apps/sim/app/api/v2/files/folders/[folderId]/restore/route.test.ts b/apps/sim/app/api/v2/files/folders/[folderId]/restore/route.test.ts deleted file mode 100644 index ce9d026e6ac..00000000000 --- a/apps/sim/app/api/v2/files/folders/[folderId]/restore/route.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -/** - * @vitest-environment node - */ -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockCheckRateLimit, mockResolveWorkspaceAccess, mockPerformRestore } = vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockPerformRestore: 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/workspace-files/orchestration', () => ({ - performRestoreWorkspaceFileFolder: mockPerformRestore, -})) - -import { POST } from '@/app/api/v2/files/folders/[folderId]/restore/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 FOLDER = { - id: FOLDER_ID, - workspaceId: WS, - userId: 'user-1', - name: 'Q1', - parentId: null, - path: 'Q1', - sortOrder: 0, - deletedAt: null, - createdAt: new Date('2024-01-01T00:00:00Z'), - updatedAt: new Date('2024-01-02T00:00:00Z'), -} - -const callRestore = (body: unknown) => - POST( - new NextRequest(`http://localhost:3000/api/v2/files/folders/${FOLDER_ID}/restore`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }), - { params: Promise.resolve({ folderId: FOLDER_ID }) } - ) - -describe('POST /api/v2/files/folders/[folderId]/restore', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockPerformRestore.mockResolvedValue({ - success: true, - folder: FOLDER, - restoredItems: { files: 3, folders: 1 }, - }) - }) - - 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 callRestore({ workspaceId: WS }) - - expect(res.status).toBe(404) - expect(mockPerformRestore).not.toHaveBeenCalled() - }) - - it('400s when workspaceId is missing', async () => { - const res = await callRestore({}) - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(mockPerformRestore).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 callRestore({ workspaceId: WS }) - expect(res.status).toBe(403) - expect(mockPerformRestore).not.toHaveBeenCalled() - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callRestore({ workspaceId: WS }) - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') - }) - - it('restores the folder and its subtree', async () => { - const res = await callRestore({ workspaceId: WS }) - const body = await res.json() - - expect(res.status).toBe(200) - expect(body.data).toEqual({ - folder: { - id: FOLDER_ID, - name: 'Q1', - parentId: null, - path: 'Q1', - sortOrder: 0, - createdAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-02T00:00:00.000Z', - deletedAt: null, - }, - restoredItems: { files: 3, folders: 1 }, - }) - expect(mockPerformRestore).toHaveBeenCalledWith({ - workspaceId: WS, - folderId: FOLDER_ID, - userId: 'user-1', - }) - }) - - it('maps a conflict errorCode to 409 when the name is taken again', async () => { - mockPerformRestore.mockResolvedValue({ - success: false, - error: 'A folder with this name already exists in this location', - errorCode: 'conflict', - }) - - const res = await callRestore({ workspaceId: WS }) - - expect(res.status).toBe(409) - expect((await res.json()).error.code).toBe('CONFLICT') - }) -}) diff --git a/apps/sim/app/api/v2/files/folders/[folderId]/restore/route.ts b/apps/sim/app/api/v2/files/folders/[folderId]/restore/route.ts deleted file mode 100644 index 9c185da6c38..00000000000 --- a/apps/sim/app/api/v2/files/folders/[folderId]/restore/route.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' -import { v2RestoreFileFolderContract } from '@/lib/api/contracts/v2/files' -import { parseRequest } from '@/lib/api/server' -import { messageForOrchestrationError } from '@/lib/core/orchestration/types' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { performRestoreWorkspaceFileFolder } from '@/lib/workspace-files/orchestration' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { toV2FileFolder } from '@/app/api/v2/files/utils' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2Data, - v2Error, - v2ErrorForOrchestration, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2FileFolderRestoreAPI') - -export const dynamic = 'force-dynamic' -export const revalidate = 0 - -interface FolderRouteParams { - params: Promise<{ folderId: string }> -} - -/** - * POST /api/v2/files/folders/[folderId]/restore — Restore an archived folder. - * - * Restores the whole subtree that was archived with it; `restoredItems` counts - * the folder itself among `folders`. - */ -export const POST = withRouteHandler(async (request: NextRequest, context: FolderRouteParams) => { - try { - const rateLimit = await checkRateLimit(request, 'file-folder-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2RestoreFileFolderContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { folderId } = parsed.data.params - const { workspaceId } = parsed.data.body - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - const result = await performRestoreWorkspaceFileFolder({ workspaceId, folderId, userId }) - - if (!result.success || !result.folder || !result.restoredItems) { - return v2ErrorForOrchestration( - result.errorCode, - messageForOrchestrationError(result, 'Failed to restore folder') - ) - } - - return v2Data( - { folder: toV2FileFolder(result.folder), restoredItems: result.restoredItems }, - { rateLimit } - ) - } catch (error) { - logger.error('Error restoring file folder', { error: getErrorMessage(error, 'Unknown error') }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } -}) diff --git a/apps/sim/app/api/v2/files/folders/[folderId]/route.test.ts b/apps/sim/app/api/v2/files/folders/[folderId]/route.test.ts deleted file mode 100644 index 601c5e1ae96..00000000000 --- a/apps/sim/app/api/v2/files/folders/[folderId]/route.test.ts +++ /dev/null @@ -1,236 +0,0 @@ -/** - * @vitest-environment node - */ -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockCheckRateLimit, mockResolveWorkspaceAccess, mockPerformUpdate, mockPerformDelete } = - vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockPerformUpdate: vi.fn(), - mockPerformDelete: 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/workspace-files/orchestration', () => ({ - performUpdateWorkspaceFileFolder: mockPerformUpdate, - performDeleteWorkspaceFileItems: mockPerformDelete, -})) - -import { DELETE, PATCH } from '@/app/api/v2/files/folders/[folderId]/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 FOLDER = { - id: FOLDER_ID, - workspaceId: WS, - userId: 'user-1', - name: 'Q2', - parentId: null, - path: 'Q2', - sortOrder: 0, - deletedAt: null, - createdAt: new Date('2024-01-01T00:00:00Z'), - updatedAt: new Date('2024-01-02T00:00:00Z'), -} - -const ctx = { params: Promise.resolve({ folderId: FOLDER_ID }) } - -const callPatch = (body: unknown) => - PATCH( - new NextRequest(`http://localhost:3000/api/v2/files/folders/${FOLDER_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/folders/${FOLDER_ID}?${query}`), ctx) - -describe('PATCH /api/v2/files/folders/[folderId]', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockPerformUpdate.mockResolvedValue({ success: true, folder: FOLDER }) - }) - - 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 callPatch({ workspaceId: WS, name: 'Q2' }) - - expect(res.status).toBe(404) - expect(mockPerformUpdate).not.toHaveBeenCalled() - }) - - it('400s when no updatable field is supplied', async () => { - const res = await callPatch({ workspaceId: WS }) - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(mockPerformUpdate).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 callPatch({ workspaceId: WS, name: 'Q2' }) - expect(res.status).toBe(403) - expect(mockPerformUpdate).not.toHaveBeenCalled() - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callPatch({ workspaceId: WS, name: 'Q2' }) - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') - }) - - it('renames the folder and returns the public shape', async () => { - const res = await callPatch({ workspaceId: WS, name: 'Q2' }) - const body = await res.json() - - expect(res.status).toBe(200) - expect(body.data).toEqual({ - id: FOLDER_ID, - name: 'Q2', - parentId: null, - path: 'Q2', - sortOrder: 0, - createdAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-02T00:00:00.000Z', - deletedAt: null, - }) - expect(mockPerformUpdate).toHaveBeenCalledWith({ - workspaceId: WS, - folderId: FOLDER_ID, - userId: 'user-1', - name: 'Q2', - parentId: undefined, - sortOrder: undefined, - }) - }) - - it('maps a validation errorCode (e.g. a reparent cycle) to 400', async () => { - mockPerformUpdate.mockResolvedValue({ - success: false, - error: 'A folder cannot be moved into its own descendant', - errorCode: 'validation', - }) - - const res = await callPatch({ workspaceId: WS, parentId: 'fold_child' }) - const body = await res.json() - - expect(res.status).toBe(400) - expect(body.error.message).toContain('descendant') - }) -}) - -describe('DELETE /api/v2/files/folders/[folderId]', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockPerformDelete.mockResolvedValue({ success: true, deletedItems: { files: 3, folders: 1 } }) - }) - - 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}`) - - expect(res.status).toBe(404) - expect(mockPerformDelete).not.toHaveBeenCalled() - }) - - it('400s when workspaceId is missing', async () => { - const res = await callDelete('') - expect(res.status).toBe(400) - expect(mockPerformDelete).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 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 folder and reports the cascade', async () => { - const res = await callDelete(`workspaceId=${WS}`) - const body = await res.json() - - expect(res.status).toBe(200) - expect(body.data).toEqual({ - id: FOLDER_ID, - deleted: true, - deletedItems: { files: 3, folders: 1 }, - }) - expect(mockPerformDelete).toHaveBeenCalledWith({ - workspaceId: WS, - userId: 'user-1', - folderIds: [FOLDER_ID], - request: expect.anything(), - }) - }) - - it('maps a not_found errorCode to 404', async () => { - mockPerformDelete.mockResolvedValue({ - success: false, - error: 'Folder not found', - errorCode: 'not_found', - }) - - 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/folders/[folderId]/route.ts b/apps/sim/app/api/v2/files/folders/[folderId]/route.ts deleted file mode 100644 index 2239b8eb4a6..00000000000 --- a/apps/sim/app/api/v2/files/folders/[folderId]/route.ts +++ /dev/null @@ -1,135 +0,0 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' -import { - v2DeleteFileFolderContract, - v2UpdateFileFolderContract, -} from '@/lib/api/contracts/v2/files' -import { parseRequest } from '@/lib/api/server' -import { messageForOrchestrationError } from '@/lib/core/orchestration/types' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { - performDeleteWorkspaceFileItems, - performUpdateWorkspaceFileFolder, -} from '@/lib/workspace-files/orchestration' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { toV2FileFolder } from '@/app/api/v2/files/utils' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2Data, - v2Error, - v2ErrorForOrchestration, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2FileFolderDetailAPI') - -export const dynamic = 'force-dynamic' -export const revalidate = 0 - -interface FolderRouteParams { - params: Promise<{ folderId: string }> -} - -/** - * PATCH /api/v2/files/folders/[folderId] — Rename, reparent, or reorder a folder. - * - * Reparenting is cycle-checked inside the workspace's file-folder advisory lock, - * so making a folder its own descendant fails as `BAD_REQUEST` rather than - * corrupting the tree. - */ -export const PATCH = withRouteHandler(async (request: NextRequest, context: FolderRouteParams) => { - try { - const rateLimit = await checkRateLimit(request, 'file-folder-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2UpdateFileFolderContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { folderId } = parsed.data.params - const { workspaceId, name, parentId, sortOrder } = parsed.data.body - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - const result = await performUpdateWorkspaceFileFolder({ - workspaceId, - folderId, - userId, - name, - parentId, - sortOrder, - }) - - if (!result.success || !result.folder) { - return v2ErrorForOrchestration( - result.errorCode, - messageForOrchestrationError(result, 'Failed to update folder') - ) - } - - return v2Data(toV2FileFolder(result.folder), { rateLimit }) - } catch (error) { - logger.error('Error updating file folder', { error: getErrorMessage(error, 'Unknown error') }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } -}) - -/** - * DELETE /api/v2/files/folders/[folderId] — Archive a folder and its contents. - * - * `deletedItems` counts the whole cascade, not just this folder. - */ -export const DELETE = withRouteHandler(async (request: NextRequest, context: FolderRouteParams) => { - try { - const rateLimit = await checkRateLimit(request, 'file-folder-detail') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest(v2DeleteFileFolderContract, request, context, { - validationErrorResponse: v2ValidationError, - }) - if (!parsed.success) return parsed.response - - const { folderId } = parsed.data.params - const { workspaceId } = parsed.data.query - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - const result = await performDeleteWorkspaceFileItems({ - workspaceId, - userId, - folderIds: [folderId], - request, - }) - - if (!result.success || !result.deletedItems) { - return v2ErrorForOrchestration( - result.errorCode, - messageForOrchestrationError(result, 'Failed to delete folder') - ) - } - - return v2Data( - { id: folderId, deleted: true as const, deletedItems: result.deletedItems }, - { rateLimit } - ) - } catch (error) { - logger.error('Error deleting file folder', { error: getErrorMessage(error, 'Unknown error') }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } -}) diff --git a/apps/sim/app/api/v2/files/folders/route.test.ts b/apps/sim/app/api/v2/files/folders/route.test.ts deleted file mode 100644 index 28d6cb6c9a5..00000000000 --- a/apps/sim/app/api/v2/files/folders/route.test.ts +++ /dev/null @@ -1,242 +0,0 @@ -/** - * @vitest-environment node - * - * Public v2 file folders. These are a separate surface from `/api/v2/folders` - * on purpose, so the name rules the generic folder engine does not enforce - * (path separators, dot segments) are pinned here. - */ -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockCheckRateLimit, mockResolveWorkspaceAccess, mockListFolders, mockPerformCreate } = - vi.hoisted(() => ({ - mockCheckRateLimit: vi.fn(), - mockResolveWorkspaceAccess: vi.fn(), - mockListFolders: vi.fn(), - mockPerformCreate: 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/contexts/workspace', () => ({ - listWorkspaceFileFolders: mockListFolders, -})) - -vi.mock('@/lib/workspace-files/orchestration', () => ({ - performCreateWorkspaceFileFolder: mockPerformCreate, -})) - -import { GET, POST } from '@/app/api/v2/files/folders/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, -} - -function buildFolder(overrides: Record = {}) { - return { - id: 'fold_1', - workspaceId: WS, - userId: 'user-1', - name: 'Q1', - parentId: null, - path: 'Q1', - sortOrder: 0, - deletedAt: null, - createdAt: new Date('2024-01-01T00:00:00Z'), - updatedAt: new Date('2024-01-02T00:00:00Z'), - ...overrides, - } -} - -const callList = (query: string) => - GET(new NextRequest(`http://localhost:3000/api/v2/files/folders?${query}`)) - -const callCreate = (body: unknown) => - POST( - new NextRequest('http://localhost:3000/api/v2/files/folders', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(body), - }) - ) - -describe('GET /api/v2/files/folders', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockListFolders.mockResolvedValue([buildFolder()]) - }) - - 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(mockListFolders).not.toHaveBeenCalled() - }) - - it('400s when workspaceId is missing', async () => { - const res = await callList('scope=active') - expect(res.status).toBe(400) - expect((await res.json()).error.code).toBe('BAD_REQUEST') - expect(mockListFolders).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 callList(`workspaceId=${WS}`) - expect(res.status).toBe(403) - expect(mockListFolders).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 folder shape without internal scoping columns', async () => { - 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: 'fold_1', - name: 'Q1', - parentId: null, - path: 'Q1', - sortOrder: 0, - createdAt: '2024-01-01T00:00:00.000Z', - updatedAt: '2024-01-02T00:00:00.000Z', - deletedAt: null, - }, - ]) - expect(mockListFolders).toHaveBeenCalledWith(WS, { scope: 'active' }) - }) - - it('passes scope=archived through to the manager', async () => { - mockListFolders.mockResolvedValue([ - buildFolder({ id: 'fold_gone', deletedAt: new Date('2024-02-01T00:00:00Z') }), - ]) - - const res = await callList(`workspaceId=${WS}&scope=archived`) - const body = await res.json() - - expect(mockListFolders).toHaveBeenCalledWith(WS, { scope: 'archived' }) - expect(body.data[0].deletedAt).toBe('2024-02-01T00:00:00.000Z') - }) -}) - -describe('POST /api/v2/files/folders', () => { - beforeEach(() => { - vi.clearAllMocks() - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_OK) - mockResolveWorkspaceAccess.mockResolvedValue(null) - mockPerformCreate.mockResolvedValue({ success: true, folder: buildFolder() }) - }) - - 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 callCreate({ workspaceId: WS, name: 'Q1' }) - - expect(res.status).toBe(404) - expect(mockPerformCreate).not.toHaveBeenCalled() - }) - - it('400s when name is missing', async () => { - const res = await callCreate({ workspaceId: WS }) - expect(res.status).toBe(400) - expect(mockPerformCreate).not.toHaveBeenCalled() - }) - - it('400s on a name containing a path separator or a dot segment', async () => { - for (const name of ['Reports/Q1', 'Reports\\Q1', '..', '.']) { - const res = await callCreate({ workspaceId: WS, name }) - expect(res.status).toBe(400) - } - expect(mockPerformCreate).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 callCreate({ workspaceId: WS, name: 'Q1' }) - expect(res.status).toBe(403) - expect(mockPerformCreate).not.toHaveBeenCalled() - }) - - it('returns the rate-limit response when denied', async () => { - mockCheckRateLimit.mockResolvedValue(RATE_LIMIT_DENIED) - const res = await callCreate({ workspaceId: WS, name: 'Q1' }) - expect(res.status).toBe(429) - expect((await res.json()).error.code).toBe('RATE_LIMITED') - }) - - it('creates the folder and returns 201', async () => { - const res = await callCreate({ workspaceId: WS, name: 'Q1' }) - const body = await res.json() - - expect(res.status).toBe(201) - expect(body.data.id).toBe('fold_1') - expect(body.data).not.toHaveProperty('workspaceId') - expect(body.data).not.toHaveProperty('userId') - expect(mockPerformCreate).toHaveBeenCalledWith({ - workspaceId: WS, - userId: 'user-1', - name: 'Q1', - parentId: null, - }) - }) - - it('maps a conflict errorCode to 409', async () => { - mockPerformCreate.mockResolvedValue({ - success: false, - error: 'A folder named "Q1" already exists in this location', - errorCode: 'conflict', - }) - - const res = await callCreate({ workspaceId: WS, name: 'Q1' }) - - expect(res.status).toBe(409) - expect((await res.json()).error.code).toBe('CONFLICT') - }) -}) diff --git a/apps/sim/app/api/v2/files/folders/route.ts b/apps/sim/app/api/v2/files/folders/route.ts deleted file mode 100644 index 60952579e76..00000000000 --- a/apps/sim/app/api/v2/files/folders/route.ts +++ /dev/null @@ -1,112 +0,0 @@ -import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' -import type { NextRequest } from 'next/server' -import { v2CreateFileFolderContract, v2ListFileFoldersContract } from '@/lib/api/contracts/v2/files' -import { parseRequest } from '@/lib/api/server' -import { messageForOrchestrationError } from '@/lib/core/orchestration/types' -import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { listWorkspaceFileFolders } from '@/lib/uploads/contexts/workspace' -import { performCreateWorkspaceFileFolder } from '@/lib/workspace-files/orchestration' -import { checkRateLimit, resolveWorkspaceAccess } from '@/app/api/v1/middleware' -import { toV2FileFolder } from '@/app/api/v2/files/utils' -import { v2ApiGateError } from '@/app/api/v2/lib/gate' -import { - v2CursorList, - v2Data, - v2Error, - v2ErrorForOrchestration, - v2RateLimitError, - v2ValidationError, - v2WorkspaceAccessError, -} from '@/app/api/v2/lib/response' - -const logger = createLogger('V2FileFoldersAPI') - -export const dynamic = 'force-dynamic' -export const revalidate = 0 - -/** - * GET /api/v2/files/folders — List a workspace's file-folder tree. - * - * File folders live in the `folder` table but are deliberately not served by - * `/api/v2/folders`: their mutations run behind a per-workspace advisory lock - * and their names are path segments, neither of which the generic folder engine - * enforces. - */ -export const GET = withRouteHandler(async (request: NextRequest) => { - try { - const rateLimit = await checkRateLimit(request, 'file-folders') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2ListFileFoldersContract, - request, - {}, - { validationErrorResponse: v2ValidationError } - ) - if (!parsed.success) return parsed.response - - const { workspaceId, scope } = parsed.data.query - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'read') - if (access) return v2WorkspaceAccessError(access) - - const folders = await listWorkspaceFileFolders(workspaceId, { scope }) - - // One workspace's file-folder tree is bounded → a single full page. - return v2CursorList(folders.map(toV2FileFolder), null, { rateLimit }) - } catch (error) { - logger.error('Error listing file folders', { error: getErrorMessage(error, 'Unknown error') }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } -}) - -/** POST /api/v2/files/folders — Create a file folder. */ -export const POST = withRouteHandler(async (request: NextRequest) => { - try { - const rateLimit = await checkRateLimit(request, 'file-folders') - if (!rateLimit.allowed) return v2RateLimitError(rateLimit) - - const userId = rateLimit.userId! - - const gate = await v2ApiGateError(userId) - if (gate) return gate - - const parsed = await parseRequest( - v2CreateFileFolderContract, - request, - {}, - { validationErrorResponse: v2ValidationError } - ) - if (!parsed.success) return parsed.response - - const { workspaceId, name, parentId } = parsed.data.body - - const access = await resolveWorkspaceAccess(rateLimit, userId, workspaceId, 'write') - if (access) return v2WorkspaceAccessError(access) - - const result = await performCreateWorkspaceFileFolder({ - workspaceId, - userId, - name, - parentId: parentId ?? null, - }) - - if (!result.success || !result.folder) { - return v2ErrorForOrchestration( - result.errorCode, - messageForOrchestrationError(result, 'Failed to create folder') - ) - } - - return v2Data(toV2FileFolder(result.folder), { rateLimit, status: 201 }) - } catch (error) { - logger.error('Error creating file folder', { error: getErrorMessage(error, 'Unknown error') }) - return v2Error('INTERNAL_ERROR', 'Internal server error') - } -}) diff --git a/apps/sim/app/api/v2/files/utils.ts b/apps/sim/app/api/v2/files/utils.ts index 43b855415ed..c49966ce7f2 100644 --- a/apps/sim/app/api/v2/files/utils.ts +++ b/apps/sim/app/api/v2/files/utils.ts @@ -1,8 +1,5 @@ -import type { V2File, V2FileFolder } from '@/lib/api/contracts/v2/files' -import type { - WorkspaceFileFolderRecord, - WorkspaceFileRecord, -} from '@/lib/uploads/contexts/workspace' +import type { V2File } from '@/lib/api/contracts/v2/files' +import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' /** Shared serialization for the v2 files surface. */ @@ -24,20 +21,3 @@ export function toV2File(record: WorkspaceFileRecord): V2File { updatedAt: record.updatedAt.toISOString(), } } - -/** - * Public file-folder projection, mirroring {@link toV2File}: `workspaceId` and - * `userId` (the creator) are internal scoping columns and stay off the wire. - */ -export function toV2FileFolder(record: WorkspaceFileFolderRecord): V2FileFolder { - return { - id: record.id, - name: record.name, - parentId: record.parentId, - path: record.path, - sortOrder: record.sortOrder, - createdAt: record.createdAt.toISOString(), - updatedAt: record.updatedAt.toISOString(), - deletedAt: record.deletedAt?.toISOString() ?? null, - } -} diff --git a/apps/sim/lib/api/contracts/v2/files.ts b/apps/sim/lib/api/contracts/v2/files.ts index c365ece3313..52fbeadd284 100644 --- a/apps/sim/lib/api/contracts/v2/files.ts +++ b/apps/sim/lib/api/contracts/v2/files.ts @@ -11,12 +11,14 @@ import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/sha * param — including on upload — so the route can authorize before reading the * multipart body. * - * File folders are NOT served by the generic `/api/v2/folders` surface: every - * file-folder mutation runs behind the `workspace_file_folders:${workspaceId}` - * advisory lock that makes its cycle and name checks atomic, and a file-folder - * name becomes a path segment so `/`, `\`, `.` and `..` are forbidden. Neither - * holds for the generic folder engine, so file folders get their own routes - * under `/api/v2/files/folders/**`. + * Folders are referenced but not managed here. A file carries `folderId` / + * `folderPath`, and `move` retargets it, but there are deliberately no + * folder-CRUD routes on this surface: file folders already live in the shared + * `folder` table (`resourceType: 'file'`), and the remaining file-specific + * folder machinery is being folded into the generic folder engine. Publishing + * `/api/v2/files/folders/**` would pin a transitional split into a public + * contract; folder management belongs on `/api/v2/folders` once that surface + * serves `resourceType: 'file'`. * * Presigned upload is deliberately absent. Presign only performs an advisory * quota pre-check; the storage debit happens in the separate register step, so @@ -45,26 +47,6 @@ export const v2FileSchema = z.object({ export type V2File = z.output -/** - * A file folder as exposed by the v2 surface. `workspaceId` (already known to - * the caller, who supplied it) and `userId` (the creator) are internal scoping - * columns and are not exposed, matching the v2 folders projection. - */ -export const v2FileFolderSchema = z.object({ - id: z.string(), - name: z.string(), - parentId: z.string().nullable(), - /** Slash-joined names from the workspace root down to and including this folder. */ - path: z.string(), - sortOrder: z.number(), - createdAt: z.string(), - updatedAt: z.string(), - /** Set when the folder is archived (in Recently Deleted) rather than live. */ - deletedAt: z.string().nullable(), -}) - -export type V2FileFolder = z.output - /** Acknowledgement returned by a successful archive (soft delete). */ export const v2DeleteFileResultSchema = z.object({ id: z.string(), @@ -87,12 +69,6 @@ export const v2FileParamsSchema = z.object({ export type V2FileParams = z.output -export const v2FileFolderParamsSchema = z.object({ - folderId: z.string().min(1, 'Folder ID is required'), -}) - -export type V2FileFolderParams = z.output - /** `active` lists live items; `archived` lists Recently Deleted. */ export const v2FileScopeSchema = z.enum(['active', 'archived']) @@ -231,60 +207,6 @@ export const v2BulkArchiveFileItemsResultSchema = z.object({ export type V2BulkArchiveFileItemsResult = z.output -export const v2ListFileFoldersQuerySchema = z.object({ - workspaceId: workspaceIdSchema, - scope: v2FileScopeSchema.default('active'), -}) - -export type V2ListFileFoldersQuery = z.output - -export const v2CreateFileFolderBodySchema = z - .object({ - workspaceId: workspaceIdSchema, - name: v2FileItemNameSchema, - /** Explicit `null` creates the folder at the workspace root. */ - parentId: z.string().min(1, 'parentId cannot be empty').nullable().optional(), - }) - .strict() - -export type V2CreateFileFolderBody = z.input - -/** Update body. Omitted fields keep their stored values. */ -export const v2UpdateFileFolderBodySchema = z - .object({ - workspaceId: workspaceIdSchema, - name: v2FileItemNameSchema.optional(), - parentId: z.string().min(1, 'parentId cannot be empty').nullable().optional(), - sortOrder: z.number().int('sortOrder must be an integer').min(0).optional(), - }) - .strict() - .superRefine((body, ctx) => { - if (body.name === undefined && body.parentId === undefined && body.sortOrder === undefined) { - ctx.addIssue({ - code: 'custom', - path: ['name'], - message: 'At least one of name, parentId, or sortOrder is required', - }) - } - }) - -export type V2UpdateFileFolderBody = z.input - -export const v2DeleteFileFolderResultSchema = z.object({ - id: z.string(), - deleted: z.literal(true), - deletedItems: v2FileItemCountsSchema, -}) - -export type V2DeleteFileFolderResult = z.output - -export const v2RestoreFileFolderResultSchema = z.object({ - folder: v2FileFolderSchema, - restoredItems: v2FileItemCountsSchema, -}) - -export type V2RestoreFileFolderResult = z.output - /** * Public share state. Reuses the internal {@link shareRecordSchema}, which is * already public-safe — `hasPassword` is a boolean and neither the ciphertext @@ -430,59 +352,6 @@ export const v2BulkArchiveFileItemsContract = defineRouteContract({ }, }) -export const v2ListFileFoldersContract = defineRouteContract({ - method: 'GET', - path: '/api/v2/files/folders', - query: v2ListFileFoldersQuerySchema, - response: { - mode: 'json', - schema: v2CursorListResponse(v2FileFolderSchema), - }, -}) - -export const v2CreateFileFolderContract = defineRouteContract({ - method: 'POST', - path: '/api/v2/files/folders', - body: v2CreateFileFolderBodySchema, - response: { - mode: 'json', - schema: v2DataResponse(v2FileFolderSchema), - }, -}) - -export const v2UpdateFileFolderContract = defineRouteContract({ - method: 'PATCH', - path: '/api/v2/files/folders/[folderId]', - params: v2FileFolderParamsSchema, - body: v2UpdateFileFolderBodySchema, - response: { - mode: 'json', - schema: v2DataResponse(v2FileFolderSchema), - }, -}) - -export const v2DeleteFileFolderContract = defineRouteContract({ - method: 'DELETE', - path: '/api/v2/files/folders/[folderId]', - params: v2FileFolderParamsSchema, - query: v2FileWorkspaceQuerySchema, - response: { - mode: 'json', - schema: v2DataResponse(v2DeleteFileFolderResultSchema), - }, -}) - -export const v2RestoreFileFolderContract = defineRouteContract({ - method: 'POST', - path: '/api/v2/files/folders/[folderId]/restore', - params: v2FileFolderParamsSchema, - body: v2WorkspaceScopedBodySchema, - response: { - mode: 'json', - schema: v2DataResponse(v2RestoreFileFolderResultSchema), - }, -}) - export const v2GetFileShareContract = defineRouteContract({ method: 'GET', path: '/api/v2/files/[fileId]/share', diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 17542f3f26c..c52c7f59a55 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 1049, - zodRoutes: 1049, + totalRoutes: 1046, + zodRoutes: 1046, nonZodRoutes: 0, } as const From 850f742a5f5b1f9d79862ed67bb59e953b2f9f24 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Sat, 1 Aug 2026 11:08:47 -0700 Subject: [PATCH 5/5] fix(files): classify upload failures instead of matching their wording Bugbot round 2. uploadWorkspaceFile had the same outer-catch rewrap that updateWorkspaceFileContent did, so a blown storage quota reached the route as a bare Error and the v2 handler recovered the status by substring-matching the message. Any rewording silently demoted a 413 to a 500. - uploadWorkspaceFile rethrows a classified failure untouched and attaches cause to the generic wrap. - FileConflictError is now an OrchestrationError('conflict'), so a duplicate name classifies like every other conflict. Its 'FILE_EXISTS' discriminator had no readers and is gone; the instanceof checks elsewhere still hold. - The v2 upload handler uses v2CaughtOrchestrationError, dropping all three string matches. Also documents that bulk-archive is best-effort: unknown or already-archived ids are skipped rather than failing the call, and deletedItems is what actually happened. That asymmetry with the single-id DELETE was undocumented. --- apps/docs/openapi-v2-files-audit.json | 2 +- apps/sim/app/api/v2/files/route.test.ts | 27 ++++++++++++++++++- apps/sim/app/api/v2/files/route.ts | 17 +++++------- .../workspace/workspace-file-manager.ts | 20 +++++++++++--- 4 files changed, 49 insertions(+), 17 deletions(-) diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index dfa8caed854..81df2b36c50 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -1051,7 +1051,7 @@ "post": { "operationId": "bulkArchiveFileItems", "summary": "Archive Files and Folders", - "description": "Archive (soft delete) files and/or folders in one call. Archiving a folder cascades to everything under it, so `deletedItems` reports totals larger than the selection. Archived items remain listable via `scope=archived` and can be restored.", + "description": "Archive (soft delete) files and/or folders in one call. Archiving a folder cascades to everything under it, so `deletedItems` reports totals larger than the selection. Archived items remain listable via `scope=archived` and can be restored.\n\n**This endpoint is best-effort and idempotent.** Ids that do not exist, belong to another workspace, or are already archived are skipped rather than failing the request — the call still returns `200`. `deletedItems` is what was actually archived, so compare it against your selection if you need to detect that something was skipped. The single-item `DELETE /api/v2/files/{fileId}` does return `404` for a missing id.", "tags": ["Files"], "x-codeSamples": [ { diff --git a/apps/sim/app/api/v2/files/route.test.ts b/apps/sim/app/api/v2/files/route.test.ts index c7295c4ac25..270661356a4 100644 --- a/apps/sim/app/api/v2/files/route.test.ts +++ b/apps/sim/app/api/v2/files/route.test.ts @@ -53,6 +53,7 @@ vi.mock('@sim/audit', () => ({ AuditResourceType: { FILE: 'file' }, })) +import { OrchestrationError } from '@/lib/core/orchestration/types' import { GET, POST } from '@/app/api/v2/files/route' const WS = 'workspace-1' @@ -287,11 +288,35 @@ describe('POST /api/v2/files', () => { }) it('404s when the target folder does not exist', async () => { - mockUploadWorkspaceFile.mockRejectedValue(new Error('Target folder not found')) + mockUploadWorkspaceFile.mockRejectedValue( + new OrchestrationError('not_found', 'Target folder not found') + ) const res = await callUpload(`workspaceId=${WS}&folderId=missing`) expect(res.status).toBe(404) expect((await res.json()).error.code).toBe('NOT_FOUND') }) + + it('413s on a blown storage quota by class, not by message wording', async () => { + mockUploadWorkspaceFile.mockRejectedValue( + new OrchestrationError('payload_too_large', 'Quota exceeded for this workspace') + ) + + const res = await callUpload(`workspaceId=${WS}`) + + expect(res.status).toBe(413) + expect((await res.json()).error.code).toBe('PAYLOAD_TOO_LARGE') + }) + + it('409s on a duplicate-name conflict by class', async () => { + mockUploadWorkspaceFile.mockRejectedValue( + new OrchestrationError('conflict', 'A file named "data.csv" already exists in this workspace') + ) + + const res = await callUpload(`workspaceId=${WS}`) + + expect(res.status).toBe(409) + expect((await res.json()).error.code).toBe('CONFLICT') + }) }) diff --git a/apps/sim/app/api/v2/files/route.ts b/apps/sim/app/api/v2/files/route.ts index 09740040cae..fb0a0cb8bce 100644 --- a/apps/sim/app/api/v2/files/route.ts +++ b/apps/sim/app/api/v2/files/route.ts @@ -15,7 +15,6 @@ import { } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { - FileConflictError, getWorkspaceFile, listWorkspaceFiles, uploadWorkspaceFile, @@ -26,6 +25,7 @@ import { v2ApiGateError } from '@/app/api/v2/lib/gate' import { decodeCursor, encodeCursor, + v2CaughtOrchestrationError, v2CursorList, v2Data, v2Error, @@ -226,17 +226,12 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return v2Error('PAYLOAD_TOO_LARGE', error.message) } - const message = getErrorMessage(error, 'Failed to upload file') - if (error instanceof FileConflictError || message.includes('already exists')) { - return v2Error('CONFLICT', message) - } - if (message === 'Target folder not found') { - return v2Error('NOT_FOUND', message) - } - if (message.includes('Storage limit') || message.includes('storage limit')) { - return v2Error('PAYLOAD_TOO_LARGE', 'Storage limit exceeded') - } + // Conflicts, a missing target folder, and a blown storage quota all arrive classified + // now, so the status comes off the error's code rather than its wording. + const classified = v2CaughtOrchestrationError(error) + if (classified) return classified + const message = getErrorMessage(error, 'Failed to upload file') logger.error('Error uploading file', { error: message }) return v2Error('INTERNAL_ERROR', 'Internal server error') } 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 86adc692d47..c77719e6860 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -52,10 +52,15 @@ const logger = createLogger('WorkspaceFileStorage') export type WorkspaceFileScope = 'active' | 'archived' | 'all' -export class FileConflictError extends Error { - readonly code = 'FILE_EXISTS' as const +/** + * An {@link OrchestrationError} so every surface reaches 409 by class rather than by + * searching the message for "already exists". Carries the inherited `code: 'conflict'`; + * the old `'FILE_EXISTS'` discriminator had no readers. + */ +export class FileConflictError extends OrchestrationError { constructor(name: string) { - super(`A file named "${name}" already exists in this workspace`) + super('conflict', `A file named "${name}" already exists in this workspace`) + this.name = 'FileConflictError' } } @@ -424,8 +429,15 @@ export async function uploadWorkspaceFile( ) continue } + // A classified failure (a blown storage quota, a missing target folder) keeps its class: + // re-wrapping it in a bare Error is what forced every caller to substring-match the + // message to recover the status. + const classified = asOrchestrationError(error) + if (classified) throw classified logger.error(`Failed to upload workspace file ${fileName}:`, error) - throw new Error(`Failed to upload file: ${getErrorMessage(error, 'Unknown error')}`) + throw new Error(`Failed to upload file: ${getErrorMessage(error, 'Unknown error')}`, { + cause: error, + }) } }