diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index 402866bc262..81df2b36c50 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -23,7 +23,7 @@ "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": "Audit Logs", @@ -40,7 +40,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 +54,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 +108,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 +140,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 **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": [ { @@ -139,6 +153,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 +210,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" } } } @@ -216,7 +243,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": { @@ -225,7 +252,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" } } } @@ -426,6 +453,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,115 +827,818 @@ } } } - } - }, - "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.", - "example": "2026-01-15T10:30:00Z" - } + "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.\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": [ + { + "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/{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" + } + } + }, + "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" + } } }, "V2DeleteFileResult": { @@ -993,6 +1829,201 @@ } } } + }, + "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" + } + } + }, + "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" + } + } + }, + "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" + } + } + }, + "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 +2150,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..5fb64caf1df 100644 --- a/apps/sim/app/api/v1/middleware.ts +++ b/apps/sim/app/api/v1/middleware.ts @@ -40,6 +40,10 @@ export type ApiEndpoint = | 'table-columns' | 'files' | 'file-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/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..270661356a4 --- /dev/null +++ b/apps/sim/app/api/v2/files/route.test.ts @@ -0,0 +1,322 @@ +/** + * @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 { OrchestrationError } from '@/lib/core/orchestration/types' +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 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 47055ab713b..fb0a0cb8bce 100644 --- a/apps/sim/app/api/v2/files/route.ts +++ b/apps/sim/app/api/v2/files/route.ts @@ -15,16 +15,17 @@ import { } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { - FileConflictError, getWorkspaceFile, listWorkspaceFiles, 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, encodeCursor, + v2CaughtOrchestrationError, v2CursorList, v2Data, v2Error, @@ -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,38 +203,35 @@ export const POST = withRouteHandler(async (request: NextRequest) => { request, }) - 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, + /** + * `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, { throwOnError: true }) + 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) } - const message = getErrorMessage(error, 'Failed to upload file') - if (error instanceof FileConflictError || message.includes('already exists')) { - return v2Error('CONFLICT', 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/app/api/v2/files/utils.ts b/apps/sim/app/api/v2/files/utils.ts new file mode 100644 index 00000000000..c49966ce7f2 --- /dev/null +++ b/apps/sim/app/api/v2/files/utils.ts @@ -0,0 +1,23 @@ +import type { V2File } from '@/lib/api/contracts/v2/files' +import type { 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(), + } +} 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..52fbeadd284 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,21 @@ 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. + * + * 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 + * 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,9 +34,15 @@ 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 @@ -33,12 +55,40 @@ 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 +/** `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 +96,7 @@ export type V2FileParams = z.output */ export const v2ListFilesQuerySchema = z.object({ workspaceId: workspaceIdSchema, + scope: v2FileScopeSchema.default('active'), limit: z.coerce .number() .optional() @@ -56,9 +107,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 +127,148 @@ 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 + +/** + * 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 +299,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 +320,67 @@ 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 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-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 c0dbbbecce9..c77719e6860 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 { 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' @@ -51,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' } } @@ -423,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, + }) } } @@ -1072,7 +1085,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) @@ -1122,7 +1135,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 @@ -1169,7 +1182,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 @@ -1255,8 +1268,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, + }) } } @@ -1275,7 +1295,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) { @@ -1288,10 +1308,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), @@ -1308,7 +1329,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}"`) @@ -1316,6 +1337,7 @@ export async function renameWorkspaceFile( return { ...fileRecord, name: normalizedName, + updatedAt: renamedAt, } } @@ -1336,7 +1358,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( @@ -1376,7 +1398,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 { @@ -1399,7 +1421,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 @@ -1432,7 +1454,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) { @@ -1441,7 +1463,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/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.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 7de50ed8b73..8057fb8a0c8 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 } @@ -207,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' } } } @@ -285,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' } } } @@ -316,6 +310,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 +329,7 @@ export interface PerformMoveRenameWorkspaceFileParams { export interface PerformMoveRenameWorkspaceFileResult { success: boolean error?: string - errorCode?: WorkspaceFilesOrchestrationErrorCode + errorCode?: OrchestrationErrorCode file?: WorkspaceFileRecord } @@ -414,6 +412,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' } } } @@ -448,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' } } } @@ -495,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' } } } @@ -536,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' } } } 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..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: 1041, - zodRoutes: 1041, + totalRoutes: 1046, + zodRoutes: 1046, nonZodRoutes: 0, } as const