diff --git a/apps/docs/content/docs/cli/files.mdx b/apps/docs/content/docs/cli/files.mdx index db168e20bfe..4167f1a00d1 100644 --- a/apps/docs/content/docs/cli/files.mdx +++ b/apps/docs/content/docs/cli/files.mdx @@ -38,6 +38,7 @@ sim files create [options] | Option | Required | Description | | --- | --- | --- | +| `--workflow-ids ` | No | Deployed workflows in this workspace that the HTML document may call. (JSON, or @path / @- to read a file or stdin). | | `--name ` | Yes | File name, including its extension. Path separators and dot segments are rejected. | | `--content-type ` | No | MIME type. When omitted, it is inferred from the file extension. | | `--folder ` | No | Folder path as shown in the app; the leading / is optional. | @@ -502,6 +503,77 @@ sim files read [options] +## Get file workflow result + +```bash +sim files workflows get +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `fileId` | Yes | HTML file identifier. | +| `workflowId` | Yes | Workflow ID configured in the file metadata. | + + + +## Run file workflow + +```bash +sim files workflows create [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `fileId` | Yes | HTML file identifier. | +| `workflowId` | Yes | Workflow ID configured in the file metadata. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--input ` | No | JSON values for fields declared by the current workflow deployment. (JSON, or @path / @- to read a file or stdin). | + + + +## Read file workflow input result + +```bash +sim files result create [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `fileId` | Yes | HTML file identifier. | +| `workflowId` | Yes | Workflow ID configured in the file metadata. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--input ` | Yes | The same declared JSON input values used to run this workflow. (JSON, or @path / @- to read a file or stdin). | + + + ## Rename a file ```bash @@ -619,6 +691,32 @@ sim files set-content [options] +## Update file metadata + +```bash +sim files metadata update [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `fileId` | Yes | HTML file identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--workflow-ids ` | Yes | Replace the workflows this HTML file may call. Send an empty array to remove all dependencies. Sharing exposes these calls to the file audience. (JSON, or @path / @- to read a file or stdin). | + + + ## Upload a file to the workspace ```bash diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx index 5a0af9c37e0..5a4ef2f8143 100644 --- a/apps/docs/content/docs/cli/reference.mdx +++ b/apps/docs/content/docs/cli/reference.mdx @@ -761,6 +761,7 @@ sim files create [options] | Option | Required | Description | | --- | --- | --- | +| `--workflow-ids ` | No | Deployed workflows in this workspace that the HTML document may call. (JSON, or @path / @- to read a file or stdin). | | `--name ` | Yes | File name, including its extension. Path separators and dot segments are rejected. | | `--content-type ` | No | MIME type. When omitted, it is inferred from the file extension. | | `--folder ` | No | Folder path as shown in the app; the leading / is optional. | @@ -1261,6 +1262,83 @@ sim files read [options] +### sim files workflows get + +Get File Workflow Result + +```bash +sim files workflows get +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `fileId` | Yes | HTML file identifier. | +| `workflowId` | Yes | Workflow ID configured in the file metadata. | + + + +### sim files workflows create + +Run File Workflow + +```bash +sim files workflows create [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `fileId` | Yes | HTML file identifier. | +| `workflowId` | Yes | Workflow ID configured in the file metadata. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--input ` | No | JSON values for fields declared by the current workflow deployment. (JSON, or @path / @- to read a file or stdin). | + + + +### sim files result create + +Read File Workflow Input Result + +```bash +sim files result create [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `fileId` | Yes | HTML file identifier. | +| `workflowId` | Yes | Workflow ID configured in the file metadata. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--input ` | Yes | The same declared JSON input values used to run this workflow. (JSON, or @path / @- to read a file or stdin). | + + + ### sim files rename Rename a file @@ -1388,6 +1466,34 @@ sim files set-content [options] +### sim files metadata update + +Update File Metadata + +```bash +sim files metadata update [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `fileId` | Yes | HTML file identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--workflow-ids ` | Yes | Replace the workflows this HTML file may call. Send an empty array to remove all dependencies. Sharing exposes these calls to the file audience. (JSON, or @path / @- to read a file or stdin). | + + + ### sim files upload Upload a file to the workspace diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index 376508187b7..bd6f40b6277 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -39,6 +39,482 @@ } ], "paths": { + "/api/v2/files/{fileId}/metadata": { + "patch": { + "operationId": "updateFileMetadata", + "summary": "Update File Metadata", + "description": "Replace the workflows an HTML file can call. Workflows must belong to the same workspace and be deployed. Updating a shared file also checks permission to expose those workflows to its audience.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.update_metadata", + "x-oauth-scope": "api:write", + "tags": ["Files"], + "parameters": [ + { + "name": "fileId", + "in": "path", + "required": true, + "description": "HTML file identifier.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9_-]+$", + "description": "HTML file identifier." + } + } + ], + "requestBody": { + "required": true, + "description": "UpdateFileMetadata body schema.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateFileMetadataBody" + } + } + } + }, + "responses": { + "200": { + "description": "Update File Metadata result.", + "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/UpdateFileMetadataResponse" + } + } + } + }, + "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" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + }, + "get": { + "operationId": "getFile", + "summary": "Get File Metadata", + "description": "Get file metadata, its public-share configuration, and the version number of its current content. The `share` field is null when the file has never been shared. `currentVersion` identifies the content in List File Versions and is the precondition Revert File Version accepts.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "files.read_metadata", + "x-oauth-scope": "api:read", + "tags": ["Files"], + "parameters": [ + { + "name": "fileId", + "in": "path", + "required": true, + "description": "File identifier.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9_-]+$", + "description": "File identifier." + } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace that owns the file.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the file." + } + }, + { + "name": "scope", + "in": "query", + "required": false, + "description": "Which lifecycle set to read from: `active` (default) resolves live files only and returns `404` for a file a delete soft-deleted; `archived` also resolves soft-deleted files, so metadata stays readable before the file is restored. Authorization is identical for both.", + "schema": { + "default": "active", + "description": "Which lifecycle set to read from: `active` (default) resolves live files only and returns `404` for a file a delete soft-deleted; `archived` also resolves soft-deleted files, so metadata stays readable before the file is restored. Authorization is identical for both.", + "type": "string", + "enum": ["active", "archived"] + } + } + ], + "responses": { + "200": { + "description": "File metadata and public-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/V2FileMetadataResponse" + } + } + } + }, + "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" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/files/{fileId}/workflows/{workflowId}": { + "get": { + "operationId": "getFileWorkflowResult", + "summary": "Get File Workflow Result", + "description": "Read the latest cached result available to the caller. This operation never starts a workflow. An empty result means no result is available for this caller; nextRunAt indicates the earliest next attempt.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "files.workflows.read", + "x-oauth-scope": "api:read", + "tags": ["Files"], + "parameters": [ + { + "name": "fileId", + "in": "path", + "required": true, + "description": "HTML file identifier.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9_-]+$", + "description": "HTML file identifier." + } + }, + { + "name": "workflowId", + "in": "path", + "required": true, + "description": "Workflow ID configured in the file metadata.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Workflow ID configured in the file metadata." + } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace that owns the file.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the file." + } + } + ], + "responses": { + "200": { + "description": "Get File Workflow Result result.", + "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/GetFileWorkflowResultResponse" + } + } + } + }, + "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" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + }, + "post": { + "operationId": "runFileWorkflow", + "summary": "Run File Workflow", + "description": "Run a workflow configured in an HTML file synchronously, using its latest deployment when execution starts. Optional declared input values select a per-input, per-audience result cache with a five-minute cooldown. A file and workflow may admit at most twenty runs across inputs in five minutes. A running status means an existing run is in progress; read that input’s result to poll.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.workflows.run", + "x-oauth-scope": "api:write", + "tags": ["Files"], + "parameters": [ + { + "name": "fileId", + "in": "path", + "required": true, + "description": "HTML file identifier.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9_-]+$", + "description": "HTML file identifier." + } + }, + { + "name": "workflowId", + "in": "path", + "required": true, + "description": "Workflow ID configured in the file metadata.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Workflow ID configured in the file metadata." + } + } + ], + "requestBody": { + "required": true, + "description": "RunFileWorkflow body schema.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RunFileWorkflowBody" + } + } + } + }, + "responses": { + "200": { + "description": "Run File Workflow result.", + "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/RunFileWorkflowResponse" + } + } + } + }, + "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" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/files/{fileId}/workflows/{workflowId}/result": { + "post": { + "operationId": "readFileWorkflowInputResult", + "summary": "Read File Workflow Input Result", + "description": "Read the cached result for a specific declared input object without starting a workflow. Use the same input sent to Run File Workflow.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "files.workflows.read", + "x-oauth-scope": "api:read", + "tags": ["Files"], + "parameters": [ + { + "name": "fileId", + "in": "path", + "required": true, + "description": "HTML file identifier.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9_-]+$", + "description": "HTML file identifier." + } + }, + { + "name": "workflowId", + "in": "path", + "required": true, + "description": "Workflow ID configured in the file metadata.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Workflow ID configured in the file metadata." + } + } + ], + "requestBody": { + "required": true, + "description": "Workspace and declared input values.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ReadFileWorkflowInputResultBody" + } + } + } + }, + "responses": { + "200": { + "description": "The result cached for this input and caller.", + "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/ReadFileWorkflowInputResultResponse" + } + } + } + }, + "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" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, "/api/v2/files": { "get": { "operationId": "listFiles", @@ -1915,103 +2391,14 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RenameFileRequest" - } - } - } - }, - "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" - } - } - } - }, - "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" - }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" - }, - "415": { - "$ref": "#/components/responses/UnsupportedMediaType" - }, - "429": { - "$ref": "#/components/responses/RateLimited" - }, - "500": { - "$ref": "#/components/responses/InternalError" - }, - "503": { - "$ref": "#/components/responses/ServiceUnavailable" - } - } - } - }, - "/api/v2/files/{fileId}/restore": { - "post": { - "operationId": "restoreFile", - "summary": "Restore File", - "description": "Restore an archived file to the workspace root. Name collisions add a `_restored` suffix; use the returned `name` and `folderPath`. An active file returns unchanged. An archived workspace returns `400`; an unresolved name collision returns `409`.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.restore", - "x-oauth-scope": "api:write", - "tags": ["Files"], - "parameters": [ - { - "name": "fileId", - "in": "path", - "required": true, - "description": "File identifier.", - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9_-]+$", - "description": "File identifier." - } - } - ], - "requestBody": { - "required": true, - "description": "Workspace scope for the archived file.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RestoreFileRequest" + "$ref": "#/components/schemas/RenameFileRequest" } } } }, "responses": { "200": { - "description": "The file as it exists after the restore.", + "description": "The renamed file.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -2026,7 +2413,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2RestoreFileResponse" + "$ref": "#/components/schemas/V2FileResponse" } } } @@ -2064,13 +2451,13 @@ } } }, - "/api/v2/files/{fileId}/metadata": { - "get": { - "operationId": "getFile", - "summary": "Get File Metadata", - "description": "Get file metadata, its public-share configuration, and the version number of its current content. The `share` field is null when the file has never been shared. `currentVersion` identifies the content in List File Versions and is the precondition Revert File Version accepts.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "files.read_metadata", - "x-oauth-scope": "api:read", + "/api/v2/files/{fileId}/restore": { + "post": { + "operationId": "restoreFile", + "summary": "Restore File", + "description": "Restore an archived file to the workspace root. Name collisions add a `_restored` suffix; use the returned `name` and `folderPath`. An active file returns unchanged. An archived workspace returns `400`; an unresolved name collision returns `409`.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.restore", + "x-oauth-scope": "api:write", "tags": ["Files"], "parameters": [ { @@ -2085,35 +2472,22 @@ "pattern": "^[A-Za-z0-9_-]+$", "description": "File identifier." } - }, - { - "name": "workspaceId", - "in": "query", - "required": true, - "description": "Workspace that owns the file.", - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace that owns the file." - } - }, - { - "name": "scope", - "in": "query", - "required": false, - "description": "Which lifecycle set to read from: `active` (default) resolves live files only and returns `404` for a file a delete soft-deleted; `archived` also resolves soft-deleted files, so metadata stays readable before the file is restored. Authorization is identical for both.", - "schema": { - "default": "active", - "description": "Which lifecycle set to read from: `active` (default) resolves live files only and returns `404` for a file a delete soft-deleted; `archived` also resolves soft-deleted files, so metadata stays readable before the file is restored. Authorization is identical for both.", - "type": "string", - "enum": ["active", "archived"] - } } ], + "requestBody": { + "required": true, + "description": "Workspace scope for the archived file.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestoreFileRequest" + } + } + } + }, "responses": { "200": { - "description": "File metadata and public-share state.", + "description": "The file as it exists after the restore.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -2128,7 +2502,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2FileMetadataResponse" + "$ref": "#/components/schemas/V2RestoreFileResponse" } } } @@ -2145,6 +2519,15 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -3902,6 +4285,236 @@ } ] }, + "V2FileWorkflowMetadata": { + "type": "object", + "properties": { + "workflowIds": { + "maxItems": 10, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "description": "Deployed workflows this HTML file may call, in the same workspace." + } + }, + "required": ["workflowIds"], + "additionalProperties": false, + "title": "File workflow metadata", + "description": "Saved workflows that an HTML file may call." + }, + "UpdateFileMetadataResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2FileWorkflowMetadata" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "UpdateFileMetadata response", + "description": "UpdateFileMetadata response schema." + }, + "UpdateFileMetadataBody": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the file." + }, + "workflowIds": { + "maxItems": 10, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "description": "Replace the workflows this HTML file may call. Send an empty array to remove all dependencies. Sharing exposes these calls to the file audience." + } + }, + "required": ["workspaceId", "workflowIds"], + "additionalProperties": false, + "title": "UpdateFileMetadata body", + "description": "UpdateFileMetadata body schema." + }, + "V2FileWorkflowSnapshot": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["empty", "running", "completed", "failed"], + "description": "Result availability for this caller. Empty includes an expired or evicted cache entry." + }, + "executionId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Run identifier, visible only to the audience that started it." + }, + "deploymentVersionId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Deployment selected when this execution started, when available." + }, + "generatedAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "Time the result was generated, or null when unavailable." + }, + "nextRunAt": { + "anyOf": [ + { + "type": "string", + "format": "date-time", + "pattern": "^(?:(?:\\d\\d[2468][048]|\\d\\d[13579][26]|\\d\\d0[48]|[02468][048]00|[13579][26]00)-02-29|\\d{4}-(?:(?:0[13578]|1[02])-(?:0[1-9]|[12]\\d|3[01])|(?:0[469]|11)-(?:0[1-9]|[12]\\d|30)|(?:02)-(?:0[1-9]|1\\d|2[0-8])))T(?:(?:[01]\\d|2[0-3]):[0-5]\\d(?::[0-5]\\d(?:\\.\\d+)?)?(?:Z))$" + }, + { + "type": "null" + } + ], + "description": "Earliest next attempt. A still-running execution also prevents a new attempt." + }, + "output": { + "description": "Arbitrary workflow JSON output, limited to 1 MB; null when no completed output is available." + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Safe execution error message, or null." + } + }, + "required": [ + "status", + "executionId", + "deploymentVersionId", + "generatedAt", + "nextRunAt", + "output", + "error" + ], + "additionalProperties": false, + "title": "File workflow result", + "description": "Execution status and cached output for the authorized caller." + }, + "GetFileWorkflowResultResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2FileWorkflowSnapshot" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "GetFileWorkflowResult response", + "description": "GetFileWorkflowResult response schema." + }, + "RunFileWorkflowResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2FileWorkflowSnapshot" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "RunFileWorkflow response", + "description": "RunFileWorkflow response schema." + }, + "RunFileWorkflowBody": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the file." + }, + "input": { + "description": "JSON values for fields declared by the current workflow deployment.", + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "A JSON value accepted by the declared workflow input field." + } + } + }, + "required": ["workspaceId"], + "additionalProperties": false, + "title": "RunFileWorkflow body", + "description": "RunFileWorkflow body schema." + }, + "ReadFileWorkflowInputResultResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2FileWorkflowSnapshot" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Read file workflow input result response", + "description": "The cached result for the input." + }, + "ReadFileWorkflowInputResultBody": { + "type": "object", + "properties": { + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the file." + }, + "input": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "description": "A JSON value accepted by the declared workflow input field." + }, + "description": "The same declared JSON input values used to run this workflow." + } + }, + "required": ["workspaceId", "input"], + "additionalProperties": false, + "title": "Read file workflow input result body", + "description": "Workspace and declared input values." + }, "FolderPathInput": { "title": "Folder path input", "description": "Folder path. A missing leading slash is normalized before validation. Segments are percent-encoded, so a folder shown as \"New folder\" is `/New%20folder`: everything outside `A-Z a-z 0-9 - _ . ~` is escaped as uppercase hex, and only that exact encoding is accepted. A trailing slash, an empty segment, and a literal `.` or `..` segment are rejected. At most 64 segments and 4096 encoded bytes.", @@ -3921,6 +4534,16 @@ "format": "uri", "description": "Canonical absolute URL for opening this resource in the Sim web application." }, + "workflowIds": { + "maxItems": 10, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "description": "Workflows callable through this HTML file. Empty for files without workflow dependencies." + }, "name": { "type": "string", "description": "Original file name.", @@ -3984,6 +4607,7 @@ "required": [ "id", "webUrl", + "workflowIds", "name", "size", "type", @@ -4031,6 +4655,7 @@ "id": "wf_V1StGXR8z5jdHi6BmyT91", "webUrl": "https://www.sim.ai/workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/files/wf_V1StGXR8z5jdHi6BmyT91", "name": "data.csv", + "workflowIds": [], "size": 1024, "type": "text/csv", "key": "workspace/example/data.csv", @@ -4063,6 +4688,7 @@ "id": "wf_V1StGXR8z5jdHi6BmyT91", "webUrl": "https://www.sim.ai/workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/files/wf_V1StGXR8z5jdHi6BmyT91", "name": "data.csv", + "workflowIds": [], "size": 1024, "type": "text/csv", "key": "workspace/example/data.csv", @@ -4084,6 +4710,16 @@ "maxLength": 128, "description": "Workspace in which to create the file." }, + "workflowIds": { + "description": "Deployed workflows in this workspace that the HTML document may call.", + "maxItems": 10, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, "name": { "type": "string", "minLength": 1, @@ -4956,6 +5592,7 @@ "id": "wf_V1StGXR8z5jdHi6BmyT91", "webUrl": "https://www.sim.ai/workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/files/wf_V1StGXR8z5jdHi6BmyT91", "name": "data.csv", + "workflowIds": [], "size": 1024, "type": "text/csv", "key": "workspace/example/data.csv", @@ -5151,6 +5788,7 @@ "id": "wf_V1StGXR8z5jdHi6BmyT91", "webUrl": "https://www.sim.ai/workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/files/wf_V1StGXR8z5jdHi6BmyT91", "name": "data_restored.csv", + "workflowIds": [], "size": 1024, "type": "text/csv", "key": "workspace/example/data.csv", @@ -5260,6 +5898,16 @@ "format": "uri", "description": "Canonical absolute URL for opening this resource in the Sim web application." }, + "workflowIds": { + "maxItems": 10, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "description": "Workflows callable through this HTML file. Empty for files without workflow dependencies." + }, "name": { "type": "string", "description": "Original file name.", @@ -5344,6 +5992,7 @@ "required": [ "id", "webUrl", + "workflowIds", "name", "size", "type", @@ -5378,6 +6027,7 @@ "id": "wf_V1StGXR8z5jdHi6BmyT91", "webUrl": "https://www.sim.ai/workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/files/wf_V1StGXR8z5jdHi6BmyT91", "name": "data.csv", + "workflowIds": [], "size": 1024, "type": "text/csv", "key": "workspace/example/data.csv", @@ -5396,6 +6046,7 @@ "id": "wf_V1StGXR8z5jdHi6BmyT91", "webUrl": "https://www.sim.ai/workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/files/wf_V1StGXR8z5jdHi6BmyT91", "name": "data.csv", + "workflowIds": [], "size": 1024, "type": "text/csv", "key": "workspace/example/data.csv", @@ -5850,6 +6501,7 @@ "id": "wf_V1StGXR8z5jdHi6BmyT91", "webUrl": "https://www.sim.ai/workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/files/wf_V1StGXR8z5jdHi6BmyT91", "name": "data.csv", + "workflowIds": [], "size": 1024, "type": "text/csv", "key": "workspace/example/data.csv", @@ -6164,6 +6816,16 @@ "format": "uri", "description": "Canonical absolute URL for opening this resource in the Sim web application." }, + "workflowIds": { + "maxItems": 10, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "description": "Workflows callable through this HTML file. Empty for files without workflow dependencies." + }, "name": { "type": "string", "description": "Original file name.", @@ -6231,6 +6893,7 @@ "required": [ "id", "webUrl", + "workflowIds", "name", "size", "type", @@ -6263,6 +6926,7 @@ "id": "wf_V1StGXR8z5jdHi6BmyT91", "webUrl": "https://www.sim.ai/workspace/a91c4b2e-6d3f-4e8a-b5c7-0d9e2f1a8c64/files/wf_V1StGXR8z5jdHi6BmyT91", "name": "data.csv", + "workflowIds": [], "size": 1024, "type": "text/csv", "key": "workspace/example/data.csv", diff --git a/apps/sim/.env.example b/apps/sim/.env.example index 33b321ba57d..2e76c25084a 100644 --- a/apps/sim/.env.example +++ b/apps/sim/.env.example @@ -18,6 +18,8 @@ BETTER_AUTH_URL=http://localhost:3000 # NextJS (Required) NEXT_PUBLIC_APP_URL=http://localhost:3000 +# HTML_CONTENT_ORIGIN=http://127.0.0.1:3000 # Workflow HTML sandbox; set independently at build and runtime in each environment. +# Example: staging https://file.staging.simstudio.ai, production https://file.simstudio.ai. Each must use a separate site from its app origin. Route only /html-frame to this app on the content host. # NEXT_PUBLIC_STATUS_NOTICE_PREVIEW=true # Force the sidebar service-status notice into its critical preview state for testing # INTERNAL_API_BASE_URL=http://sim-app.default.svc.cluster.local:3000 # Optional: internal URL for server-side /api self-calls; defaults to NEXT_PUBLIC_APP_URL # SIM_MCP_URL=https://mcp.example.com/mcp # Optional: dedicated host for the Sim MCP server; defaults to NEXT_PUBLIC_APP_URL/api/mcp diff --git a/apps/sim/app/api/files/html-runtime/route.ts b/apps/sim/app/api/files/html-runtime/route.ts new file mode 100644 index 00000000000..fef3339763e --- /dev/null +++ b/apps/sim/app/api/files/html-runtime/route.ts @@ -0,0 +1,17 @@ +import { NextResponse } from 'next/server' +import { getHtmlRuntimeContract } from '@/lib/api/contracts/file-workflows' +import { parseRequest } from '@/lib/api/server' +import { getEnv } from '@/lib/core/config/env' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { htmlContentOrigin } from '@/lib/workspace-files/html-runtime/config' + +/** Static public runtime configuration, with no protected resource access. */ +export const GET = withRouteHandler(async (request) => { + const parsed = await parseRequest(getHtmlRuntimeContract, request, {}) + if (!parsed.success) return parsed.response + const origin = htmlContentOrigin(getEnv('HTML_CONTENT_ORIGIN'), getEnv('NEXT_PUBLIC_APP_URL')!) + return NextResponse.json( + getHtmlRuntimeContract.response.schema.parse({ frameUrl: `${origin}/html-frame` }), + { headers: { 'Cache-Control': 'no-store' } } + ) +}) diff --git a/apps/sim/app/api/files/public/[token]/route.test.ts b/apps/sim/app/api/files/public/[token]/route.test.ts index 559692faf25..d31c3355b25 100644 --- a/apps/sim/app/api/files/public/[token]/route.test.ts +++ b/apps/sim/app/api/files/public/[token]/route.test.ts @@ -53,6 +53,8 @@ const publicShare = { originalName: 'report.pdf', contentType: 'application/pdf', sizeBytes: 2048, + workflowIds: [], + updatedAt: new Date('2026-09-22T00:00:00.000Z'), }, workspaceName: 'Acme Workspace', ownerName: 'Jane Doe', @@ -95,6 +97,8 @@ describe('GET /api/files/public/[token]', () => { name: 'report.pdf', type: 'application/pdf', size: 2048, + workflowIds: [], + version: new Date('2026-09-22T00:00:00.000Z').getTime(), workspaceName: 'Acme Workspace', ownerName: 'Jane Doe', }) diff --git a/apps/sim/app/api/files/public/[token]/route.ts b/apps/sim/app/api/files/public/[token]/route.ts index 5874d977b9b..650f4627c28 100644 --- a/apps/sim/app/api/files/public/[token]/route.ts +++ b/apps/sim/app/api/files/public/[token]/route.ts @@ -55,14 +55,19 @@ export const GET = withRouteHandler( } const { file, workspaceName, ownerName } = resolved - return NextResponse.json({ - token, - name: file.originalName, - type: file.contentType, - size: getWorkspaceFileSize(file), - workspaceName, - ownerName, - }) + return NextResponse.json( + { + token, + name: file.originalName, + workflowIds: file.workflowIds, + version: file.updatedAt.getTime(), + type: file.contentType, + size: getWorkspaceFileSize(file), + workspaceName, + ownerName, + }, + { headers: { 'Cache-Control': 'private, no-store' } } + ) } catch (error) { logger.error('Error fetching public file metadata:', error) return NextResponse.json( diff --git a/apps/sim/app/api/files/public/[token]/workflows/[workflowId]/result/route.ts b/apps/sim/app/api/files/public/[token]/workflows/[workflowId]/result/route.ts new file mode 100644 index 00000000000..34750b4ff91 --- /dev/null +++ b/apps/sim/app/api/files/public/[token]/workflows/[workflowId]/result/route.ts @@ -0,0 +1,3 @@ +import { publicWorkflowRoute } from '@/lib/workspace-files/transport/public-workflow-route' + +export const POST = publicWorkflowRoute('read-input') diff --git a/apps/sim/app/api/files/public/[token]/workflows/[workflowId]/route.ts b/apps/sim/app/api/files/public/[token]/workflows/[workflowId]/route.ts new file mode 100644 index 00000000000..18f66ee7a67 --- /dev/null +++ b/apps/sim/app/api/files/public/[token]/workflows/[workflowId]/route.ts @@ -0,0 +1,4 @@ +import { publicWorkflowRoute } from '@/lib/workspace-files/transport/public-workflow-route' + +export const GET = publicWorkflowRoute('read') +export const POST = publicWorkflowRoute('run') diff --git a/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts index 6c160f29915..cf6ba2f05c0 100644 --- a/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts +++ b/apps/sim/app/api/v2/files/[fileId]/content/route.test.ts @@ -182,6 +182,7 @@ describe('PUT /api/v2/files/[fileId]/content', () => { data: { id: FILE_ID, webUrl: `https://test.sim.ai/workspace/${WORKSPACE_ID}/files/${FILE_ID}`, + workflowIds: [], name: 'data.csv', size: 8, type: 'text/csv', diff --git a/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts b/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts index 6efbae5b9e3..97c443c4754 100644 --- a/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts +++ b/apps/sim/app/api/v2/files/[fileId]/metadata/route.test.ts @@ -137,6 +137,7 @@ describe('GET /api/v2/files/[fileId]/metadata', () => { data: { id: FILE_ID, webUrl: `https://test.sim.ai/workspace/${WORKSPACE_ID}/files/${FILE_ID}`, + workflowIds: [], name: 'data.csv', size: 1024, type: 'text/csv', @@ -196,6 +197,7 @@ describe('GET /api/v2/files/[fileId]/metadata', () => { data: { id: FILE_ID, webUrl: `https://test.sim.ai/workspace/${WORKSPACE_ID}/files/${FILE_ID}`, + workflowIds: [], name: 'data.csv', size: 1024, type: 'text/csv', diff --git a/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts b/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts index 02a57449037..2dd114a4214 100644 --- a/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/metadata/route.ts @@ -1,9 +1,11 @@ +import { v2UpdateFileMetadataContract } from '@/lib/api/contracts/v2/file-workflows' import { v2GetFileContract } from '@/lib/api/contracts/v2/files' import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' import { v2FileErrorPolicies } from '@/lib/workspace-files/api' import { workspaceFileRevisionField } from '@/lib/workspace-files/application/file-revision' import { fileOperations } from '@/lib/workspace-files/application/operations' import { readWorkspaceFileMetadataWithVersion } from '@/lib/workspace-files/application/read-workspace-file-metadata' +import { updateWorkspaceFileMetadata } from '@/lib/workspace-files/application/update-workspace-file-metadata' import { toV2File } from '@/app/api/v2/files/utils' export const dynamic = 'force-dynamic' @@ -39,3 +41,19 @@ export const GET = defineV2JsonRoute({ }, }), }) + +export const PATCH = defineV2JsonRoute({ + contract: v2UpdateFileMetadataContract, + auth: v2ApiKeyAuth, + operation: fileOperations.updateMetadata, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, + parseOptions: { maxBodyBytes: 4096 }, + mapInput: ({ params, body }) => ({ + fileId: params.fileId, + assertedWorkspaceId: body.workspaceId, + workflowIds: body.workflowIds, + }), + useCase: updateWorkspaceFileMetadata, + present: (result) => ({ data: result }), +}) 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 index 888bdfae7d7..9f98dac4ea6 100644 --- a/apps/sim/app/api/v2/files/[fileId]/restore/route.test.ts +++ b/apps/sim/app/api/v2/files/[fileId]/restore/route.test.ts @@ -96,6 +96,7 @@ describe('POST /api/v2/files/[fileId]/restore', () => { data: { id: FILE_ID, webUrl: `https://test.sim.ai/workspace/${WORKSPACE_ID}/files/${FILE_ID}`, + workflowIds: [], name: 'notes_restored.md', size: 12, type: 'text/markdown', diff --git a/apps/sim/app/api/v2/files/[fileId]/workflows/[workflowId]/result/route.ts b/apps/sim/app/api/v2/files/[fileId]/workflows/[workflowId]/result/route.ts new file mode 100644 index 00000000000..47cb241d0d9 --- /dev/null +++ b/apps/sim/app/api/v2/files/[fileId]/workflows/[workflowId]/result/route.ts @@ -0,0 +1,22 @@ +import { v2ReadFileWorkflowInputContract } from '@/lib/api/contracts/v2/file-workflows' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2FileErrorPolicies } from '@/lib/workspace-files/api' +import { readFileWorkflow } from '@/lib/workspace-files/application/file-workflows' +import { fileOperations } from '@/lib/workspace-files/application/operations' + +export const POST = defineV2JsonRoute({ + contract: v2ReadFileWorkflowInputContract, + auth: v2ApiKeyAuth, + operation: fileOperations.readWorkflowResult, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, + parseOptions: { maxBodyBytes: 20 * 1024 }, + mapInput: ({ params, body }) => ({ + fileId: params.fileId, + workflowId: params.workflowId, + assertedWorkspaceId: body.workspaceId, + input: body.input, + }), + useCase: readFileWorkflow, + present: (result) => ({ data: result }), +}) diff --git a/apps/sim/app/api/v2/files/[fileId]/workflows/[workflowId]/route.ts b/apps/sim/app/api/v2/files/[fileId]/workflows/[workflowId]/route.ts new file mode 100644 index 00000000000..9a08de93553 --- /dev/null +++ b/apps/sim/app/api/v2/files/[fileId]/workflows/[workflowId]/route.ts @@ -0,0 +1,39 @@ +import { + v2ReadFileWorkflowContract, + v2RunFileWorkflowContract, +} from '@/lib/api/contracts/v2/file-workflows' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { v2FileErrorPolicies } from '@/lib/workspace-files/api' +import { readFileWorkflow, runFileWorkflow } from '@/lib/workspace-files/application/file-workflows' +import { fileOperations } from '@/lib/workspace-files/application/operations' + +export const GET = defineV2JsonRoute({ + contract: v2ReadFileWorkflowContract, + auth: v2ApiKeyAuth, + operation: fileOperations.readWorkflowResult, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, + mapInput: ({ params, query }) => ({ + fileId: params.fileId, + workflowId: params.workflowId, + assertedWorkspaceId: query.workspaceId, + }), + useCase: readFileWorkflow, + present: (result) => ({ data: result }), +}) +export const POST = defineV2JsonRoute({ + contract: v2RunFileWorkflowContract, + auth: v2ApiKeyAuth, + operation: fileOperations.runWorkflow, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.concealResourceAuthorization, + parseOptions: { maxBodyBytes: 20 * 1024 }, + mapInput: ({ params, body }) => ({ + fileId: params.fileId, + workflowId: params.workflowId, + assertedWorkspaceId: body.workspaceId, + input: body.input, + }), + useCase: runFileWorkflow, + present: (result) => ({ data: result }), +}) diff --git a/apps/sim/app/api/v2/files/route.test.ts b/apps/sim/app/api/v2/files/route.test.ts index f32ccdb3e6f..f52d461d5d9 100644 --- a/apps/sim/app/api/v2/files/route.test.ts +++ b/apps/sim/app/api/v2/files/route.test.ts @@ -153,6 +153,7 @@ describe('/api/v2/files', () => { { id: FILE.id, webUrl: `https://test.sim.ai/workspace/${WORKSPACE_ID}/files/${FILE.id}`, + workflowIds: [], name: 'notes.md', size: 0, type: 'text/markdown', diff --git a/apps/sim/app/api/v2/files/route.ts b/apps/sim/app/api/v2/files/route.ts index 4f38ee453fb..e959998d795 100644 --- a/apps/sim/app/api/v2/files/route.ts +++ b/apps/sim/app/api/v2/files/route.ts @@ -86,6 +86,7 @@ export const POST = defineV2JsonRoute({ mapInput: ({ body }) => ({ workspaceId: body.workspaceId, name: body.name, + workflowIds: body.workflowIds, contentType: body.contentType ?? getMimeTypeFromExtension(getFileExtension(body.name)), content: body.content, encoding: body.encoding, diff --git a/apps/sim/app/api/v2/files/utils.ts b/apps/sim/app/api/v2/files/utils.ts index 124050edfc8..4d4a2c1c198 100644 --- a/apps/sim/app/api/v2/files/utils.ts +++ b/apps/sim/app/api/v2/files/utils.ts @@ -37,6 +37,7 @@ function serializeV2File( id: record.id, webUrl: workspaceResourceWebUrl(baseUrl, record.workspaceId, 'file', record.id), name: record.name, + workflowIds: record.workflowIds ?? [], size: record.size, type: record.type, key: record.key, diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.test.ts b/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.test.ts index 323a43fd62d..c23dcd4ebb5 100644 --- a/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.test.ts @@ -359,6 +359,51 @@ describe('POST /api/v2/workflows/[workflowId]/execute', () => { }) }) + it('selects the latest deployment after preparation and executes that same graph across a redeploy', async () => { + const selected = { + blocks: {}, + edges: [], + loops: {}, + parallels: {}, + variables: { release: 'latest' }, + deploymentVersionId: 'version-at-start', + } + const prepared = await mockPreprocessExecution() + mockPreprocessExecution.mockImplementationOnce(async () => { + mockLoadDeployedWorkflowState.mockResolvedValue(selected) + return prepared + }) + mockExecuteWorkflowCore.mockImplementationOnce(async () => { + mockLoadDeployedWorkflowState.mockResolvedValue({ + ...selected, + deploymentVersionId: 'version-after-start', + }) + return { success: true, output: { result: 'done' } } + }) + const result = await executeWorkflowService({ + workflowId: 'workflow-1', + principal: { kind: 'personal_api_key', userId: 'actor-1', keyId: 'key-1' }, + userId: 'actor-1', + input: {}, + triggerType: 'api', + requestId: 'request-1', + mode: 'sync', + }) + expect(result).toMatchObject({ + ok: true, + status: 'completed', + deploymentVersionId: 'version-at-start', + }) + expect(mockLoadDeployedWorkflowState).toHaveBeenCalledOnce() + expect(mockExecuteWorkflowCore).toHaveBeenCalledWith( + expect.objectContaining({ + snapshot: expect.objectContaining({ + metadata: expect.objectContaining({ workflowStateOverride: selected }), + }), + }) + ) + }) + it('streams an immediate heartbeat and the same sync result when NDJSON is accepted', async () => { vi.useFakeTimers() try { diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/metadata/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/metadata/route.ts new file mode 100644 index 00000000000..45a3cac94bc --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/metadata/route.ts @@ -0,0 +1,24 @@ +import { updateFileWorkflowMetadataContract } from '@/lib/api/contracts/file-workflows' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { updateWorkspaceFileMetadata } from '@/lib/workspace-files/application/update-workspace-file-metadata' + +export const PATCH = defineInternalJsonRoute({ + contract: updateFileWorkflowMetadataContract, + auth: internalSessionAuth, + operation: fileOperations.updateMetadata, + rateLimit: internalRateLimits.user({ bucketName: 'file-metadata' }), + errorPolicy: internalOrchestrationErrorPolicy, + parseOptions: { maxBodyBytes: 4096 }, + mapInput: ({ params, body }) => ({ + fileId: params.fileId, + assertedWorkspaceId: params.id, + workflowIds: body.workflowIds, + }), + useCase: updateWorkspaceFileMetadata, +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/workflows/[workflowId]/result/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/workflows/[workflowId]/result/route.ts new file mode 100644 index 00000000000..56ef39fd709 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/workflows/[workflowId]/result/route.ts @@ -0,0 +1,26 @@ +import { readFileWorkflowInputContract } from '@/lib/api/contracts/file-workflows' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { readFileWorkflow } from '@/lib/workspace-files/application/file-workflows' +import { fileOperations } from '@/lib/workspace-files/application/operations' + +export const POST = defineInternalJsonRoute({ + contract: readFileWorkflowInputContract, + auth: internalSessionAuth, + operation: fileOperations.readWorkflowResult, + rateLimit: internalRateLimits.user({ bucketName: 'file-workflow-read' }), + errorPolicy: internalOrchestrationErrorPolicy, + parseOptions: { maxBodyBytes: 20 * 1024 }, + mapInput: ({ params, body }) => ({ + fileId: params.fileId, + assertedWorkspaceId: params.id, + workflowId: params.workflowId, + input: body.input, + }), + useCase: readFileWorkflow, + staticResponseHeaders: { 'Cache-Control': 'private, no-store' }, +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/workflows/[workflowId]/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/workflows/[workflowId]/route.ts new file mode 100644 index 00000000000..3f5509aa3fa --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/workflows/[workflowId]/route.ts @@ -0,0 +1,43 @@ +import { + readFileWorkflowContract, + runFileWorkflowContract, +} from '@/lib/api/contracts/file-workflows' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { readFileWorkflow, runFileWorkflow } from '@/lib/workspace-files/application/file-workflows' +import { fileOperations } from '@/lib/workspace-files/application/operations' + +export const GET = defineInternalJsonRoute({ + contract: readFileWorkflowContract, + auth: internalSessionAuth, + operation: fileOperations.readWorkflowResult, + rateLimit: internalRateLimits.user({ bucketName: 'file-workflow-read' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ + fileId: params.fileId, + assertedWorkspaceId: params.id, + workflowId: params.workflowId, + }), + useCase: readFileWorkflow, + staticResponseHeaders: { 'Cache-Control': 'private, no-store' }, +}) +export const POST = defineInternalJsonRoute({ + contract: runFileWorkflowContract, + auth: internalSessionAuth, + operation: fileOperations.runWorkflow, + rateLimit: internalRateLimits.user({ bucketName: 'file-workflow-run' }), + errorPolicy: internalOrchestrationErrorPolicy, + parseOptions: { maxBodyBytes: 20 * 1024 }, + mapInput: ({ params, body }) => ({ + fileId: params.fileId, + assertedWorkspaceId: params.id, + workflowId: params.workflowId, + input: body.input, + }), + useCase: runFileWorkflow, + staticResponseHeaders: { 'Cache-Control': 'private, no-store' }, +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/route.ts b/apps/sim/app/api/workspaces/[id]/files/route.ts index 2d29ddfad10..c68d12c30db 100644 --- a/apps/sim/app/api/workspaces/[id]/files/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/route.ts @@ -49,6 +49,7 @@ export const POST = defineInternalJsonRoute({ mapInput: ({ params, body }) => ({ workspaceId: params.id, name: body.name, + workflowIds: body.workflowIds, contentType: body.contentType ?? getMimeTypeFromExtension(getFileExtension(body.name)), content: body.content, encoding: body.encoding, diff --git a/apps/sim/app/f/[token]/page.tsx b/apps/sim/app/f/[token]/page.tsx index 2c59e81d057..b18d8c5c71d 100644 --- a/apps/sim/app/f/[token]/page.tsx +++ b/apps/sim/app/f/[token]/page.tsx @@ -116,6 +116,7 @@ export default async function PublicFilePage({ params }: PublicFilePageProps) { return ( + This shared file is no longer available. Reload to authenticate again. + + ) + return (
diff --git a/apps/sim/app/html-frame/route.ts b/apps/sim/app/html-frame/route.ts new file mode 100644 index 00000000000..56721e3e16d --- /dev/null +++ b/apps/sim/app/html-frame/route.ts @@ -0,0 +1,24 @@ +import { NextResponse } from 'next/server' +import { getEnv } from '@/lib/core/config/env' +import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { htmlContentOrigin } from '@/lib/workspace-files/html-runtime/config' +import { htmlRuntimeCsp, htmlRuntimeShell } from '@/lib/workspace-files/html-runtime/shell' + +/** Public bootstrap bytes only. Private document content arrives over a parent-bound MessagePort. */ +export const GET = withRouteHandler(async (request) => { + const appOrigin = new URL(getEnv('NEXT_PUBLIC_APP_URL')!).origin + const origin = htmlContentOrigin(getEnv('HTML_CONTENT_ORIGIN'), appOrigin) + if (request.headers.get('host') !== new URL(origin).host) + return new NextResponse(null, { status: 404 }) + return new NextResponse(htmlRuntimeShell(appOrigin), { + headers: { + 'Content-Type': 'text/html; charset=utf-8', + 'Cache-Control': 'no-store', + 'Content-Security-Policy': htmlRuntimeCsp(appOrigin), + 'Referrer-Policy': 'no-referrer', + 'X-Content-Type-Options': 'nosniff', + 'Permissions-Policy': + 'camera=(), microphone=(), geolocation=(), clipboard-read=(), clipboard-write=()', + }, + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx index 52407bbf750..f6d5cf9bcf4 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx @@ -314,6 +314,7 @@ const ReadOnlyTextPreview = memo(function ReadOnlyTextPreview({
+ ) if (previewType === 'html') return ( ${headInjection}${content}` } -/** - * Batches iframe content updates while an agent streams. Every content change - * replaces the srcDoc (a full document reload), so applying each chunk as it - * arrives would reload the page several times a second; ~2s batches keep the - * growing page readable. Off-stream, the live value passes straight through. - */ -function useStreamBatchedValue(value: string, streaming: boolean, intervalMs: number): string { - const [batched, setBatched] = useState(value) - const lastAppliedAtRef = useRef(0) - useEffect(() => { - if (!streaming) { - lastAppliedAtRef.current = 0 - setBatched(value) - return - } - const elapsed = Date.now() - lastAppliedAtRef.current - if (elapsed >= intervalMs) { - lastAppliedAtRef.current = Date.now() - setBatched(value) - return - } - const timer = setTimeout(() => { - lastAppliedAtRef.current = Date.now() - setBatched(value) - }, intervalMs - elapsed) - return () => clearTimeout(timer) - }, [value, streaming, intervalMs]) - return streaming ? batched : value -} - /** * The sandboxed frame carries no cookies, so a workspace image * (`/api/files/view/`, what `![alt](sim:file/)` compiles to) would @@ -321,6 +306,7 @@ const HtmlPreview = memo(function HtmlPreview({ }) { const { resolvedTheme } = useTheme() const router = useRouter() + const frameRef = useRef(null) const batchedContent = useStreamBatchedValue(content, isStreaming === true, 2000) // A SAVED sim page prefers the server-compiled document — the pptx/docx // model: the serve route resolves chart references (reading a table's @@ -352,6 +338,7 @@ const HtmlPreview = memo(function HtmlPreview({ // routes them in the app. Only workspace-internal paths are honored. useEffect(() => { const onMessage = (event: MessageEvent) => { + if (event.source !== frameRef.current?.contentWindow) return const href = (event.data as { __simPageNav?: unknown } | null)?.__simPageNav if (typeof href !== 'string') return if (href.startsWith('/workspace/')) { @@ -431,6 +418,7 @@ const HtmlPreview = memo(function HtmlPreview({
{isRenderable && (