diff --git a/.github/scripts/http-e2e.sh b/.github/scripts/http-e2e.sh index 313122ee810..531a04cde2c 100755 --- a/.github/scripts/http-e2e.sh +++ b/.github/scripts/http-e2e.sh @@ -1,7 +1,7 @@ #!/usr/bin/env bash # Runs one end-to-end suite group over real HTTP, each against its own `next dev` app. # -# Usage: http-e2e.sh (run from apps/sim) +# Usage: http-e2e.sh (run from apps/sim) # # The job provides DATABASE_URL, BETTER_AUTH_SECRET and ENCRYPTION_KEY; each group sets the rest of # its app's environment here. Reports and server logs land in $RUNNER_TEMP/e2e. @@ -17,7 +17,7 @@ # telemetry flush runs detached and still writes .next/dev). set -euo pipefail -group=${1:?usage: http-e2e.sh } +group=${1:?usage: http-e2e.sh } report_dir="$RUNNER_TEMP/e2e" ready_timeout_seconds=300 mkdir -p "$report_dir" @@ -152,6 +152,15 @@ case "$group" in bun run test:desktop-inbox:e2e ;; + project-files) + PROJECT_FILES_E2E_ADMIN_DATABASE_URL=postgresql://postgres:postgres@127.0.0.1:5432/postgres \ + PROJECT_FILES_E2E_REDIS_URL=redis://127.0.0.1:6379 \ + PROJECT_FILES_E2E_BASE_URL=http://127.0.0.1:3050 \ + PROJECT_FILES_E2E_RELAY_URL=http://127.0.0.1:3052 \ + PROJECT_FILES_E2E_REPORT_PATH="$report_dir/project-files/orchestration.json" \ + bun --no-env-file scripts/test-project-files-e2e.ts + ;; + *) echo "::error::Unknown end-to-end group: $group" >&2 exit 2 diff --git a/.github/workflows/checks.yml b/.github/workflows/checks.yml index 20fddf15f35..fa23121c23a 100644 --- a/.github/workflows/checks.yml +++ b/.github/workflows/checks.yml @@ -160,6 +160,8 @@ jobs: runner: blacksmith-4vcpu-ubuntu-2404 - group: stop-after runner: blacksmith-4vcpu-ubuntu-2404 + - group: project-files + runner: blacksmith-8vcpu-ubuntu-2404 - group: desktop-inbox runner: blacksmith-4vcpu-ubuntu-2404 services: @@ -176,7 +178,7 @@ jobs: --health-interval 5s --health-timeout 5s --health-retries 10 - # Only the desktop executor's app is given REDIS_URL: its doorbell and presence live there. + # Project file subscriptions and the desktop executor use Redis. redis: image: redis:8.2-alpine ports: @@ -205,6 +207,14 @@ jobs: working-directory: packages/db run: bun run db:migrate + - name: Build native isolate addon for Project file rendering + if: matrix.group == 'project-files' + working-directory: apps/sim + run: | + addon_dir="$(node -p 'require("node:path").dirname(require.resolve("isolated-vm/package.json", { paths: ["./apps/sim"] }))')" + cd "$addon_dir" + bun run install + # No step timeout: the job's bound covers a hang without cutting a slow but healthy suite # short of writing its report. - name: Run end-to-end suites diff --git a/apps/docs/content/docs/cli/commands.mdx b/apps/docs/content/docs/cli/commands.mdx index 85c9eb0da58..a1fedef42cf 100644 --- a/apps/docs/content/docs/cli/commands.mdx +++ b/apps/docs/content/docs/cli/commands.mdx @@ -47,6 +47,7 @@ These apply to every command, and may be written before or after it. | [`sim meta`](/cli/meta) | Manage meta | | [`sim organizations`](/cli/organizations) | Manage organizations | | [`sim permission-groups`](/cli/permission-groups) | Manage permission groups | +| [`sim projects`](/cli/projects) | Manage projects | | [`sim sandboxes`](/cli/sandboxes) | Manage sandboxes | | [`sim secrets`](/cli/secrets) | Manage secrets | | [`sim selectors`](/cli/selectors) | Manage selectors | diff --git a/apps/docs/content/docs/cli/files.mdx b/apps/docs/content/docs/cli/files.mdx index 853e60ce484..fd6faef9f39 100644 --- a/apps/docs/content/docs/cli/files.mdx +++ b/apps/docs/content/docs/cli/files.mdx @@ -26,6 +26,25 @@ sim files batch-delete [options] +## Copy file items + +```bash +sim files copy [options] +``` + +Copy File Items (OAuth login or personal API key required) + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--source ` | Yes | Source owner and selected fileIds or folderIds (JSON, or @path / @- to read a file or stdin). | +| `--destination ` | Yes | Destination owner and optional folderId (JSON, or @path / @- to read a file or stdin). | + + + ## Create file ```bash diff --git a/apps/docs/content/docs/cli/meta.json b/apps/docs/content/docs/cli/meta.json index 14ef23a60dd..30ee29465d3 100644 --- a/apps/docs/content/docs/cli/meta.json +++ b/apps/docs/content/docs/cli/meta.json @@ -30,6 +30,7 @@ "meta", "organizations", "permission-groups", + "projects", "sandboxes", "secrets", "selectors", diff --git a/apps/docs/content/docs/cli/projects.mdx b/apps/docs/content/docs/cli/projects.mdx new file mode 100644 index 00000000000..fd0e8f5d3a9 --- /dev/null +++ b/apps/docs/content/docs/cli/projects.mdx @@ -0,0 +1,718 @@ +--- +title: Projects +description: Manage projects — every subcommand, argument, and flag +--- + +import { CommandTable } from '@/components/ui/command-table' + +Every command below also accepts the [global options](/cli/commands#global-options). + +## Archive project file items + +```bash +sim projects files archive [options] +``` + +Archive Project File Items (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--file-ids ` | No | Identifiers of the files to archive. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--folder-ids ` | No | Identifiers of folders to archive recursively, including their files and descendants. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `-y, --yes` | Yes | Confirm this operation. | + + + +## Create project file + +```bash +sim projects files create [options] +``` + +Create Project File (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--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. | +| `--content ` | No | Initial file content. Omit or send an empty string for a zero-byte file. The 70,000,000-character bound guards the JSON envelope; the decoded bytes must be at most 50 MiB, and a longer base64 payload is rejected with `413`. Use an upload session for anything larger. | +| `--encoding ` | No | Encoding of the content field. Accepted values: `utf-8`, `base64`. | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional. | + + + +## Create project file folder + +```bash +sim projects files folders create [options] +``` + +Create Project File Folder (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--name ` | Yes | Name for the new folder. | +| `--parent-id ` | No | Parent folder identifier; omit or use null for the root. (--parent-id null sends the word, not JSON null). | + + + +## List project file folders + +```bash +sim projects files folders list [options] +``` + +List Project File Folders (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--scope ` | No | Folder lifecycle scope. Accepted values: `active`, `archived`, `all`. | + + + +## Restore project file folder + +```bash +sim projects files folders restore +``` + +Restore Project File Folder (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | +| `folderId` | Yes | Folder identifier within the Project. | + + + +## Update project file folder + +```bash +sim projects files folders update [options] +``` + +Update Project File Folder (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | +| `folderId` | Yes | Folder identifier within the Project. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--name ` | No | New folder name; omit to leave unchanged. | +| `--parent-id ` | No | New parent folder identifier; null moves to the root, omission leaves the parent unchanged. (--parent-id null sends the word, not JSON null). | +| `--sort-order ` | No | New manual position; omit to leave unchanged. | + + + +## Permanently delete a previous version of a shared project file + +```bash +sim projects files versions delete [options] +``` + +Permanently delete a previous version of a shared Project file (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | +| `fileId` | Yes | File identifier within the Project. | +| `version` | Yes | Version number. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `-y, --yes` | Yes | Confirm this operation. | + + + +## Show the metadata of one version of a shared project file + +```bash +sim projects files versions describe +``` + +Show the metadata of one version of a shared Project file (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | +| `fileId` | Yes | File identifier within the Project. | +| `version` | Yes | Version number. | + + + +## List the recorded versions of a shared project file + +```bash +sim projects files versions list [options] +``` + +List the recorded versions of a shared Project file (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | +| `fileId` | Yes | File identifier within the Project. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--sort-by ` | No | Field used to sort the result. Accepted values: `version`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | + + + +## Make a previous version of a shared project file current again + +```bash +sim projects files versions revert [options] +``` + +Make a previous version of a shared Project file current again (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | +| `fileId` | Yes | File identifier within the Project. | +| `version` | Yes | Version number. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--expected-current-version ` | No | Revert only while this is still the current version; otherwise the request fails with `409`. Omit to revert whatever is current. Collaborative edits and repeated workflow writes that fold into the current version keep its number, so prefer `expectedRevision` to guard content. | +| `--expected-revision ` | No | Revert only while the file still holds the content this revision names, as returned by Get File Metadata or an earlier write; otherwise the request fails with `409`. Unlike a version number, it also catches edits that folded into the current version. | + + + +## Download a project file version’s stored source to stdout or a local file + +```bash +sim projects files versions source [options] +``` + +Download a Project file version’s stored source to stdout or a local file (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project that owns the file | +| `fileId` | Yes | File identifier. | +| `version` | Yes | Version number. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `-o, --output-file ` | No | Write content to a file instead of stdout. | +| `--force` | No | Overwrite --output-file if it already exists. | + + + +## Show project file metadata and ownership + +```bash +sim projects files describe +``` + +Show Project file metadata and ownership (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | +| `fileId` | Yes | File identifier within the Project. | + + + +## Show a project file’s share settings + +```bash +sim projects files share get +``` + +Show a Project file’s share settings (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | +| `fileId` | Yes | File identifier within the Project. | + + + +## Enable or disable sharing for a project file + +```bash +sim projects files share set [options] +``` + +Enable or disable sharing for a Project file (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | +| `fileId` | Yes | File identifier within the Project. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--is-active ` | Yes | Whether the share should resolve. Disabling preserves the token and the whole access configuration, so re-enabling restores the share as it was; enabling rewrites the credentials the resulting mode does not use. Accepted values: `true`, `false`. | +| `--auth-type ` | No | How access to the share is gated. The stored mode is kept when omitted. Enabling `public` clears the stored password and empties `allowedEmails`; `password` empties `allowedEmails`; `email` and `sso` clear the stored password. Accepted values: `public`, `password`, `email`, `sso`. | +| `--password ` | No | Literal password of 15 to 1024 characters. Kept when omitted; enabling password access without a supplied or stored password is rejected. | +| `--allowed-emails ` | No | Allowed addresses or `@domain` patterns for email and SSO shares. Kept when omitted; enabling `email` or `sso` with an empty resulting list is a 400. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | + + + +## List project files + +```bash +sim projects files list [options] +``` + +List Project Files (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional. | +| `--recursive` | No | Include subfolders in the folder filter. Defaults to true when searching and false otherwise. Ignored without a folder filter. | +| `--no-recursive` | No | Send --recursive as false. | +| `--scope ` | No | Which lifecycle set to list: `active` (default) for live files, `archived` for files a delete soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too. Accepted values: `active`, `archived`. | +| `--search ` | No | Case-insensitive substring match against the file name. | +| `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `size`, `uploadedAt`, `updatedAt`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | + + + +## Move project file items + +```bash +sim projects files move [options] +``` + +Move Project File Items (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--file-ids ` | No | Identifiers of the files to move. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--folder-ids ` | No | Identifiers of folders to move with their contents. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--to ` | No | Destination folder path; omit for root. | + + + +## Rename project file + +```bash +sim projects files rename [options] +``` + +Rename Project File (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | +| `fileId` | Yes | File identifier within the Project. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--name ` | Yes | New file name, including its extension. | + + + +## Restore project file + +```bash +sim projects files restore +``` + +Restore Project File (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | +| `fileId` | Yes | File identifier within the Project. | + + + +## Search project file content + +```bash +sim projects files search [options] +``` + +Search Project File Content (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--query ` | Yes | Regular expression, or exact text when `mode` is `exact`. | +| `--mode ` | No | How `query` is read. Accepted values: `exact`, `regex`. | +| `--max-results ` | No | Maximum matching lines to return. | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--include-subfolders` | No | Whether the scope descends into nested folders. Absent means yes. | +| `--no-include-subfolders` | No | Send --include-subfolders as false. | + + + +## Unzip an archive into a new folder beside it in the project + +```bash +sim projects files unzip [options] +``` + +Unzip an archive into a new folder beside it in the Project (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | +| `fileId` | Yes | File identifier within the Project. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `-y, --yes` | Yes | Confirm this operation. | + + + +## Replace a shared project file’s contents + +```bash +sim projects files set-content [options] +``` + +Replace a shared Project file’s contents (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | +| `fileId` | Yes | File identifier within the Project. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--content ` | Yes | Complete replacement content for the file. The 70,000,000-character bound guards the JSON envelope; the decoded bytes must be at most 50 MiB, and a longer base64 payload is rejected with `413`. | +| `--encoding ` | No | Content encoding. Accepted values: `utf-8`, `base64`. | +| `--expected-revision ` | No | Revision from Get File Metadata or an earlier write; the request is refused with `409` when the content moved on. | + + + +## Upload a shared project file + +```bash +sim projects files upload [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project that will own the file | +| `path` | Yes | Local file to upload | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--folder ` | No | Folder path as shown in the app; defaults to the Project root. | +| `--name ` | No | Store it under a different name. | + + + +## Download a project file’s stored source to stdout or a local file + +```bash +sim projects files source [options] +``` + +Download a Project file’s stored source to stdout or a local file (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project that owns the file | +| `fileId` | Yes | File whose stored source to read | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `-o, --output-file ` | No | Write content to a file instead of stdout. | +| `--force` | No | Overwrite --output-file if it already exists. | + + + +## Download project files and recursive folder contents as a zip archive + +```bash +sim projects files bulk-download [options] +``` + +Download Project files and recursive folder contents as a zip archive (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project that owns the files | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--file-ids ` | No | File identifiers to include. | +| `--folder-ids ` | No | Folder identifiers to include recursively. | +| `-o, --output-file ` | No | Write the archive to a file instead of stdout. | +| `--force` | No | Overwrite --output-file if it already exists. | + + + +## Export a visible project markdown snapshot with its embedded assets + +```bash +sim projects files export [options] +``` + +Export a visible Project Markdown snapshot with its embedded assets (OAuth login or personal API key required) + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project that owns the file | +| `fileId` | Yes | Markdown file whose visible snapshot to export | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--content ` | Yes | Visible Markdown content or a file to read. | +| `-o, --output-file ` | No | Write the export to a file instead of stdout. | +| `--force` | No | Overwrite --output-file if it already exists. | + + diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx index f5c58823f39..86994db15a1 100644 --- a/apps/docs/content/docs/cli/reference.mdx +++ b/apps/docs/content/docs/cli/reference.mdx @@ -866,6 +866,25 @@ sim files batch-delete [options] +### sim files copy + +Copy File Items (OAuth login or personal API key required) + +```bash +sim files copy [options] +``` + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--source ` | Yes | Source owner and selected fileIds or folderIds (JSON, or @path / @- to read a file or stdin). | +| `--destination ` | Yes | Destination owner and optional folderId (JSON, or @path / @- to read a file or stdin). | + + + ### sim files create Create File @@ -4294,6 +4313,720 @@ sim permission-groups update [options] +## sim projects + +### sim projects files archive + +Archive Project File Items (OAuth login or personal API key required) + +```bash +sim projects files archive [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--file-ids ` | No | Identifiers of the files to archive. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--folder-ids ` | No | Identifiers of folders to archive recursively, including their files and descendants. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `-y, --yes` | Yes | Confirm this operation. | + + + +### sim projects files create + +Create Project File (OAuth login or personal API key required) + +```bash +sim projects files create [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--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. | +| `--content ` | No | Initial file content. Omit or send an empty string for a zero-byte file. The 70,000,000-character bound guards the JSON envelope; the decoded bytes must be at most 50 MiB, and a longer base64 payload is rejected with `413`. Use an upload session for anything larger. | +| `--encoding ` | No | Encoding of the content field. Accepted values: `utf-8`, `base64`. | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional. | + + + +### sim projects files folders create + +Create Project File Folder (OAuth login or personal API key required) + +```bash +sim projects files folders create [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--name ` | Yes | Name for the new folder. | +| `--parent-id ` | No | Parent folder identifier; omit or use null for the root. (--parent-id null sends the word, not JSON null). | + + + +### sim projects files folders list + +List Project File Folders (OAuth login or personal API key required) + +```bash +sim projects files folders list [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--scope ` | No | Folder lifecycle scope. Accepted values: `active`, `archived`, `all`. | + + + +### sim projects files folders restore + +Restore Project File Folder (OAuth login or personal API key required) + +```bash +sim projects files folders restore +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | +| `folderId` | Yes | Folder identifier within the Project. | + + + +### sim projects files folders update + +Update Project File Folder (OAuth login or personal API key required) + +```bash +sim projects files folders update [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | +| `folderId` | Yes | Folder identifier within the Project. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--name ` | No | New folder name; omit to leave unchanged. | +| `--parent-id ` | No | New parent folder identifier; null moves to the root, omission leaves the parent unchanged. (--parent-id null sends the word, not JSON null). | +| `--sort-order ` | No | New manual position; omit to leave unchanged. | + + + +### sim projects files versions delete + +Permanently delete a previous version of a shared Project file (OAuth login or personal API key required) + +```bash +sim projects files versions delete [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | +| `fileId` | Yes | File identifier within the Project. | +| `version` | Yes | Version number. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `-y, --yes` | Yes | Confirm this operation. | + + + +### sim projects files versions describe + +Show the metadata of one version of a shared Project file (OAuth login or personal API key required) + +```bash +sim projects files versions describe +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | +| `fileId` | Yes | File identifier within the Project. | +| `version` | Yes | Version number. | + + + +### sim projects files versions list + +List the recorded versions of a shared Project file (OAuth login or personal API key required) + +```bash +sim projects files versions list [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | +| `fileId` | Yes | File identifier within the Project. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--sort-by ` | No | Field used to sort the result. Accepted values: `version`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | + + + +### sim projects files versions revert + +Make a previous version of a shared Project file current again (OAuth login or personal API key required) + +```bash +sim projects files versions revert [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | +| `fileId` | Yes | File identifier within the Project. | +| `version` | Yes | Version number. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--expected-current-version ` | No | Revert only while this is still the current version; otherwise the request fails with `409`. Omit to revert whatever is current. Collaborative edits and repeated workflow writes that fold into the current version keep its number, so prefer `expectedRevision` to guard content. | +| `--expected-revision ` | No | Revert only while the file still holds the content this revision names, as returned by Get File Metadata or an earlier write; otherwise the request fails with `409`. Unlike a version number, it also catches edits that folded into the current version. | + + + +### sim projects files versions source + +Download a Project file version’s stored source to stdout or a local file (OAuth login or personal API key required) + +```bash +sim projects files versions source [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project that owns the file | +| `fileId` | Yes | File identifier. | +| `version` | Yes | Version number. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `-o, --output-file ` | No | Write content to a file instead of stdout. | +| `--force` | No | Overwrite --output-file if it already exists. | + + + +### sim projects files describe + +Show Project file metadata and ownership (OAuth login or personal API key required) + +```bash +sim projects files describe +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | +| `fileId` | Yes | File identifier within the Project. | + + + +### sim projects files share get + +Show a Project file’s share settings (OAuth login or personal API key required) + +```bash +sim projects files share get +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | +| `fileId` | Yes | File identifier within the Project. | + + + +### sim projects files share set + +Enable or disable sharing for a Project file (OAuth login or personal API key required) + +```bash +sim projects files share set [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | +| `fileId` | Yes | File identifier within the Project. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--is-active ` | Yes | Whether the share should resolve. Disabling preserves the token and the whole access configuration, so re-enabling restores the share as it was; enabling rewrites the credentials the resulting mode does not use. Accepted values: `true`, `false`. | +| `--auth-type ` | No | How access to the share is gated. The stored mode is kept when omitted. Enabling `public` clears the stored password and empties `allowedEmails`; `password` empties `allowedEmails`; `email` and `sso` clear the stored password. Accepted values: `public`, `password`, `email`, `sso`. | +| `--password ` | No | Literal password of 15 to 1024 characters. Kept when omitted; enabling password access without a supplied or stored password is rejected. | +| `--allowed-emails ` | No | Allowed addresses or `@domain` patterns for email and SSO shares. Kept when omitted; enabling `email` or `sso` with an empty resulting list is a 400. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | + + + +### sim projects files list + +List Project Files (OAuth login or personal API key required) + +```bash +sim projects files list [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional. | +| `--recursive` | No | Include subfolders in the folder filter. Defaults to true when searching and false otherwise. Ignored without a folder filter. | +| `--no-recursive` | No | Send --recursive as false. | +| `--scope ` | No | Which lifecycle set to list: `active` (default) for live files, `archived` for files a delete soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too. Accepted values: `active`, `archived`. | +| `--search ` | No | Case-insensitive substring match against the file name. | +| `--sort-by ` | No | Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order. Accepted values: `name`, `size`, `uploadedAt`, `updatedAt`. | +| `--sort-order ` | No | Sort direction. Accepted values: `asc`, `desc`. | +| `--limit ` | No | Maximum items to return (0 for everything). Defaults to `100`. | +| `--cursor ` | No | Continue from nextCursor returned by a previous result. | + + + +### sim projects files move + +Move Project File Items (OAuth login or personal API key required) + +```bash +sim projects files move [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--file-ids ` | No | Identifiers of the files to move. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--folder-ids ` | No | Identifiers of folders to move with their contents. (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--to ` | No | Destination folder path; omit for root. | + + + +### sim projects files rename + +Rename Project File (OAuth login or personal API key required) + +```bash +sim projects files rename [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | +| `fileId` | Yes | File identifier within the Project. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--name ` | Yes | New file name, including its extension. | + + + +### sim projects files restore + +Restore Project File (OAuth login or personal API key required) + +```bash +sim projects files restore +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | +| `fileId` | Yes | File identifier within the Project. | + + + +### sim projects files search + +Search Project File Content (OAuth login or personal API key required) + +```bash +sim projects files search [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--query ` | Yes | Regular expression, or exact text when `mode` is `exact`. | +| `--mode ` | No | How `query` is read. Accepted values: `exact`, `regex`. | +| `--max-results ` | No | Maximum matching lines to return. | +| `--folder ` | No | Folder path as shown in the app; the leading / is optional (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--include-subfolders` | No | Whether the scope descends into nested folders. Absent means yes. | +| `--no-include-subfolders` | No | Send --include-subfolders as false. | + + + +### sim projects files unzip + +Unzip an archive into a new folder beside it in the Project (OAuth login or personal API key required) + +```bash +sim projects files unzip [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | +| `fileId` | Yes | File identifier within the Project. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `-y, --yes` | Yes | Confirm this operation. | + + + +### sim projects files set-content + +Replace a shared Project file’s contents (OAuth login or personal API key required) + +```bash +sim projects files set-content [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project identifier. | +| `fileId` | Yes | File identifier within the Project. | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--content ` | Yes | Complete replacement content for the file. The 70,000,000-character bound guards the JSON envelope; the decoded bytes must be at most 50 MiB, and a longer base64 payload is rejected with `413`. | +| `--encoding ` | No | Content encoding. Accepted values: `utf-8`, `base64`. | +| `--expected-revision ` | No | Revision from Get File Metadata or an earlier write; the request is refused with `409` when the content moved on. | + + + +### sim projects files upload + +Upload a shared Project file + +```bash +sim projects files upload [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project that will own the file | +| `path` | Yes | Local file to upload | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--folder ` | No | Folder path as shown in the app; defaults to the Project root. | +| `--name ` | No | Store it under a different name. | + + + +### sim projects files source + +Download a Project file’s stored source to stdout or a local file (OAuth login or personal API key required) + +```bash +sim projects files source [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project that owns the file | +| `fileId` | Yes | File whose stored source to read | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `-o, --output-file ` | No | Write content to a file instead of stdout. | +| `--force` | No | Overwrite --output-file if it already exists. | + + + +### sim projects files bulk-download + +Download Project files and recursive folder contents as a zip archive (OAuth login or personal API key required) + +```bash +sim projects files bulk-download [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project that owns the files | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--file-ids ` | No | File identifiers to include. | +| `--folder-ids ` | No | Folder identifiers to include recursively. | +| `-o, --output-file ` | No | Write the archive to a file instead of stdout. | +| `--force` | No | Overwrite --output-file if it already exists. | + + + +### sim projects files export + +Export a visible Project Markdown snapshot with its embedded assets (OAuth login or personal API key required) + +```bash +sim projects files export [options] +``` + +**Arguments** + + + +| Argument | Required | Description | +| --- | --- | --- | +| `projectId` | Yes | Project that owns the file | +| `fileId` | Yes | Markdown file whose visible snapshot to export | + + + +**Options** + + + +| Option | Required | Description | +| --- | --- | --- | +| `--content ` | Yes | Visible Markdown content or a file to read. | +| `-o, --output-file ` | No | Write the export to a file instead of stdout. | +| `--force` | No | Overwrite --output-file if it already exists. | + + + ## sim sandboxes Also spelled `sim sandbox`. diff --git a/apps/docs/openapi-v2-files-audit.json b/apps/docs/openapi-v2-files-audit.json index 9e96b13c639..cd20efed9e1 100644 --- a/apps/docs/openapi-v2-files-audit.json +++ b/apps/docs/openapi-v2-files-audit.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "Sim API v2 — Files & Audit Logs", - "description": "Version 2 of the Sim REST API for workspace files, resumable uploads, public shares, and organization audit logs.", + "description": "Version 2 of the Sim REST API for workspace and Project files, resumable uploads, public shares, and organization audit logs.", "version": "2.0.0", "contact": { "name": "Sim Support", @@ -23,7 +23,7 @@ "tags": [ { "name": "Files", - "description": "Create, upload, download, organize, share, version, and delete workspace files." + "description": "Create, upload, download, organize, share, version, and delete workspace and Project files." }, { "name": "Audit Logs", @@ -39,201 +39,28 @@ } ], "paths": { - "/api/v2/files": { - "get": { - "operationId": "listFiles", - "summary": "List Files", - "description": "List active workspace files with folder filtering, search, sorting, and cursor pagination. Use `scope=archived` to find files available for restoration. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "files.list", - "x-oauth-scope": "api:read", - "tags": ["Files"], - "parameters": [ - { - "name": "workspaceId", - "in": "query", - "required": true, - "description": "Workspace whose files should be listed.", - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace whose files should be listed." - } - }, - { - "name": "folderPath", - "in": "query", - "required": false, - "description": "Restrict files to this folder, including subfolders when `recursive` is true. Unknown folder paths contribute no matches.", - "schema": { - "description": "Restrict files to this folder, including subfolders when `recursive` is true. Unknown folder paths contribute no matches.", - "$ref": "#/components/schemas/FolderPathInput" - } - }, - { - "name": "recursive", - "in": "query", - "required": false, - "description": "Include subfolders in the folder filter. Defaults to true when searching and false otherwise. Ignored without a folder filter.", - "schema": { - "description": "Include subfolders in the folder filter. Defaults to true when searching and false otherwise. Ignored without a folder filter.", - "enum": [ - "true", - "1", - "yes", - "on", - "y", - "enabled", - "false", - "0", - "no", - "off", - "n", - "disabled" - ], - "type": "string" - } - }, - { - "name": "scope", - "in": "query", - "required": false, - "description": "Which lifecycle set to list: `active` (default) for live files, `archived` for files a delete soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.", - "schema": { - "default": "active", - "description": "Which lifecycle set to list: `active` (default) for live files, `archived` for files a delete soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.", - "type": "string", - "enum": ["active", "archived"] - } - }, - { - "name": "search", - "in": "query", - "required": false, - "description": "Case-insensitive substring match against the file name.", - "schema": { - "description": "Case-insensitive substring match against the file name.", - "type": "string", - "minLength": 1, - "maxLength": 200 - } - }, - { - "name": "sortBy", - "in": "query", - "required": false, - "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", - "schema": { - "default": "uploadedAt", - "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", - "type": "string", - "enum": ["name", "size", "uploadedAt", "updatedAt"] - } - }, - { - "name": "sortOrder", - "in": "query", - "required": false, - "description": "Sort direction.", - "schema": { - "default": "asc", - "description": "Sort direction.", - "type": "string", - "enum": ["asc", "desc"] - } - }, - { - "name": "limit", - "in": "query", - "required": false, - "description": "Maximum files per page. Values outside 1–1000 are truncated and clamped into that range rather than rejected. Defaults to 100.", - "schema": { - "description": "Maximum files per page. Values outside 1–1000 are truncated and clamped into that range rather than rejected. Defaults to 100.", - "type": "integer", - "default": 100 - } - }, - { - "name": "cursor", - "in": "query", - "required": false, - "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", - "schema": { - "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", - "type": "string", - "minLength": 1 - } - } - ], - "responses": { - "200": { - "description": "A page of workspace files.", - "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/V2FileListResponse" - } - } - } - }, - "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" - }, - "503": { - "$ref": "#/components/responses/ServiceUnavailable" - } - } - }, + "/api/v2/files/copy": { "post": { - "operationId": "createFile", - "summary": "Create File", - "description": "Create a workspace file from inline UTF-8 or base64 content. Use an upload session for streamed or larger files.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.create", + "operationId": "copyFileItems", + "summary": "Copy File Items", + "description": "Copy selected files and folder trees between workspace or Project owners. Source read and destination write access are checked independently. Copies receive new identities and retain source secret provenance. Registration is atomic, with destination names resolved by the existing copy rules. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.copy", "x-oauth-scope": "api:write", "tags": ["Files"], "requestBody": { "required": true, - "description": "Inline content and placement for a new workspace file.", + "description": "Exact source owner and selected identifiers, plus the destination owner and folder.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateFileRequest" + "$ref": "#/components/schemas/CopyFileItemsRequest" } } } }, "responses": { "201": { - "description": "The created file.", + "description": "New file and folder identities with their destination owner.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -248,7 +75,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2CreatedFileResponse" + "$ref": "#/components/schemas/V2CopyFileItemsResponse" } } } @@ -286,28 +113,41 @@ } } }, - "/api/v2/files/uploads": { + "/api/v2/projects/{projectId}/files/{fileId}/unzip": { "post": { - "operationId": "createFileUpload", - "summary": "Create File Upload", - "description": "Create a resumable upload session and receive either a signed PUT URL or multipart instructions.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.upload.create", + "operationId": "unzipProjectFile", + "summary": "Unzip Project File", + "description": "Extract a ZIP archive into a new sibling folder in the same Project. Use List Project Files to inspect its contents. Concurrent extraction of the same archive returns `409`; size or processing-time limits return `413`. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "project_files.extract_archive", "x-oauth-scope": "api:write", "tags": ["Files"], - "requestBody": { - "required": true, - "description": "File metadata required to create an upload session.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateFileUploadRequest" - } + "parameters": [ + { + "name": "projectId", + "in": "path", + "required": true, + "description": "Project identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Project identifier." + } + }, + { + "name": "fileId", + "in": "path", + "required": true, + "description": "File identifier within the Project.", + "schema": { + "type": "string", + "minLength": 1, + "description": "File identifier within the Project." } } - }, + ], "responses": { - "201": { - "description": "The created upload session and transfer instructions.", + "200": { + "description": "Counts and destination folder for the unpacked archive.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -322,7 +162,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateFileUploadResponse" + "$ref": "#/components/schemas/V2ProjectFileUnzipResponse" } } } @@ -339,12 +179,12 @@ "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" }, @@ -357,53 +197,101 @@ } } }, - "/api/v2/files/uploads/{uploadId}": { + "/api/v2/projects/{projectId}/files/search": { "get": { - "operationId": "getFileUpload", - "summary": "Get File Upload", - "description": "Get an upload session's state to determine whether an interrupted transfer can resume. Requires the signed upload token and current workspace access.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "files.upload.read", + "operationId": "searchProjectFileContent", + "summary": "Search Project File Content", + "description": "Search indexed text in active Project files, returning matching lines with file IDs and line numbers. Folder filters narrow both results and reported coverage. Missing matches are inconclusive when complete is false, or skippedFiles or partialFiles is nonzero. truncated means additional matches exist beyond maxResults. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "project_files.search_content", "x-oauth-scope": "api:read", "tags": ["Files"], "parameters": [ { - "name": "uploadId", + "name": "projectId", "in": "path", "required": true, - "description": "Upload session identifier.", + "description": "Project identifier.", "schema": { "type": "string", "minLength": 1, - "description": "Upload session identifier." + "description": "Project identifier." } }, { - "name": "workspaceId", + "name": "query", "in": "query", "required": true, - "description": "Workspace that owns the upload session.", + "description": "Regular expression, or exact text when `mode` is `exact`.", "schema": { "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace that owns the upload session." + "description": "Regular expression, or exact text when `mode` is `exact`.", + "minLength": 3, + "maxLength": 512 } }, { - "name": "upload-token", - "in": "header", - "required": true, - "description": "Signed upload control token returned when the upload session was created.", + "name": "mode", + "in": "query", + "required": false, + "description": "How `query` is read.", "schema": { + "default": "regex", + "description": "How `query` is read.", "type": "string", - "minLength": 1, - "description": "Signed upload control token returned when the upload session was created." + "enum": ["exact", "regex"] } - } + }, + { + "name": "maxResults", + "in": "query", + "required": false, + "description": "Maximum matching lines to return.", + "schema": { + "default": 50, + "description": "Maximum matching lines to return.", + "type": "integer", + "minimum": 1, + "maximum": 200 + } + }, + { + "name": "folderPaths", + "in": "query", + "required": false, + "description": "Comma-separated folder paths within the Project. Omit to search the entire Project; index coverage applies to the selected folders.", + "schema": { + "description": "Comma-separated folder paths within the Project. Omit to search the entire Project; index coverage applies to the selected folders.", + "type": "string" + } + }, + { + "name": "includeSubfolders", + "in": "query", + "required": false, + "description": "Whether the scope descends into nested folders. Absent means yes. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", + "schema": { + "description": "Whether the scope descends into nested folders. Absent means yes. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", + "enum": [ + "true", + "1", + "yes", + "on", + "y", + "enabled", + "false", + "0", + "no", + "off", + "n", + "disabled" + ], + "type": "string" + } + } ], "responses": { "200": { - "description": "Current upload-session state.", + "description": "Matching lines and the index coverage they were drawn from.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -418,7 +306,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FileUploadResponse" + "$ref": "#/components/schemas/V2ProjectFileSearchResultsResponse" } } } @@ -435,6 +323,12 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -445,53 +339,54 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - }, - "delete": { - "operationId": "abortFileUpload", - "summary": "Abort File Upload", - "description": "Abort an incomplete upload session and discard its uploaded data. Completed uploads cannot be aborted.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.upload.cancel", + } + }, + "/api/v2/projects/{projectId}/files/{fileId}": { + "patch": { + "operationId": "renameProjectFile", + "summary": "Rename Project File", + "description": "Rename a shared Project file while retaining its identity and history. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "project_files.rename", "x-oauth-scope": "api:write", "tags": ["Files"], "parameters": [ { - "name": "uploadId", + "name": "projectId", "in": "path", "required": true, - "description": "Upload session identifier.", - "schema": { - "type": "string", - "minLength": 1, - "description": "Upload session identifier." - } - }, - { - "name": "workspaceId", - "in": "query", - "required": true, - "description": "Workspace that owns the upload session.", + "description": "Project identifier.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "description": "Workspace that owns the upload session." + "description": "Project identifier." } }, { - "name": "upload-token", - "in": "header", + "name": "fileId", + "in": "path", "required": true, - "description": "Signed upload control token returned when the upload session was created.", + "description": "File identifier within the Project.", "schema": { "type": "string", "minLength": 1, - "description": "Signed upload control token returned when the upload session was created." + "description": "File identifier within the Project." } } ], + "requestBody": { + "required": true, + "description": "The new file name.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RenameProjectFileRequest" + } + } + } + }, "responses": { "200": { - "description": "The aborted upload session.", + "description": "Metadata for the renamed file.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -506,7 +401,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FileUploadResponse" + "$ref": "#/components/schemas/V2ProjectFileMetadataResponse" } } } @@ -526,6 +421,12 @@ "409": { "$ref": "#/components/responses/Conflict" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -538,64 +439,41 @@ } } }, - "/api/v2/files/uploads/{uploadId}/parts": { + "/api/v2/projects/{projectId}/files/move": { "post": { - "operationId": "createFileUploadPartUrls", - "summary": "Create File Upload Part URLs", - "description": "Create signed URLs for a bounded set of multipart upload part numbers.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.upload.parts", + "operationId": "moveProjectFileItems", + "summary": "Move Project File Items", + "description": "Move selected files and folders into an existing folder within the same Project. Folder contents move with their parent. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "project_files.move_items", "x-oauth-scope": "api:write", "tags": ["Files"], "parameters": [ { - "name": "uploadId", + "name": "projectId", "in": "path", "required": true, - "description": "Upload session identifier.", - "schema": { - "type": "string", - "minLength": 1, - "description": "Upload session identifier." - } - }, - { - "name": "workspaceId", - "in": "query", - "required": true, - "description": "Workspace that owns the upload session.", - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace that owns the upload session." - } - }, - { - "name": "upload-token", - "in": "header", - "required": true, - "description": "Signed upload control token returned when the upload session was created.", + "description": "Project identifier.", "schema": { "type": "string", "minLength": 1, - "description": "Signed upload control token returned when the upload session was created." + "description": "Project identifier." } } ], "requestBody": { "required": true, - "description": "Multipart part numbers requiring signed URLs.", + "description": "Selected files and folders and their destination.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateFileUploadPartUrlsRequest" + "$ref": "#/components/schemas/MoveProjectFileItemsRequest" } } } }, "responses": { "200": { - "description": "Signed URLs for the requested upload parts.", + "description": "Counts and identifiers of moved items.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -610,7 +488,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/CreateFileUploadPartUrlsResponse" + "$ref": "#/components/schemas/V2MoveProjectFileItemsResponse" } } } @@ -648,53 +526,41 @@ } } }, - "/api/v2/files/uploads/{uploadId}/complete": { + "/api/v2/projects/{projectId}/files/archive": { "post": { - "operationId": "completeFileUpload", - "summary": "Complete File Upload", - "description": "Finalize an upload and register its workspace file. Repeating a completed upload returns the existing file.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.upload.complete", + "operationId": "archiveProjectFileItems", + "summary": "Archive Project File Items", + "description": "Archive selected files and folders. Folder contents are archived recursively and remain recoverable until retention removes them. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "project_files.archive_items", "x-oauth-scope": "api:write", "tags": ["Files"], "parameters": [ { - "name": "uploadId", + "name": "projectId", "in": "path", "required": true, - "description": "Upload session identifier.", - "schema": { - "type": "string", - "minLength": 1, - "description": "Upload session identifier." - } - }, - { - "name": "workspaceId", - "in": "query", - "required": true, - "description": "Workspace that owns the upload session.", - "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace that owns the upload session." - } - }, - { - "name": "upload-token", - "in": "header", - "required": true, - "description": "Signed upload control token returned when the upload session was created.", + "description": "Project identifier.", "schema": { "type": "string", "minLength": 1, - "description": "Signed upload control token returned when the upload session was created." + "description": "Project identifier." } } ], + "requestBody": { + "required": true, + "description": "Files and folders to archive.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ArchiveProjectFileItemsRequest" + } + } + } + }, "responses": { "200": { - "description": "The completed or finalizing upload session.", + "description": "Counts and identifiers of archived items.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -709,7 +575,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FileUploadResponse" + "$ref": "#/components/schemas/V2ArchiveProjectFileItemsResponse" } } } @@ -729,6 +595,12 @@ "409": { "$ref": "#/components/responses/Conflict" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -741,79 +613,52 @@ } } }, - "/api/v2/files/{fileId}/text": { - "get": { - "operationId": "readFileText", - "summary": "Read File Text", - "description": "Extract text without changing the file. Accepts its ID or canonical path (`files//` or `uploads/` for an unlisted chat upload); the response echoes the read path. Use Unzip File to unpack archives or Download File for original bytes. Unsupported types return `400`, compiling documents return `409`, and oversized files return `413`. `degraded: true` indicates incomplete or synthesized text, such as the legacy `.pptx` fallback; `truncated: true` indicates a parser limit.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "files.read_content", - "x-oauth-scope": "api:read", + "/api/v2/projects/{projectId}/files/{fileId}/restore": { + "post": { + "operationId": "restoreProjectFile", + "summary": "Restore Project File", + "description": "Restore an archived Project file. If its former folder is unavailable, restore it to the Project root with an available name. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "project_files.restore", + "x-oauth-scope": "api:write", "tags": ["Files"], "parameters": [ { - "name": "fileId", + "name": "projectId", "in": "path", "required": true, - "description": "File identifier, or the file’s VFS path: `files//`, or `uploads/` for a Chat upload.", + "description": "Project identifier.", "schema": { "type": "string", "minLength": 1, - "maxLength": 4096, - "description": "File identifier, or the file’s VFS path: `files//`, or `uploads/` for a Chat upload." + "description": "Project identifier." } }, { - "name": "workspaceId", - "in": "query", + "name": "fileId", + "in": "path", "required": true, - "description": "Workspace that owns the file.", + "description": "File identifier within the Project.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "description": "Workspace that owns the file." - } - }, - { - "name": "maxBytes", - "in": "query", - "required": false, - "description": "Optional ceiling on the source bytes fed to the parser, lowering but never raising the server limit.", - "schema": { - "description": "Optional ceiling on the source bytes fed to the parser, lowering but never raising the server limit.", - "type": "integer", - "minimum": 1, - "maximum": 26214400 - } - }, - { - "name": "offset", - "in": "query", - "required": false, - "description": "First line to return, 1-based; 0 also starts at the first line. Absent starts at the first line.", - "schema": { - "description": "First line to return, 1-based; 0 also starts at the first line. Absent starts at the first line.", - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - { - "name": "limit", - "in": "query", - "required": false, - "description": "How many lines to return from `offset`. Absent reads to the end.", - "schema": { - "description": "How many lines to return from `offset`. Absent reads to the end.", - "type": "integer", - "minimum": 1, - "maximum": 9007199254740991 + "description": "File identifier within the Project." } } ], + "requestBody": { + "required": true, + "description": "An empty object; the file is identified by its path parameters.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestoreProjectFileRequest" + } + } + } + }, "responses": { "200": { - "description": "The extracted text and its extraction-quality flags.", + "description": "Metadata for the restored file.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -828,7 +673,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FileTextResponse" + "$ref": "#/components/schemas/V2ProjectFileMetadataResponse" } } } @@ -851,6 +696,9 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -863,50 +711,179 @@ } } }, - "/api/v2/files/{fileId}/versions": { + "/api/v2/projects/{projectId}/files": { + "post": { + "operationId": "createProjectFile", + "summary": "Create Project File", + "description": "Create a shared Project file from inline text or base64 bytes. Names are exact; an existing sibling name returns a conflict. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "project_files.create", + "x-oauth-scope": "api:write", + "tags": ["Files"], + "parameters": [ + { + "name": "projectId", + "in": "path", + "required": true, + "description": "Project identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Project identifier." + } + } + ], + "requestBody": { + "required": true, + "description": "Name, content, encoding, and containing folder within the Project.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateProjectFileRequest" + } + } + } + }, + "responses": { + "201": { + "description": "The created file and its content revision.", + "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/V2ProjectFileMetadataResponse" + } + } + } + }, + "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": "listFileVersions", - "summary": "List File Versions", - "description": "List the versions of a file, newest first by default. Each write that changes the bytes records one; identical rewrites do not. Collaborative edits, and repeated workflow writes by one author, fold into a version under ten minutes old and written in the last five. Renames and moves are not versions. Retention removes older versions by age and plan but keeps the newest ten, so numbers can have gaps.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "files.versions.list", + "operationId": "listProjectFiles", + "summary": "List Project Files", + "description": "List shared Project files with cursor pagination. Use scope=archived to find archived files. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "project_files.list", "x-oauth-scope": "api:read", "tags": ["Files"], "parameters": [ { - "name": "fileId", + "name": "projectId", "in": "path", "required": true, - "description": "File identifier.", + "description": "Project identifier.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9_-]+$", - "description": "File identifier." + "description": "Project identifier." } }, { - "name": "workspaceId", + "name": "folderPath", "in": "query", - "required": true, - "description": "Workspace that owns the file.", + "required": false, + "description": "Restrict files to this folder, including subfolders when `recursive` is true. Unknown folder paths contribute no matches.", + "schema": { + "description": "Restrict files to this folder, including subfolders when `recursive` is true. Unknown folder paths contribute no matches.", + "$ref": "#/components/schemas/FolderPathInput" + } + }, + { + "name": "recursive", + "in": "query", + "required": false, + "description": "Include subfolders in the folder filter. Defaults to true when searching and false otherwise. Ignored without a folder filter.", + "schema": { + "description": "Include subfolders in the folder filter. Defaults to true when searching and false otherwise. Ignored without a folder filter.", + "enum": [ + "true", + "1", + "yes", + "on", + "y", + "enabled", + "false", + "0", + "no", + "off", + "n", + "disabled" + ], + "type": "string" + } + }, + { + "name": "scope", + "in": "query", + "required": false, + "description": "Which lifecycle set to list: `active` (default) for live files, `archived` for files a delete soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.", + "schema": { + "default": "active", + "description": "Which lifecycle set to list: `active` (default) for live files, `archived` for files a delete soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.", + "type": "string", + "enum": ["active", "archived"] + } + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the file name.", "schema": { + "description": "Case-insensitive substring match against the file name.", "type": "string", "minLength": 1, - "maxLength": 128, - "description": "Workspace that owns the file." + "maxLength": 200 } }, { "name": "sortBy", "in": "query", "required": false, - "description": "Field used to sort the result.", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "schema": { - "default": "version", - "description": "Field used to sort the result.", + "default": "uploadedAt", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "type": "string", - "enum": ["version"] + "enum": ["name", "size", "uploadedAt", "updatedAt"] } }, { @@ -915,7 +892,7 @@ "required": false, "description": "Sort direction.", "schema": { - "default": "desc", + "default": "asc", "description": "Sort direction.", "type": "string", "enum": ["asc", "desc"] @@ -925,13 +902,13 @@ "name": "limit", "in": "query", "required": false, - "description": "Maximum versions to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "description": "Maximum files per page. Must be a whole number from 1 to 1000. Defaults to 100.", "schema": { - "default": 50, - "description": "Maximum versions to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "default": 100, + "description": "Maximum files per page. Must be a whole number from 1 to 1000. Defaults to 100.", "type": "integer", "minimum": 1, - "maximum": 100 + "maximum": 1000 } }, { @@ -948,7 +925,7 @@ ], "responses": { "200": { - "description": "A page of file versions.", + "description": "A page of Project files.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -963,7 +940,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2FileVersionListResponse" + "$ref": "#/components/schemas/V2ProjectFileListResponse" } } } @@ -992,71 +969,57 @@ } } }, - "/api/v2/files/{fileId}/versions/{version}": { + "/api/v2/projects/{projectId}/files/{fileId}/content": { "get": { - "operationId": "getFileVersion", - "summary": "Get File Version", - "description": "Get one version of a file. A version removed by retention, or one that never existed, returns `404`.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "files.versions.read", + "operationId": "readProjectFileContent", + "summary": "Read Project File Source", + "description": "Read the stored source bytes of a Project file. Generated documents return their generation source; rendered downloads are separate. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "project_files.read_content", "x-oauth-scope": "api:read", "tags": ["Files"], "parameters": [ { - "name": "fileId", + "name": "projectId", "in": "path", "required": true, - "description": "File identifier.", + "description": "Project identifier.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9_-]+$", - "description": "File identifier." + "description": "Project identifier." } }, { - "name": "version", + "name": "fileId", "in": "path", "required": true, - "description": "Version number.", - "schema": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 2147483647, - "description": "Version number." - } - }, - { - "name": "workspaceId", - "in": "query", - "required": true, - "description": "Workspace that owns the file.", + "description": "File identifier within the Project.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "description": "Workspace that owns the file." + "description": "File identifier within the Project." } } ], "responses": { "200": { - "description": "The file version.", + "description": "The current stored content bytes.", "headers": { - "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" + "Content-Type": { + "$ref": "#/components/headers/Content-Type" }, - "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" + "Content-Disposition": { + "$ref": "#/components/headers/Content-Disposition" }, - "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" + "Content-Length": { + "$ref": "#/components/headers/Content-Length" } }, "content": { - "application/json": { + "application/octet-stream": { "schema": { - "$ref": "#/components/schemas/V2FileVersionResponse" + "type": "string", + "format": "binary" } } } @@ -1073,6 +1036,12 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1084,55 +1053,51 @@ } } }, - "delete": { - "operationId": "deleteFileVersion", - "summary": "Delete File Version", - "description": "Permanently delete one earlier version and its stored content, for example to purge a leaked value from history before retention removes it. The current version returns `409`; revert to another version first. A version that does not exist returns `404`.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.versions.delete", + "put": { + "operationId": "updateProjectFileContent", + "summary": "Replace Project File Content", + "description": "Replace the complete content of a Project file. Supply expectedRevision to reject a stale edit with 409. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "project_files.update_content", "x-oauth-scope": "api:write", "tags": ["Files"], "parameters": [ { - "name": "fileId", + "name": "projectId", "in": "path", "required": true, - "description": "File identifier.", + "description": "Project identifier.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9_-]+$", - "description": "File identifier." + "description": "Project identifier." } }, { - "name": "version", + "name": "fileId", "in": "path", "required": true, - "description": "Version number.", - "schema": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 2147483647, - "description": "Version number." - } - }, - { - "name": "workspaceId", - "in": "query", - "required": true, - "description": "Workspace that owns the file.", + "description": "File identifier within the Project.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "description": "Workspace that owns the file." + "description": "File identifier within the Project." } } ], + "requestBody": { + "required": true, + "description": "Complete replacement bytes and an optional optimistic concurrency revision.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateProjectFileContentRequest" + } + } + } + }, "responses": { "200": { - "description": "Deletion confirmation.", + "description": "The updated file and its content revision.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1147,7 +1112,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2FileVersionDeleteResponse" + "$ref": "#/components/schemas/V2ProjectFileMetadataResponse" } } } @@ -1167,6 +1132,12 @@ "409": { "$ref": "#/components/responses/Conflict" }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1179,92 +1150,41 @@ } } }, - "/api/v2/files/{fileId}/versions/{version}/text": { + "/api/v2/projects/{projectId}/files/{fileId}/metadata": { "get": { - "operationId": "readFileVersionText", - "summary": "Read File Version Text", - "description": "Extract the text of one version, exactly as Read File Text extracts the current content. Unsupported types return `400`, compiling documents return `409`, and oversized versions return `413`.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "files.versions.read_content", + "operationId": "getProjectFileMetadata", + "summary": "Get Project File Metadata", + "description": "Get an active file's metadata and Project ownership. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "project_files.read_metadata", "x-oauth-scope": "api:read", "tags": ["Files"], "parameters": [ { - "name": "fileId", + "name": "projectId", "in": "path", "required": true, - "description": "File identifier.", + "description": "Project identifier.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9_-]+$", - "description": "File identifier." + "description": "Project identifier." } }, { - "name": "version", + "name": "fileId", "in": "path", "required": true, - "description": "Version number.", - "schema": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 2147483647, - "description": "Version number." - } - }, - { - "name": "workspaceId", - "in": "query", - "required": true, - "description": "Workspace that owns the file.", + "description": "File identifier within the Project.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "description": "Workspace that owns the file." - } - }, - { - "name": "maxBytes", - "in": "query", - "required": false, - "description": "Optional ceiling on the source bytes fed to the parser, lowering but never raising the server limit.", - "schema": { - "description": "Optional ceiling on the source bytes fed to the parser, lowering but never raising the server limit.", - "type": "integer", - "minimum": 1, - "maximum": 26214400 - } - }, - { - "name": "offset", - "in": "query", - "required": false, - "description": "First line to return, 1-based; 0 also starts at the first line. Absent starts at the first line.", - "schema": { - "description": "First line to return, 1-based; 0 also starts at the first line. Absent starts at the first line.", - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991 - } - }, - { - "name": "limit", - "in": "query", - "required": false, - "description": "How many lines to return from `offset`. Absent reads to the end.", - "schema": { - "description": "How many lines to return from `offset`. Absent reads to the end.", - "type": "integer", - "minimum": 1, - "maximum": 9007199254740991 + "description": "File identifier within the Project." } } ], "responses": { "200": { - "description": "The extracted text of the version and its extraction-quality flags.", + "description": "Project file metadata.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1279,7 +1199,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FileVersionTextResponse" + "$ref": "#/components/schemas/V2ProjectFileMetadataResponse" } } } @@ -1296,12 +1216,6 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "409": { - "$ref": "#/components/responses/Conflict" - }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" - }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1314,56 +1228,50 @@ } } }, - "/api/v2/files/{fileId}/versions/{version}/content": { + "/api/v2/projects/{projectId}/files/bulk-download": { "get": { - "operationId": "downloadFileVersion", - "summary": "Download File Version", - "description": "Download the bytes of one version, served exactly as Download File serves the current bytes. Generated documents use compiled artifacts, returning `409` while compiling and `413` above the rendered-size ceiling. Downloading records an audit event. `HEAD` checks access with the same authorization as `GET` but skips side effects, returning an empty `200` without payload headers on success. `HEAD` omits `Content-Length`; use file metadata to size downloads.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "files.versions.download", + "operationId": "downloadProjectFileItems", + "summary": "Download Project File Items", + "description": "Download selected files and recursive folder contents as one ZIP archive. Duplicate selections are included once. Select at most 100 files in total; archive bytes are bounded. Unknown or archived selections are rejected. Downloads record an audit event. Workspace API keys return `403`; use a personal API key or scoped OAuth token. `HEAD` checks access with the same authorization as `GET` but skips side effects, returning an empty `200` without payload headers on success. `HEAD` omits `Content-Length`; use file metadata to size downloads.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "project_files.download_items", "x-oauth-scope": "api:read", "tags": ["Files"], "parameters": [ { - "name": "fileId", + "name": "projectId", "in": "path", "required": true, - "description": "File identifier.", + "description": "Project identifier.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9_-]+$", - "description": "File identifier." + "description": "Project identifier." } }, { - "name": "version", - "in": "path", - "required": true, - "description": "Version number.", + "name": "fileIds", + "in": "query", + "required": false, + "description": "File identifiers to include, comma-separated. At most 100 entries.", "schema": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 2147483647, - "description": "Version number." + "description": "File identifiers to include, comma-separated. At most 100 entries.", + "type": "string" } }, { - "name": "workspaceId", + "name": "folderIds", "in": "query", - "required": true, - "description": "Workspace that owns the file.", + "required": false, + "description": "Folder identifiers to include recursively, comma-separated. The resolved selection allows at most 100 files.", "schema": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "description": "Workspace that owns the file." + "description": "Folder identifiers to include recursively, comma-separated. The resolved selection allows at most 100 files.", + "type": "string" } } ], "responses": { "200": { - "description": "The version bytes.", + "description": "Selected files in their Project folder paths.", "headers": { "Content-Type": { "$ref": "#/components/headers/Content-Type" @@ -1373,19 +1281,10 @@ }, "Content-Length": { "$ref": "#/components/headers/Content-Length" - }, - "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/octet-stream": { + "application/zip": { "schema": { "type": "string", "format": "binary" @@ -1423,70 +1322,74 @@ } } }, - "/api/v2/files/{fileId}/versions/{version}/revert": { + "/api/v2/projects/{projectId}/files/{fileId}/export": { "post": { - "operationId": "revertFileVersion", - "summary": "Revert File Version", - "description": "Make the content of a version current again by writing it as a new `revert` version, so the revert can itself be reverted. Open editors receive the change. Reverting to the current version writes nothing and returns `reverted: false`. A concurrent write, or an `expectedCurrentVersion` that is no longer current, returns `409`; a version above 100 MB returns `413`.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.versions.revert", - "x-oauth-scope": "api:write", + "operationId": "exportProjectFileSnapshot", + "summary": "Export Project File Snapshot", + "description": "Export the supplied visible Markdown snapshot without changing the stored file or its history. Readable embedded Project assets are bundled in a ZIP; a snapshot without bundled assets is returned as Markdown. Missing or unreadable assets remain as references. Total bytes are bounded. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "project_files.export_snapshot", + "x-oauth-scope": "api:read", "tags": ["Files"], "parameters": [ { - "name": "fileId", + "name": "projectId", "in": "path", "required": true, - "description": "File identifier.", + "description": "Project identifier.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9_-]+$", - "description": "File identifier." + "description": "Project identifier." } }, { - "name": "version", + "name": "fileId", "in": "path", "required": true, - "description": "Version number.", + "description": "File identifier within the Project.", "schema": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 2147483647, - "description": "Version number." + "type": "string", + "minLength": 1, + "description": "File identifier within the Project." } } ], "requestBody": { "required": true, - "description": "Workspace scope and an optional current-version precondition.", + "description": "The visible document content to export.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RevertFileVersionRequest" + "$ref": "#/components/schemas/ExportProjectFileSnapshotRequest" } } } }, "responses": { "200": { - "description": "The file and its current version after the revert.", + "description": "Markdown snapshot or its archive with embedded assets.", "headers": { - "X-RateLimit-Limit": { - "$ref": "#/components/headers/X-RateLimit-Limit" + "Content-Type": { + "$ref": "#/components/headers/Content-Type" }, - "X-RateLimit-Remaining": { - "$ref": "#/components/headers/X-RateLimit-Remaining" + "Content-Disposition": { + "$ref": "#/components/headers/Content-Disposition" }, - "X-RateLimit-Reset": { - "$ref": "#/components/headers/X-RateLimit-Reset" + "Content-Length": { + "$ref": "#/components/headers/Content-Length" } }, "content": { - "application/json": { + "text/markdown": { "schema": { - "$ref": "#/components/schemas/V2FileVersionRevertResponse" + "type": "string", + "format": "binary" + } + }, + "application/zip": { + "schema": { + "type": "string", + "format": "binary" } } } @@ -1524,58 +1427,90 @@ } } }, - "/api/v2/files/bulk-download": { + "/api/v2/projects/{projectId}/files/{fileId}/versions": { "get": { - "operationId": "bulkDownloadFiles", - "summary": "Bulk Download Files", - "description": "Stream selected files and recursive folder contents as a ZIP archive. Each selection parameter and the resolved set allow 100 entries; unmatched paths or excess entries return `400`. Total bytes are bounded. Downloads record an audit event. `HEAD` checks access with the same authorization as `GET` but skips side effects, returning an empty `200` without payload headers on success. `HEAD` omits `Content-Length`; use file metadata to size downloads.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "files.download", + "operationId": "listProjectFileVersions", + "summary": "List Project File Versions", + "description": "List recorded versions of a shared Project file, newest first by default. Retention follows the Project payer and preserves the newest ten versions; removed versions leave gaps in numbering. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "project_files.versions.list", "x-oauth-scope": "api:read", "tags": ["Files"], "parameters": [ { - "name": "workspaceId", - "in": "query", + "name": "projectId", + "in": "path", "required": true, - "description": "Workspace containing the selection.", + "description": "Project identifier.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "description": "Workspace containing the selection." + "description": "Project identifier." } }, { - "name": "fileIds", + "name": "fileId", + "in": "path", + "required": true, + "description": "File identifier within the Project.", + "schema": { + "type": "string", + "minLength": 1, + "description": "File identifier within the Project." + } + }, + { + "name": "sortBy", "in": "query", "required": false, - "description": "File identifiers to include, comma-separated. At most 100 entries.", + "description": "Field used to sort the result.", "schema": { - "description": "File identifiers to include, comma-separated. At most 100 entries.", - "type": "string" + "default": "version", + "description": "Field used to sort the result.", + "type": "string", + "enum": ["version"] } }, { - "name": "folderPaths", + "name": "sortOrder", "in": "query", "required": false, - "description": "Comma-separated folder paths whose contents are included recursively. Up to 100 paths; resolved files share the 100-file download limit. Unknown paths are rejected.", + "description": "Sort direction.", "schema": { - "description": "Comma-separated folder paths whose contents are included recursively. Up to 100 paths; resolved files share the 100-file download limit. Unknown paths are rejected.", - "type": "string" + "default": "desc", + "description": "Sort direction.", + "type": "string", + "enum": ["asc", "desc"] + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum versions to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "schema": { + "default": 50, + "description": "Maximum versions to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "type": "integer", + "minimum": 1, + "maximum": 100 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "schema": { + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "type": "string", + "minLength": 1 } } ], "responses": { "200": { - "description": "The selected files as a zip archive.", + "description": "A cursor-paginated page of Project file versions.", "headers": { - "Content-Type": { - "$ref": "#/components/headers/Content-Type" - }, - "Content-Disposition": { - "$ref": "#/components/headers/Content-Disposition" - }, "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" }, @@ -1587,10 +1522,9 @@ } }, "content": { - "application/zip": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/V2ProjectFileVersionListResponse" } } } @@ -1607,9 +1541,6 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "409": { - "$ref": "#/components/responses/Conflict" - }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1622,43 +1553,53 @@ } } }, - "/api/v2/files/{fileId}/unzip": { - "post": { - "operationId": "unzipFile", - "summary": "Unzip File", - "description": "Extract a ZIP archive into a new sibling folder and return counts and the destination path. Use List Files to inspect its contents. Large archives can take minutes; concurrent extraction of the same archive returns `409`. Size or processing-time limits return `413`.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.extract_archive", - "x-oauth-scope": "api:write", + "/api/v2/projects/{projectId}/files/{fileId}/versions/{version}": { + "get": { + "operationId": "getProjectFileVersion", + "summary": "Get Project File Version", + "description": "Get metadata and author attribution for a recorded Project file version. Missing or permanently removed versions return `404`. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "project_files.versions.read", + "x-oauth-scope": "api:read", "tags": ["Files"], "parameters": [ + { + "name": "projectId", + "in": "path", + "required": true, + "description": "Project identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Project identifier." + } + }, { "name": "fileId", "in": "path", "required": true, - "description": "File identifier.", + "description": "File identifier within the Project.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9_-]+$", - "description": "File identifier." + "description": "File identifier within the Project." } - } - ], - "requestBody": { - "required": true, - "description": "Workspace scope for the archive.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UnzipFileBody" - } + }, + { + "name": "version", + "in": "path", + "required": true, + "description": "Version number.", + "schema": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 2147483647, + "description": "Version number." } } - }, + ], "responses": { "200": { - "description": "Counts and destination folder for the unpacked archive.", + "description": "Metadata for the selected version.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1673,7 +1614,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FileUnzipResponse" + "$ref": "#/components/schemas/V2ProjectFileVersionResponse" } } } @@ -1690,15 +1631,6 @@ "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" }, @@ -1709,56 +1641,54 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - } - }, - "/api/v2/files/{fileId}": { - "get": { - "operationId": "downloadFile", - "summary": "Download File", - "description": "Download current file bytes. Generated documents use compiled artifacts, returning `409` while compiling and `413` above the rendered-size ceiling. Downloading records an audit event. `HEAD` checks access with the same authorization as `GET` but skips side effects, returning an empty `200` without payload headers on success. `HEAD` omits `Content-Length`; use file metadata to size downloads.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "files.download", - "x-oauth-scope": "api:read", + }, + "delete": { + "operationId": "deleteProjectFileVersion", + "summary": "Delete Project File Version", + "description": "Permanently remove one superseded Project file version from history. Deleting the current version returns `409`; other versions and the current file remain available. Stored-object cleanup is retried asynchronously when needed. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "project_files.versions.delete", + "x-oauth-scope": "api:write", "tags": ["Files"], "parameters": [ { - "name": "fileId", + "name": "projectId", "in": "path", "required": true, - "description": "File identifier.", + "description": "Project identifier.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9_-]+$", - "description": "File identifier." + "description": "Project identifier." } }, { - "name": "workspaceId", - "in": "query", + "name": "fileId", + "in": "path", "required": true, - "description": "Workspace that owns the file.", + "description": "File identifier within the Project.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "description": "Workspace that owns the file." + "description": "File identifier within the Project." + } + }, + { + "name": "version", + "in": "path", + "required": true, + "description": "Version number.", + "schema": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 2147483647, + "description": "Version number." } } ], "responses": { "200": { - "description": "The file bytes.", + "description": "Deletion acknowledgement for the superseded version.", "headers": { - "Content-Type": { - "$ref": "#/components/headers/Content-Type" - }, - "Content-Disposition": { - "$ref": "#/components/headers/Content-Disposition" - }, - "Content-Length": { - "$ref": "#/components/headers/Content-Length" - }, "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" }, @@ -1770,10 +1700,9 @@ } }, "content": { - "application/octet-stream": { + "application/json": { "schema": { - "type": "string", - "format": "binary" + "$ref": "#/components/schemas/V2ProjectFileVersionDeleteResponse" } } } @@ -1793,9 +1722,6 @@ "409": { "$ref": "#/components/responses/Conflict" }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" - }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1806,44 +1732,55 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - }, - "delete": { - "operationId": "deleteFile", - "summary": "Delete File", - "description": "Archive a workspace file, retaining its stored bytes and removing API read access. List Files with `scope=archived` finds it; Restore File recovers it. Archiving an already archived file returns `404`.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.delete", - "x-oauth-scope": "api:write", + } + }, + "/api/v2/projects/{projectId}/files/{fileId}/versions/{version}/content": { + "get": { + "operationId": "readProjectFileVersionContent", + "summary": "Read Project File Version Content", + "description": "Read the stored source bytes of a Project file version using the file’s current name. Generated documents return their editable source, rather than a compiled Office or page export. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "project_files.versions.read_content", + "x-oauth-scope": "api:read", "tags": ["Files"], "parameters": [ { - "name": "fileId", + "name": "projectId", "in": "path", "required": true, - "description": "File identifier.", + "description": "Project identifier.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9_-]+$", - "description": "File identifier." + "description": "Project identifier." } }, { - "name": "workspaceId", - "in": "query", + "name": "fileId", + "in": "path", "required": true, - "description": "Workspace that owns the file.", + "description": "File identifier within the Project.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "description": "Workspace that owns the file." + "description": "File identifier within the Project." + } + }, + { + "name": "version", + "in": "path", + "required": true, + "description": "Version number.", + "schema": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 2147483647, + "description": "Version number." } } ], "responses": { "200": { - "description": "Deletion confirmation.", + "description": "The selected version’s stored source bytes.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1856,9 +1793,10 @@ } }, "content": { - "application/json": { + "application/octet-stream": { "schema": { - "$ref": "#/components/schemas/V2DeleteFileResponse" + "type": "string", + "format": "binary" } } } @@ -1875,6 +1813,12 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -1885,43 +1829,66 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - }, - "patch": { - "operationId": "renameFile", - "summary": "Rename File", - "description": "Rename a workspace file without changing its containing folder.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.rename", + } + }, + "/api/v2/projects/{projectId}/files/{fileId}/versions/{version}/revert": { + "post": { + "operationId": "revertProjectFileVersion", + "summary": "Revert Project File Version", + "description": "Make an earlier version current by recording its source bytes as a new revert version. Reverting to the current version is a no-op. Use expectedRevision to reject changes made since the last read; a stale revision or concurrent edit returns `409`. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "project_files.versions.revert", "x-oauth-scope": "api:write", "tags": ["Files"], "parameters": [ + { + "name": "projectId", + "in": "path", + "required": true, + "description": "Project identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Project identifier." + } + }, { "name": "fileId", "in": "path", "required": true, - "description": "File identifier.", + "description": "File identifier within the Project.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9_-]+$", - "description": "File identifier." + "description": "File identifier within the Project." + } + }, + { + "name": "version", + "in": "path", + "required": true, + "description": "Version number.", + "schema": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 2147483647, + "description": "Version number." } } ], "requestBody": { "required": true, - "description": "Workspace scope and new file name.", + "description": "Optional revision or current-version preconditions.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RenameFileRequest" + "$ref": "#/components/schemas/RevertProjectFileVersionRequest" } } } }, "responses": { "200": { - "description": "The renamed file.", + "description": "The file and current version after the revert.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -1936,7 +1903,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2FileResponse" + "$ref": "#/components/schemas/V2ProjectFileVersionRevertResponse" } } } @@ -1974,43 +1941,118 @@ } } }, - "/api/v2/files/{fileId}/restore": { + "/api/v2/projects/{projectId}/files/folders": { + "get": { + "operationId": "listProjectFileFolders", + "summary": "List Project File Folders", + "description": "List the Project folder tree with stable identifiers. Use scope=archived to find folders eligible for restore. Returns the complete set in one page; `nextCursor` is always null. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "project_file_folders.list", + "x-oauth-scope": "api:read", + "tags": ["Files"], + "parameters": [ + { + "name": "projectId", + "in": "path", + "required": true, + "description": "Project identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Project identifier." + } + }, + { + "name": "scope", + "in": "query", + "required": false, + "description": "Folder lifecycle scope.", + "schema": { + "default": "active", + "description": "Folder lifecycle scope.", + "type": "string", + "enum": ["active", "archived", "all"] + } + } + ], + "responses": { + "200": { + "description": "The complete set of folders in the selected lifecycle scope.", + "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/V2ProjectFileFolderListResponse" + } + } + } + }, + "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" + } + } + }, "post": { - "operationId": "restoreFile", - "summary": "Restore File", - "description": "Restore a soft-deleted file to its original folder, or the workspace root if that folder was archived. Name collisions add a `_restored` suffix; read `folderPath` and `name` from the response. Already-active files return unchanged, making retries safe. An archived workspace returns `400`; an unresolved name collision returns `409`.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.restore", + "operationId": "createProjectFileFolder", + "summary": "Create Project File Folder", + "description": "Create a folder under an existing parent, or at the Project root when parentId is omitted. Sibling names must be unique. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "project_file_folders.create", "x-oauth-scope": "api:write", "tags": ["Files"], "parameters": [ { - "name": "fileId", + "name": "projectId", "in": "path", "required": true, - "description": "File identifier.", + "description": "Project identifier.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9_-]+$", - "description": "File identifier." + "description": "Project identifier." } } ], "requestBody": { "required": true, - "description": "Workspace scope for the archived file.", + "description": "The folder name and optional parent identifier.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RestoreFileRequest" + "$ref": "#/components/schemas/CreateProjectFileFolderRequest" } } } }, "responses": { - "200": { - "description": "The file as it exists after the restore.", + "201": { + "description": "The created folder and its owner and creator attribution.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -2025,7 +2067,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2RestoreFileResponse" + "$ref": "#/components/schemas/V2ProjectFileFolderResponse" } } } @@ -2063,56 +2105,52 @@ } } }, - "/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/projects/{projectId}/files/folders/{folderId}": { + "patch": { + "operationId": "updateProjectFileFolder", + "summary": "Update Project File Folder", + "description": "Rename, move, or reorder a folder while retaining its identity and descendants. Omitted fields stay unchanged; parentId=null moves the folder to the root. Cross-owner parents and cycles are rejected. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "project_file_folders.update", + "x-oauth-scope": "api:write", "tags": ["Files"], "parameters": [ { - "name": "fileId", + "name": "projectId", "in": "path", "required": true, - "description": "File identifier.", + "description": "Project identifier.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9_-]+$", - "description": "File identifier." + "description": "Project identifier." } }, { - "name": "workspaceId", - "in": "query", + "name": "folderId", + "in": "path", "required": true, - "description": "Workspace that owns the file.", + "description": "Folder identifier within the Project.", "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"] + "description": "Folder identifier within the Project." } } ], + "requestBody": { + "required": true, + "description": "The fields to change on the folder.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateProjectFileFolderRequest" + } + } + } + }, "responses": { "200": { - "description": "File metadata and public-share state.", + "description": "The updated folder.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -2127,7 +2165,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2FileMetadataResponse" + "$ref": "#/components/schemas/V2ProjectFileFolderResponse" } } } @@ -2144,6 +2182,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" }, @@ -2156,142 +2203,52 @@ } } }, - "/api/v2/audit-logs": { - "get": { - "operationId": "listAuditLogs", - "summary": "List Audit Logs", - "description": "List an organization audit trail with filters and opaque cursor pagination. Requires an Enterprise subscription and organization admin or owner access. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "audit_logs.list", - "x-oauth-scope": "api:read", - "tags": ["Audit Logs"], + "/api/v2/projects/{projectId}/files/folders/{folderId}/restore": { + "post": { + "operationId": "restoreProjectFileFolder", + "summary": "Restore Project File Folder", + "description": "Restore an archived folder and the files and subfolders archived with it. Find identifiers with List Project File Folders using scope=archived. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "project_file_folders.restore", + "x-oauth-scope": "api:write", + "tags": ["Files"], "parameters": [ { - "name": "action", - "in": "query", - "required": false, - "description": "Filter by exact action name.", - "schema": { - "description": "Filter by exact action name.", - "type": "string" - } - }, - { - "name": "resourceType", - "in": "query", - "required": false, - "description": "Filter by resource type. Accepts a comma-separated set; members are trimmed and deduplicated, and member order affects neither the result nor the cursor.", - "schema": { - "description": "Filter by resource type. Accepts a comma-separated set; members are trimmed and deduplicated, and member order affects neither the result nor the cursor.", - "type": "string" - } - }, - { - "name": "resourceId", - "in": "query", - "required": false, - "description": "Filter by exact resource identifier.", - "schema": { - "description": "Filter by exact resource identifier.", - "type": "string" - } - }, - { - "name": "workspaceId", - "in": "query", - "required": false, - "description": "Filter to actions in one workspace.", + "name": "projectId", + "in": "path", + "required": true, + "description": "Project identifier.", "schema": { - "description": "Filter to actions in one workspace.", "type": "string", "minLength": 1, - "maxLength": 128 - } - }, - { - "name": "startDate", - "in": "query", - "required": false, - "description": "Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant.", - "schema": { - "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))$", - "description": "Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant." - } - }, - { - "name": "endDate", - "in": "query", - "required": false, - "description": "Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant.", - "schema": { - "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))$", - "description": "Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant." - } - }, - { - "name": "includeDeparted", - "in": "query", - "required": false, - "description": "Include actions by users who have left the organization.", - "schema": { - "description": "Include actions by users who have left the organization.", - "type": "boolean" - } - }, - { - "name": "limit", - "in": "query", - "required": false, - "description": "Maximum audit entries to return per page. Must be a whole number from 1 to 100. Defaults to 50.", - "schema": { - "default": 50, - "description": "Maximum audit entries to return per page. Must be a whole number from 1 to 100. Defaults to 50.", - "type": "integer", - "minimum": 1, - "maximum": 100 - } - }, - { - "name": "cursor", - "in": "query", - "required": false, - "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", - "schema": { - "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", - "type": "string", - "minLength": 1 - } - }, - { - "name": "organizationId", - "in": "query", - "required": false, - "description": "Organization whose audit trail should be queried. Defaults to the caller's own organization when omitted. A caller that belongs to no organization, or that names one it is not a member of, is refused with a 403.", - "schema": { - "description": "Organization whose audit trail should be queried. Defaults to the caller's own organization when omitted. A caller that belongs to no organization, or that names one it is not a member of, is refused with a 403.", - "type": "string", - "minLength": 1 + "description": "Project identifier." } }, { - "name": "actorEmail", - "in": "query", - "required": false, - "description": "Filter by actor email address.", + "name": "folderId", + "in": "path", + "required": true, + "description": "Folder identifier within the Project.", "schema": { - "description": "Filter by actor email address.", "type": "string", - "format": "email", - "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$" + "minLength": 1, + "description": "Folder identifier within the Project." } } ], + "requestBody": { + "required": true, + "description": "An empty object; the folder is identified in the path.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestoreProjectFileFolderRequest" + } + } + } + }, "responses": { "200": { - "description": "A page of audit-log entries.", + "description": "The restored folder and affected item counts.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -2306,7 +2263,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2AuditLogListResponse" + "$ref": "#/components/schemas/V2RestoreProjectFileFolderResponse" } } } @@ -2320,6 +2277,18 @@ "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" }, @@ -2332,41 +2301,41 @@ } } }, - "/api/v2/audit-logs/{auditLogId}": { + "/api/v2/projects/{projectId}/files/{fileId}/share": { "get": { - "operationId": "getAuditLog", - "summary": "Get Audit Log", - "description": "Get one organization audit-log entry. Requires an Enterprise subscription and organization admin or owner access. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "audit_logs.read_detail", + "operationId": "getProjectFileShare", + "summary": "Get Project File Share", + "description": "Get a Project file's public-share configuration. An unshared file returns data: null; a disabled share retains its configuration with isActive: false. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "project_files.share.read", "x-oauth-scope": "api:read", - "tags": ["Audit Logs"], + "tags": ["Files"], "parameters": [ { - "name": "auditLogId", + "name": "projectId", "in": "path", "required": true, - "description": "Audit-log entry identifier.", + "description": "Project identifier.", "schema": { "type": "string", "minLength": 1, - "description": "Audit-log entry identifier." + "description": "Project identifier." } }, { - "name": "organizationId", - "in": "query", - "required": false, - "description": "Organization whose audit-log entry should be returned. Defaults to the caller's own organization when omitted. A caller that belongs to no organization, or that names one it is not a member of, is refused with a 403.", + "name": "fileId", + "in": "path", + "required": true, + "description": "File identifier within the Project.", "schema": { - "description": "Organization whose audit-log entry should be returned. Defaults to the caller's own organization when omitted. A caller that belongs to no organization, or that names one it is not a member of, is refused with a 403.", "type": "string", - "minLength": 1 + "minLength": 1, + "description": "File identifier within the Project." } } ], "responses": { "200": { - "description": "The requested audit-log entry.", + "description": "Current nullable file-share state.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -2381,7 +2350,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2AuditLogResponse" + "$ref": "#/components/schemas/V2GetProjectFileShareResponse" } } } @@ -2408,30 +2377,52 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - } - }, - "/api/v2/files/move": { - "post": { - "operationId": "moveFileItems", - "summary": "Move Files", - "description": "Move up to 1,000 files to a folder path or the workspace root.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.move", + }, + "patch": { + "operationId": "updateProjectFileShare", + "summary": "Update Project File Share", + "description": "Create or update a Project file's public share. isActive is required; omitted settings retain their current values except credentials unused by the selected access mode, which are cleared. Disabling retains the token and access configuration. Publication requires Project edit access and the current sharing policy across accessible active environments. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "project_files.share.update", "x-oauth-scope": "api:write", "tags": ["Files"], + "parameters": [ + { + "name": "projectId", + "in": "path", + "required": true, + "description": "Project identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Project identifier." + } + }, + { + "name": "fileId", + "in": "path", + "required": true, + "description": "File identifier within the Project.", + "schema": { + "type": "string", + "minLength": 1, + "description": "File identifier within the Project." + } + } + ], "requestBody": { "required": true, - "description": "Files and destination selected for a bulk move.", + "description": "Desired public-share state and access policy. Share tokens are generated by the server.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MoveFileItemsRequest" + "$ref": "#/components/schemas/UpdateProjectFileShareRequest" } } } }, "responses": { "200": { - "description": "Count of moved files.", + "description": "The updated Project file share.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -2446,7 +2437,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2MoveFileItemsResponse" + "$ref": "#/components/schemas/V2UpdateProjectFileShareResponse" } } } @@ -2463,7 +2454,91 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "409": { + "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/projects/{projectId}/files/uploads": { + "post": { + "operationId": "createProjectFileUpload", + "summary": "Create Project File Upload", + "description": "Create a resumable Project file upload. The file is registered only after the signed transfer and completion succeed. The original API credential and upload-token are required on every control request. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "project_file_uploads.create", + "x-oauth-scope": "api:write", + "tags": ["Files"], + "parameters": [ + { + "name": "projectId", + "in": "path", + "required": true, + "description": "Project identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Project identifier." + } + } + ], + "requestBody": { + "required": true, + "description": "The parameters for this upload operation.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProjectUploadCreateRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Create Project File Upload 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/V2CreateProjectFileUploadResponse" + } + } + } + }, + "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": { @@ -2484,44 +2559,52 @@ } } }, - "/api/v2/files/{fileId}/share": { + "/api/v2/projects/{projectId}/files/uploads/{uploadId}": { "get": { - "operationId": "getFileShare", - "summary": "Get File Share", - "description": "Get a file's public-share configuration. An unshared file returns `data: null`; a disabled share returns its configuration with `isActive: false`.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "files.share.read", - "x-oauth-scope": "api:read", + "operationId": "getProjectFileUpload", + "summary": "Get Project File Upload", + "description": "Read the current state of a Project upload, including its file after completion. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "project_file_uploads.read", + "x-oauth-scope": "api:write", "tags": ["Files"], "parameters": [ { - "name": "fileId", + "name": "projectId", "in": "path", "required": true, - "description": "File identifier.", + "description": "Project identifier.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9_-]+$", - "description": "File identifier." + "description": "Project identifier." } }, { - "name": "workspaceId", - "in": "query", + "name": "uploadId", + "in": "path", "required": true, - "description": "Workspace that owns the file.", + "description": "Upload session identifier within the Project.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "description": "Workspace that owns the file." + "description": "Upload session identifier within the Project." + } + }, + { + "name": "upload-token", + "in": "header", + "required": true, + "description": "Signed upload control token returned when the upload session was created.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Signed upload control token returned when the upload session was created." } } ], "responses": { "200": { - "description": "Current nullable file-share state.", + "description": "Get Project File Upload result.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -2536,7 +2619,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2GetFileShareResponse" + "$ref": "#/components/schemas/V2ProjectFileUploadResponse" } } } @@ -2553,6 +2636,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" }, @@ -2564,42 +2656,51 @@ } } }, - "patch": { - "operationId": "upsertFileShare", - "summary": "Enable or Disable File Share", - "description": "Create or update a file's public share. `isActive` is required; other fields describe their behavior when access modes change. Enabling a protected mode on a previously unshared file requires its credential in the same request. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.share.update", + "delete": { + "operationId": "abortProjectFileUpload", + "summary": "Abort Project File Upload", + "description": "Abort a pending Project upload and schedule its unregistered bytes for cleanup. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "project_file_uploads.cancel", "x-oauth-scope": "api:write", "tags": ["Files"], "parameters": [ { - "name": "fileId", + "name": "projectId", "in": "path", "required": true, - "description": "File identifier.", + "description": "Project identifier.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9_-]+$", - "description": "File identifier." + "description": "Project identifier." } - } - ], - "requestBody": { - "required": true, - "description": "Desired public-share state and access policy.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/UpsertFileShareRequest" - } + }, + { + "name": "uploadId", + "in": "path", + "required": true, + "description": "Upload session identifier within the Project.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Upload session identifier within the Project." + } + }, + { + "name": "upload-token", + "in": "header", + "required": true, + "description": "Signed upload control token returned when the upload session was created.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Signed upload control token returned when the upload session was created." } } - }, + ], "responses": { "200": { - "description": "The updated file share.", + "description": "Abort Project File Upload result.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -2614,7 +2715,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2UpsertFileShareResponse" + "$ref": "#/components/schemas/V2ProjectFileUploadResponse" } } } @@ -2631,6 +2732,9 @@ "404": { "$ref": "#/components/responses/NotFound" }, + "409": { + "$ref": "#/components/responses/Conflict" + }, "413": { "$ref": "#/components/responses/PayloadTooLarge" }, @@ -2649,43 +2753,52 @@ } } }, - "/api/v2/files/{fileId}/content": { - "patch": { - "operationId": "editFileContent", - "summary": "Edit File Content", - "description": "Edit part of a UTF-8 file; use Replace File Content to replace it entirely. Search-and-replace requires one exact match unless `replaceAll` is true. Anchored modes match trimmed complete lines; their input descriptions specify boundary handling. Non-UTF-8 files return `400`. Concurrent writes return `409`; re-read before retrying.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.update_content", + "/api/v2/projects/{projectId}/files/uploads/{uploadId}/complete": { + "post": { + "operationId": "completeProjectFileUpload", + "summary": "Complete Project File Upload", + "description": "Finalize verified bytes and atomically register one Project file. Retrying completion returns the same file without billing twice. Current edit access is checked again. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "project_file_uploads.complete", "x-oauth-scope": "api:write", "tags": ["Files"], "parameters": [ { - "name": "fileId", + "name": "projectId", "in": "path", "required": true, - "description": "File identifier.", + "description": "Project identifier.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9_-]+$", - "description": "File identifier." + "description": "Project identifier." } - } - ], - "requestBody": { - "required": true, - "description": "Workspace scope and the change to apply.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/EditFileContentRequest" - } + }, + { + "name": "uploadId", + "in": "path", + "required": true, + "description": "Upload session identifier within the Project.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Upload session identifier within the Project." + } + }, + { + "name": "upload-token", + "in": "header", + "required": true, + "description": "Signed upload control token returned when the upload session was created.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Signed upload control token returned when the upload session was created." } } - }, + ], "responses": { "200": { - "description": "The edited file and its new line count.", + "description": "Complete Project File Upload result.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -2700,7 +2813,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2EditedFileResponse" + "$ref": "#/components/schemas/V2ProjectFileUploadResponse" } } } @@ -2726,9 +2839,6 @@ "415": { "$ref": "#/components/responses/UnsupportedMediaType" }, - "423": { - "$ref": "#/components/responses/Locked" - }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -2739,43 +2849,65 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - }, - "put": { - "operationId": "updateFileContent", - "summary": "Replace File Content", - "description": "Replace the complete contents of an existing file from UTF-8 or base64 input. A stale `expectedRevision`, or a write that raced this one, returns `409`; re-read before retrying.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.update_content", + } + }, + "/api/v2/projects/{projectId}/files/uploads/{uploadId}/parts": { + "post": { + "operationId": "getProjectFileUploadPartUrls", + "summary": "Get Project File Upload Part URLs", + "description": "Request signed multipart transfer URLs for an active Project upload. Send exactly the returned transfer headers when uploading each part. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "project_file_uploads.parts", "x-oauth-scope": "api:write", "tags": ["Files"], "parameters": [ { - "name": "fileId", + "name": "projectId", "in": "path", "required": true, - "description": "File identifier.", + "description": "Project identifier.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9_-]+$", - "description": "File identifier." + "description": "Project identifier." + } + }, + { + "name": "uploadId", + "in": "path", + "required": true, + "description": "Upload session identifier within the Project.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Upload session identifier within the Project." + } + }, + { + "name": "upload-token", + "in": "header", + "required": true, + "description": "Signed upload control token returned when the upload session was created.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Signed upload control token returned when the upload session was created." } } ], "requestBody": { "required": true, - "description": "Workspace scope and complete replacement content.", + "description": "The parameters for this upload operation.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/UpdateFileContentRequest" + "$ref": "#/components/schemas/ProjectFileUploadPartUrlsRequest" } } } }, "responses": { "200": { - "description": "The updated file.", + "description": "Get Project File Upload Part URLs result.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -2790,7 +2922,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2WrittenFileResponse" + "$ref": "#/components/schemas/V2ProjectFileUploadPartUrlsResponse" } } } @@ -2828,102 +2960,135 @@ } } }, - "/api/v2/files/search": { + "/api/v2/files": { "get": { - "operationId": "searchFileContent", - "summary": "Search File Content", - "description": "Search indexed text in active workspace files and return matching lines with file IDs and line numbers. `folderPaths` limits both results and reported coverage. Missing matches are inconclusive if `complete` is false or `indexStatus.skippedFiles` or `indexStatus.partialFiles` is nonzero. `truncated` means additional matches exist beyond `maxResults`.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "files.search_content", - "x-oauth-scope": "api:read", - "tags": ["Files"], - "parameters": [ + "operationId": "listFiles", + "summary": "List Files", + "description": "List active workspace files with folder filtering, search, sorting, and cursor pagination. Use `scope=archived` to find files available for restoration. Workspace folder trees exceeding 10,000 folders return `413`.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "files.list", + "x-oauth-scope": "api:read", + "tags": ["Files"], + "parameters": [ { "name": "workspaceId", "in": "query", "required": true, - "description": "Workspace to search.", + "description": "Workspace whose files should be listed.", "schema": { "type": "string", "minLength": 1, "maxLength": 128, - "description": "Workspace to search." + "description": "Workspace whose files should be listed." } }, { - "name": "query", + "name": "folderPath", "in": "query", - "required": true, - "description": "Regular expression, or exact text when `mode` is `exact`.", + "required": false, + "description": "Restrict files to this folder, including subfolders when `recursive` is true. Unknown folder paths contribute no matches.", + "schema": { + "description": "Restrict files to this folder, including subfolders when `recursive` is true. Unknown folder paths contribute no matches.", + "$ref": "#/components/schemas/FolderPathInput" + } + }, + { + "name": "recursive", + "in": "query", + "required": false, + "description": "Include subfolders in the folder filter. Defaults to true when searching and false otherwise. Ignored without a folder filter.", + "schema": { + "description": "Include subfolders in the folder filter. Defaults to true when searching and false otherwise. Ignored without a folder filter.", + "enum": [ + "true", + "1", + "yes", + "on", + "y", + "enabled", + "false", + "0", + "no", + "off", + "n", + "disabled" + ], + "type": "string" + } + }, + { + "name": "scope", + "in": "query", + "required": false, + "description": "Which lifecycle set to list: `active` (default) for live files, `archived` for files a delete soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.", "schema": { + "default": "active", + "description": "Which lifecycle set to list: `active` (default) for live files, `archived` for files a delete soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.", "type": "string", - "description": "Regular expression, or exact text when `mode` is `exact`.", - "minLength": 3, - "maxLength": 512 + "enum": ["active", "archived"] } }, { - "name": "mode", + "name": "search", "in": "query", "required": false, - "description": "How `query` is read.", + "description": "Case-insensitive substring match against the file name.", "schema": { - "default": "regex", - "description": "How `query` is read.", + "description": "Case-insensitive substring match against the file name.", "type": "string", - "enum": ["exact", "regex"] + "minLength": 1, + "maxLength": 200 } }, { - "name": "maxResults", + "name": "sortBy", "in": "query", "required": false, - "description": "Maximum matching lines to return.", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", "schema": { - "default": 50, - "description": "Maximum matching lines to return.", - "type": "integer", - "minimum": 1, - "maximum": 200 + "default": "uploadedAt", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", + "type": "string", + "enum": ["name", "size", "uploadedAt", "updatedAt"] } }, { - "name": "folderPaths", + "name": "sortOrder", "in": "query", "required": false, - "description": "Folders the search is confined to, comma-separated. Absent searches the whole workspace. The scope also narrows `indexStatus`, so `complete` describes the folders searched rather than the workspace.", + "description": "Sort direction.", "schema": { - "description": "Folders the search is confined to, comma-separated. Absent searches the whole workspace. The scope also narrows `indexStatus`, so `complete` describes the folders searched rather than the workspace.", - "type": "string" + "default": "asc", + "description": "Sort direction.", + "type": "string", + "enum": ["asc", "desc"] } }, { - "name": "includeSubfolders", + "name": "limit", "in": "query", "required": false, - "description": "Whether the scope descends into nested folders. Absent means yes. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", + "description": "Maximum files per page. Values outside 1–1000 are truncated and clamped into that range rather than rejected. Defaults to 100.", "schema": { - "description": "Whether the scope descends into nested folders. Absent means yes. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", - "enum": [ - "true", - "1", - "yes", - "on", - "y", - "enabled", - "false", - "0", - "no", - "off", - "n", - "disabled" - ], - "type": "string" + "description": "Maximum files per page. Values outside 1–1000 are truncated and clamped into that range rather than rejected. Defaults to 100.", + "type": "integer", + "default": 100 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "schema": { + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "type": "string", + "minLength": 1 } } ], "responses": { "200": { - "description": "Matching lines and the index coverage they were drawn from.", + "description": "A page of workspace files.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -2938,7 +3103,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/V2FileSearchResultsResponse" + "$ref": "#/components/schemas/V2FileListResponse" } } } @@ -2955,8 +3120,80 @@ "404": { "$ref": "#/components/responses/NotFound" }, - "423": { - "$ref": "#/components/responses/Locked" + "413": { + "$ref": "#/components/responses/PayloadTooLarge" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + }, + "post": { + "operationId": "createFile", + "summary": "Create File", + "description": "Create a workspace file from inline UTF-8 or base64 content. Use an upload session for streamed or larger files.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.create", + "x-oauth-scope": "api:write", + "tags": ["Files"], + "requestBody": { + "required": true, + "description": "Inline content and placement for a new workspace file.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateFileRequest" + } + } + } + }, + "responses": { + "201": { + "description": "The created 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/V2CreatedFileResponse" + } + } + } + }, + "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" @@ -2970,28 +3207,28 @@ } } }, - "/api/v2/files/bulk-delete": { + "/api/v2/files/uploads": { "post": { - "operationId": "bulkDeleteFiles", - "summary": "Delete Files", - "description": "Archive up to 1,000 workspace files while retaining their stored bytes. Use Restore File to recover each file.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.delete", + "operationId": "createFileUpload", + "summary": "Create File Upload", + "description": "Create a resumable upload session and receive either a signed PUT URL or multipart instructions.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.upload.create", "x-oauth-scope": "api:write", "tags": ["Files"], "requestBody": { "required": true, - "description": "Workspace and files selected for deletion.", + "description": "File metadata required to create an upload session.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BulkDeleteFilesRequest" + "$ref": "#/components/schemas/CreateFileUploadRequest" } } } }, "responses": { - "200": { - "description": "Count of deleted files.", + "201": { + "description": "The created upload session and transfer instructions.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -3006,7 +3243,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BulkDeleteFilesResponse" + "$ref": "#/components/schemas/CreateFileUploadResponse" } } } @@ -3041,125 +3278,53 @@ } } }, - "/api/v2/files/folders": { + "/api/v2/files/uploads/{uploadId}": { "get": { - "operationId": "listFilesFolders", - "summary": "List Folders", - "description": "List workspace file folders with parent-path filtering and sorting. Use `scope=archived` to find paths accepted by Restore Folder. Returns the complete set in one page; `nextCursor` is always null.\n\nOAuth scope: `api:read`.", - "x-sim-operation": "files.folders.list", + "operationId": "getFileUpload", + "summary": "Get File Upload", + "description": "Get an upload session's state to determine whether an interrupted transfer can resume. Requires the signed upload token and current workspace access.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "files.upload.read", "x-oauth-scope": "api:read", "tags": ["Files"], "parameters": [ { - "name": "workspaceId", - "in": "query", + "name": "uploadId", + "in": "path", "required": true, - "description": "Workspace whose folders should be listed.", + "description": "Upload session identifier.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "description": "Workspace whose folders should be listed." + "description": "Upload session identifier." } }, { - "name": "parentPath", + "name": "workspaceId", "in": "query", - "required": false, - "description": "Restrict results to direct children of this parent path. Unknown folder paths contribute no matches.", + "required": true, + "description": "Workspace that owns the upload session.", "schema": { - "description": "Restrict results to direct children of this parent path. Unknown folder paths contribute no matches.", - "$ref": "#/components/schemas/FolderPathInput" + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the upload session." } }, { - "name": "search", - "in": "query", - "required": false, - "description": "Case-insensitive substring match against the folder name.", + "name": "upload-token", + "in": "header", + "required": true, + "description": "Signed upload control token returned when the upload session was created.", "schema": { - "description": "Case-insensitive substring match against the folder name.", "type": "string", "minLength": 1, - "maxLength": 200 - } - }, - { - "name": "sortBy", - "in": "query", - "required": false, - "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", - "schema": { - "default": "name", - "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", - "type": "string", - "enum": ["name", "createdAt", "updatedAt"] - } - }, - { - "name": "sortOrder", - "in": "query", - "required": false, - "description": "Sort direction.", - "schema": { - "default": "asc", - "description": "Sort direction.", - "type": "string", - "enum": ["asc", "desc"] - } - }, - { - "name": "scope", - "in": "query", - "required": false, - "description": "Which lifecycle set to list: `active` (default) returns live folders only; `archived` returns folders a recursive delete soft-deleted, which is how a caller finds a path to hand to the folder restore. Authorization is identical for both.", - "schema": { - "default": "active", - "description": "Which lifecycle set to list: `active` (default) returns live folders only; `archived` returns folders a recursive delete soft-deleted, which is how a caller finds a path to hand to the folder restore. Authorization is identical for both.", - "type": "string", - "enum": ["active", "archived"] - } - }, - { - "name": "recursive", - "in": "query", - "required": false, - "description": "Whether parentPath includes every descendant instead of direct children only.", - "schema": { - "description": "Whether parentPath includes every descendant instead of direct children only.", - "enum": [ - "true", - "1", - "yes", - "on", - "y", - "enabled", - "false", - "0", - "no", - "off", - "n", - "disabled" - ], - "type": "string" - } - }, - { - "name": "depth", - "in": "query", - "required": false, - "description": "Deepest level below parentPath to include when recursive is true.", - "schema": { - "description": "Deepest level below parentPath to include when recursive is true.", - "type": "integer", - "minimum": 1, - "maximum": 64 + "description": "Signed upload control token returned when the upload session was created." } } ], "responses": { "200": { - "description": "Workspace file folders.", + "description": "Current upload-session state.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -3174,7 +3339,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FileFolderListResponse" + "$ref": "#/components/schemas/FileUploadResponse" } } } @@ -3202,27 +3367,52 @@ } } }, - "post": { - "operationId": "createFilesFolder", - "summary": "Create Folder", - "description": "Create a folder at the supplied workspace path.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.folders.create", + "delete": { + "operationId": "abortFileUpload", + "summary": "Abort File Upload", + "description": "Abort an incomplete upload session and discard its uploaded data. Completed uploads cannot be aborted.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.upload.cancel", "x-oauth-scope": "api:write", "tags": ["Files"], - "requestBody": { - "required": true, - "description": "Workspace and canonical path for a new folder.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CreateFileFolderRequest" - } + "parameters": [ + { + "name": "uploadId", + "in": "path", + "required": true, + "description": "Upload session identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Upload session identifier." + } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace that owns the upload session.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the upload session." + } + }, + { + "name": "upload-token", + "in": "header", + "required": true, + "description": "Signed upload control token returned when the upload session was created.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Signed upload control token returned when the upload session was created." } } - }, + ], "responses": { - "201": { - "description": "The created folder.", + "200": { + "description": "The aborted upload session.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -3237,7 +3427,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FileFolderResponse" + "$ref": "#/components/schemas/FileUploadResponse" } } } @@ -3257,12 +3447,6 @@ "409": { "$ref": "#/components/responses/Conflict" }, - "413": { - "$ref": "#/components/responses/PayloadTooLarge" - }, - "415": { - "$ref": "#/components/responses/UnsupportedMediaType" - }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -3273,28 +3457,66 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - }, - "patch": { - "operationId": "relocateFilesFolder", - "summary": "Rename or Move Folder", - "description": "Rename or move a folder and atomically update all descendant paths.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.folders.update", + } + }, + "/api/v2/files/uploads/{uploadId}/parts": { + "post": { + "operationId": "createFileUploadPartUrls", + "summary": "Create File Upload Part URLs", + "description": "Create signed URLs for a bounded set of multipart upload part numbers.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.upload.parts", "x-oauth-scope": "api:write", "tags": ["Files"], + "parameters": [ + { + "name": "uploadId", + "in": "path", + "required": true, + "description": "Upload session identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Upload session identifier." + } + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace that owns the upload session.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the upload session." + } + }, + { + "name": "upload-token", + "in": "header", + "required": true, + "description": "Signed upload control token returned when the upload session was created.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Signed upload control token returned when the upload session was created." + } + } + ], "requestBody": { "required": true, - "description": "Current and destination canonical paths for a folder.", + "description": "Multipart part numbers requiring signed URLs.", "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/RelocateFileFolderRequest" + "$ref": "#/components/schemas/CreateFileUploadPartUrlsRequest" } } } }, "responses": { "200": { - "description": "The relocated folder.", + "description": "Signed URLs for the requested upload parts.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -3309,7 +3531,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FileFolderResponse" + "$ref": "#/components/schemas/CreateFileUploadPartUrlsResponse" } } } @@ -3345,66 +3567,55 @@ "$ref": "#/components/responses/ServiceUnavailable" } } - }, - "delete": { - "operationId": "deleteFilesFolder", - "summary": "Delete Folder", - "description": "Archive an empty folder, or set `recursive=true` to archive its files and subfolders. Use Restore Folder to recover the archived contents.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.folders.delete", + } + }, + "/api/v2/files/uploads/{uploadId}/complete": { + "post": { + "operationId": "completeFileUpload", + "summary": "Complete File Upload", + "description": "Finalize an upload and register its workspace file. Repeating a completed upload returns the existing file.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.upload.complete", "x-oauth-scope": "api:write", "tags": ["Files"], "parameters": [ { - "name": "workspaceId", - "in": "query", + "name": "uploadId", + "in": "path", "required": true, - "description": "Workspace containing the folder.", + "description": "Upload session identifier.", "schema": { "type": "string", "minLength": 1, - "maxLength": 128, - "description": "Workspace containing the folder." + "description": "Upload session identifier." } }, { - "name": "path", + "name": "workspaceId", "in": "query", "required": true, - "description": "Path of the folder to delete.", + "description": "Workspace that owns the upload session.", "schema": { - "description": "Path of the folder to delete.", - "$ref": "#/components/schemas/NonRootFolderPathInput" + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the upload session." } }, { - "name": "recursive", - "in": "query", - "required": false, - "description": "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", + "name": "upload-token", + "in": "header", + "required": true, + "description": "Signed upload control token returned when the upload session was created.", "schema": { - "description": "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", - "enum": [ - "true", - "1", - "yes", - "on", - "y", - "enabled", - "false", - "0", - "no", - "off", - "n", - "disabled" - ], - "default": "false", - "type": "string" + "type": "string", + "minLength": 1, + "description": "Signed upload control token returned when the upload session was created." } } ], "responses": { "200": { - "description": "Folder deletion confirmation and deleted item counts.", + "description": "The completed or finalizing upload session.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -3419,7 +3630,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/DeleteFileFolderResponse" + "$ref": "#/components/schemas/FileUploadResponse" } } } @@ -3451,28 +3662,79 @@ } } }, - "/api/v2/files/folders/restore": { - "post": { - "operationId": "restoreFilesFolder", - "summary": "Restore Folder", - "description": "Restore a folder and the files and subfolders archived with it. Use the path from List Folders with `scope=archived`. A path that is not archived returns `404`.\n\nOAuth scope: `api:write`.", - "x-sim-operation": "files.folders.restore", - "x-oauth-scope": "api:write", + "/api/v2/files/{fileId}/text": { + "get": { + "operationId": "readFileText", + "summary": "Read File Text", + "description": "Extract text without changing the file. Accepts its ID or canonical path (`files//` or `uploads/` for an unlisted chat upload); the response echoes the read path. Use Unzip File to unpack archives or Download File for original bytes. Unsupported types return `400`, compiling documents return `409`, and oversized files return `413`. `degraded: true` indicates incomplete or synthesized text, such as the legacy `.pptx` fallback; `truncated: true` indicates a parser limit.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "files.read_content", + "x-oauth-scope": "api:read", "tags": ["Files"], - "requestBody": { - "required": true, - "description": "Workspace scope and archived folder path.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RestoreFileFolderRequest" - } + "parameters": [ + { + "name": "fileId", + "in": "path", + "required": true, + "description": "File identifier, or the file’s VFS path: `files//`, or `uploads/` for a Chat upload.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 4096, + "description": "File identifier, or the file’s VFS path: `files//`, or `uploads/` for a Chat upload." + } + }, + { + "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": "maxBytes", + "in": "query", + "required": false, + "description": "Optional ceiling on the source bytes fed to the parser, lowering but never raising the server limit.", + "schema": { + "description": "Optional ceiling on the source bytes fed to the parser, lowering but never raising the server limit.", + "type": "integer", + "minimum": 1, + "maximum": 26214400 + } + }, + { + "name": "offset", + "in": "query", + "required": false, + "description": "First line to return, 1-based; 0 also starts at the first line. Absent starts at the first line.", + "schema": { + "description": "First line to return, 1-based; 0 also starts at the first line. Absent starts at the first line.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "How many lines to return from `offset`. Absent reads to the end.", + "schema": { + "description": "How many lines to return from `offset`. Absent reads to the end.", + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 } } - }, + ], "responses": { "200": { - "description": "The restored folder and what it brought back.", + "description": "The extracted text and its extraction-quality flags.", "headers": { "X-RateLimit-Limit": { "$ref": "#/components/headers/X-RateLimit-Limit" @@ -3487,7 +3749,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/FileFolderRestoreResponse" + "$ref": "#/components/schemas/FileTextResponse" } } } @@ -3510,9 +3772,6 @@ "413": { "$ref": "#/components/responses/PayloadTooLarge" }, - "415": { - "$ref": "#/components/responses/UnsupportedMediaType" - }, "429": { "$ref": "#/components/responses/RateLimited" }, @@ -3524,391 +3783,5356 @@ } } } - } - }, - "components": { - "securitySchemes": { - "apiKey": { - "type": "apiKey", - "in": "header", - "name": "X-API-Key", - "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description." - }, - "oauthBearer": { - "type": "http", - "scheme": "bearer", - "bearerFormat": "OAuth 2.0 access token", - "description": "A Sim OAuth access token obtained by a registered client through the authorization-code flow. Each operation declares its required scope: api:read permits reads and searches; api:write also permits changes and execution and implies api:read. Scope requirements follow the application operation, independent of HTTP method or workspace role." - } - }, - "headers": { - "Content-Type": { - "description": "MIME type of the file, defaulting to application/octet-stream when the stored type is unavailable.", - "schema": { - "type": "string", - "title": "Content type", - "description": "MIME type of the file, defaulting to application/octet-stream when the stored type is unavailable." - } - }, - "Content-Disposition": { - "description": "Attachment disposition containing sanitized and RFC 5987 encoded filenames.", - "schema": { - "type": "string", - "title": "Content disposition", - "description": "Attachment disposition containing sanitized and RFC 5987 encoded filenames." - } - }, - "Content-Length": { - "description": "File size in bytes.", - "schema": { - "type": "string", - "pattern": "^(0|[1-9]\\d*)$", - "title": "Content length", - "description": "File size in bytes." - } - }, - "X-RateLimit-Limit": { - "description": "Maximum requests allowed in the current window.", - "schema": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "title": "Rate limit", - "description": "Maximum requests allowed in the current window." - } - }, - "X-RateLimit-Remaining": { - "description": "Requests remaining in the current window.", - "schema": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "title": "Rate limit remaining", - "description": "Requests remaining in the current window." - } - }, - "X-RateLimit-Reset": { - "description": "ISO 8601 timestamp when the current rate-limit window resets.", - "schema": { - "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))$", - "title": "Rate limit reset", - "description": "ISO 8601 timestamp when the current rate-limit window resets." - } - }, - "Retry-After": { - "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset.", - "schema": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "title": "Retry after", - "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset." - } - }, - "X-Run-Id": { - "description": "Identifier assigned to the workflow run.", - "schema": { - "type": "string", - "minLength": 1, - "title": "Run identifier", - "description": "Identifier assigned to the workflow run." - } - } }, - "responses": { - "BadRequest": { - "description": "The request is invalid. This includes a query parameter sent with no value (`?limit=`, `?search=`), which is rejected rather than read as zero, empty, or the parameter default — omit the parameter instead.", - "content": { - "application/json": { + "/api/v2/files/{fileId}/versions": { + "get": { + "operationId": "listFileVersions", + "summary": "List File Versions", + "description": "List the versions of a file, newest first by default. Each write that changes the bytes records one; identical rewrites do not. Collaborative edits, and repeated workflow writes by one author, fold into a version under ten minutes old and written in the last five. Renames and moves are not versions. Retention removes older versions by age and plan but keeps the newest ten, so numbers can have gaps.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "files.versions.list", + "x-oauth-scope": "api:read", + "tags": ["Files"], + "parameters": [ + { + "name": "fileId", + "in": "path", + "required": true, + "description": "File identifier.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "BAD_REQUEST", - "message": "Invalid request" - } + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9_-]+$", + "description": "File identifier." } - } - } - }, - "Unauthorized": { - "description": "The API credential is missing or invalid.", - "content": { - "application/json": { + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace that owns the file.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "UNAUTHORIZED", - "message": "Authentication required" - } + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the file." } - } - } - }, - "Forbidden": { - "description": "The caller lacks the rights this operation requires. When the cause is one a caller can act on, `error.details.code` names it. A resource in a workspace the caller cannot reach at all answers `404` instead, so absence and denial are indistinguishable.", - "content": { - "application/json": { + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field used to sort the result.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "FORBIDDEN", - "message": "Insufficient workspace permissions", - "details": { - "code": "INSUFFICIENT_WORKSPACE_ROLE" - } - } + "default": "version", + "description": "Field used to sort the result.", + "type": "string", + "enum": ["version"] } - } - } - }, - "NotFound": { - "description": "The requested resource was not found.", - "content": { - "application/json": { + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "NOT_FOUND", - "message": "Not found" - } + "default": "desc", + "description": "Sort direction.", + "type": "string", + "enum": ["asc", "desc"] } - } - } - }, - "Conflict": { - "description": "The request conflicts with current resource state.", - "content": { - "application/json": { + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum versions to return per page. Must be a whole number from 1 to 100. Defaults to 50.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "CONFLICT", - "message": "The request conflicts with the current resource state" - } + "default": 50, + "description": "Maximum versions to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "type": "integer", + "minimum": 1, + "maximum": 100 } - } - } - }, - "PayloadTooLarge": { - "description": "The request, or a resource collection it must materialize, exceeds the allowed size: an oversized request body, a generated artifact past the download ceiling, or a workspace folder tree too large to load in full.", - "content": { - "application/json": { + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "PAYLOAD_TOO_LARGE", - "message": "Request body is too large" - } + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "type": "string", + "minLength": 1 } } - } - }, - "UnsupportedMediaType": { - "description": "The request uses an unsupported media type.", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" + ], + "responses": { + "200": { + "description": "A page of file versions.", + "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" + } }, - "example": { - "error": { - "code": "UNSUPPORTED_MEDIA_TYPE", - "message": "Request body must be sent as application/json" + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2FileVersionListResponse" + } } } + }, + "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" } } - }, - "Locked": { - "description": "The resource is temporarily locked or unavailable; retry the request.", - "content": { - "application/json": { + } + }, + "/api/v2/files/{fileId}/versions/{version}": { + "get": { + "operationId": "getFileVersion", + "summary": "Get File Version", + "description": "Get one version of a file. A version removed by retention, or one that never existed, returns `404`.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "files.versions.read", + "x-oauth-scope": "api:read", + "tags": ["Files"], + "parameters": [ + { + "name": "fileId", + "in": "path", + "required": true, + "description": "File identifier.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "LOCKED", - "message": "The file or its search index is temporarily locked; retry the request" - } + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9_-]+$", + "description": "File identifier." } - } - } - }, - "RateLimited": { - "description": "The caller exceeded the request rate limit.", - "headers": { - "Retry-After": { - "$ref": "#/components/headers/Retry-After" - } - }, - "content": { - "application/json": { + }, + { + "name": "version", + "in": "path", + "required": true, + "description": "Version number.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "RATE_LIMITED", - "message": "API rate limit exceeded", - "details": { - "retryAfter": "2026-01-01T00:00:30.000Z" - } - } + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 2147483647, + "description": "Version number." } - } - } - }, - "InternalError": { - "description": "An unexpected server error occurred.", - "content": { - "application/json": { + }, + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace that owns the file.", "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "INTERNAL_ERROR", - "message": "Internal server error" - } + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace that owns the file." } } - } - }, - "ServiceUnavailable": { - "description": "A required service is temporarily unavailable. `Retry-After` carries the seconds to wait; treat it as a floor and add jitter. The header is omitted when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, because the run may already have started — reconcile against the returned run id instead of retrying.", - "headers": { - "Retry-After": { - "$ref": "#/components/headers/Retry-After" - } - }, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/V2Error" - }, - "example": { - "error": { - "code": "SERVICE_UNAVAILABLE", + ], + "responses": { + "200": { + "description": "The file version.", + "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/V2FileVersionResponse" + } + } + } + }, + "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" + } + } + }, + "delete": { + "operationId": "deleteFileVersion", + "summary": "Delete File Version", + "description": "Permanently delete one earlier version and its stored content, for example to purge a leaked value from history before retention removes it. The current version returns `409`; revert to another version first. A version that does not exist returns `404`.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.versions.delete", + "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." + } + }, + { + "name": "version", + "in": "path", + "required": true, + "description": "Version number.", + "schema": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 2147483647, + "description": "Version number." + } + }, + { + "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": "Deletion confirmation.", + "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/V2FileVersionDeleteResponse" + } + } + } + }, + "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" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/files/{fileId}/versions/{version}/text": { + "get": { + "operationId": "readFileVersionText", + "summary": "Read File Version Text", + "description": "Extract the text of one version, exactly as Read File Text extracts the current content. Unsupported types return `400`, compiling documents return `409`, and oversized versions return `413`.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "files.versions.read_content", + "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": "version", + "in": "path", + "required": true, + "description": "Version number.", + "schema": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 2147483647, + "description": "Version number." + } + }, + { + "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": "maxBytes", + "in": "query", + "required": false, + "description": "Optional ceiling on the source bytes fed to the parser, lowering but never raising the server limit.", + "schema": { + "description": "Optional ceiling on the source bytes fed to the parser, lowering but never raising the server limit.", + "type": "integer", + "minimum": 1, + "maximum": 26214400 + } + }, + { + "name": "offset", + "in": "query", + "required": false, + "description": "First line to return, 1-based; 0 also starts at the first line. Absent starts at the first line.", + "schema": { + "description": "First line to return, 1-based; 0 also starts at the first line. Absent starts at the first line.", + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991 + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "How many lines to return from `offset`. Absent reads to the end.", + "schema": { + "description": "How many lines to return from `offset`. Absent reads to the end.", + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + } + } + ], + "responses": { + "200": { + "description": "The extracted text of the version and its extraction-quality flags.", + "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/FileVersionTextResponse" + } + } + } + }, + "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" + } + } + } + }, + "/api/v2/files/{fileId}/versions/{version}/content": { + "get": { + "operationId": "downloadFileVersion", + "summary": "Download File Version", + "description": "Download the bytes of one version, served exactly as Download File serves the current bytes. Generated documents use compiled artifacts, returning `409` while compiling and `413` above the rendered-size ceiling. Downloading records an audit event. `HEAD` checks access with the same authorization as `GET` but skips side effects, returning an empty `200` without payload headers on success. `HEAD` omits `Content-Length`; use file metadata to size downloads.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "files.versions.download", + "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": "version", + "in": "path", + "required": true, + "description": "Version number.", + "schema": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 2147483647, + "description": "Version number." + } + }, + { + "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": "The version bytes.", + "headers": { + "Content-Type": { + "$ref": "#/components/headers/Content-Type" + }, + "Content-Disposition": { + "$ref": "#/components/headers/Content-Disposition" + }, + "Content-Length": { + "$ref": "#/components/headers/Content-Length" + }, + "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/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "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" + } + } + } + }, + "/api/v2/files/{fileId}/versions/{version}/revert": { + "post": { + "operationId": "revertFileVersion", + "summary": "Revert File Version", + "description": "Make the content of a version current again by writing it as a new `revert` version, so the revert can itself be reverted. Open editors receive the change. Reverting to the current version writes nothing and returns `reverted: false`. A concurrent write, or an `expectedCurrentVersion` that is no longer current, returns `409`; a version above 100 MB returns `413`.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.versions.revert", + "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." + } + }, + { + "name": "version", + "in": "path", + "required": true, + "description": "Version number.", + "schema": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 2147483647, + "description": "Version number." + } + } + ], + "requestBody": { + "required": true, + "description": "Workspace scope and an optional current-version precondition.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RevertFileVersionRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The file and its current version after the revert.", + "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/V2FileVersionRevertResponse" + } + } + } + }, + "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/bulk-download": { + "get": { + "operationId": "bulkDownloadFiles", + "summary": "Bulk Download Files", + "description": "Stream selected files and recursive folder contents as a ZIP archive. Each selection parameter and the resolved set allow 100 entries; unmatched paths or excess entries return `400`. Total bytes are bounded. Downloads record an audit event. `HEAD` checks access with the same authorization as `GET` but skips side effects, returning an empty `200` without payload headers on success. `HEAD` omits `Content-Length`; use file metadata to size downloads.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "files.download", + "x-oauth-scope": "api:read", + "tags": ["Files"], + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace containing the selection.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace containing the selection." + } + }, + { + "name": "fileIds", + "in": "query", + "required": false, + "description": "File identifiers to include, comma-separated. At most 100 entries.", + "schema": { + "description": "File identifiers to include, comma-separated. At most 100 entries.", + "type": "string" + } + }, + { + "name": "folderPaths", + "in": "query", + "required": false, + "description": "Comma-separated folder paths whose contents are included recursively. Up to 100 paths; resolved files share the 100-file download limit. Unknown paths are rejected.", + "schema": { + "description": "Comma-separated folder paths whose contents are included recursively. Up to 100 paths; resolved files share the 100-file download limit. Unknown paths are rejected.", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "The selected files as a zip archive.", + "headers": { + "Content-Type": { + "$ref": "#/components/headers/Content-Type" + }, + "Content-Disposition": { + "$ref": "#/components/headers/Content-Disposition" + }, + "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/zip": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "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" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/files/{fileId}/unzip": { + "post": { + "operationId": "unzipFile", + "summary": "Unzip File", + "description": "Extract a ZIP archive into a new sibling folder and return counts and the destination path. Use List Files to inspect its contents. Large archives can take minutes; concurrent extraction of the same archive returns `409`. Size or processing-time limits return `413`.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.extract_archive", + "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 archive.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UnzipFileBody" + } + } + } + }, + "responses": { + "200": { + "description": "Counts and destination folder for the unpacked archive.", + "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/FileUnzipResponse" + } + } + } + }, + "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}": { + "get": { + "operationId": "downloadFile", + "summary": "Download File", + "description": "Download current file bytes. Generated documents use compiled artifacts, returning `409` while compiling and `413` above the rendered-size ceiling. Downloading records an audit event. `HEAD` checks access with the same authorization as `GET` but skips side effects, returning an empty `200` without payload headers on success. `HEAD` omits `Content-Length`; use file metadata to size downloads.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "files.download", + "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." + } + } + ], + "responses": { + "200": { + "description": "The file bytes.", + "headers": { + "Content-Type": { + "$ref": "#/components/headers/Content-Type" + }, + "Content-Disposition": { + "$ref": "#/components/headers/Content-Disposition" + }, + "Content-Length": { + "$ref": "#/components/headers/Content-Length" + }, + "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/octet-stream": { + "schema": { + "type": "string", + "format": "binary" + } + } + } + }, + "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" + } + } + }, + "delete": { + "operationId": "deleteFile", + "summary": "Delete File", + "description": "Archive a workspace file, retaining its stored bytes and removing API read access. List Files with `scope=archived` finds it; Restore File recovers it. Archiving an already archived file returns `404`.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.delete", + "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." + } + }, + { + "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": "Deletion confirmation.", + "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/V2DeleteFileResponse" + } + } + } + }, + "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" + } + } + }, + "patch": { + "operationId": "renameFile", + "summary": "Rename File", + "description": "Rename a workspace file without changing its containing folder.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.rename", + "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 and new file name.", + "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 a soft-deleted file to its original folder, or the workspace root if that folder was archived. Name collisions add a `_restored` suffix; read `folderPath` and `name` from the response. Already-active files return unchanged, making retries safe. 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" + } + } + } + }, + "responses": { + "200": { + "description": "The file as it exists after the restore.", + "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" + } + } + } + }, + "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}/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", + "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/audit-logs": { + "get": { + "operationId": "listAuditLogs", + "summary": "List Audit Logs", + "description": "List an organization audit trail with filters and opaque cursor pagination. Requires an Enterprise subscription and organization admin or owner access. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "audit_logs.list", + "x-oauth-scope": "api:read", + "tags": ["Audit Logs"], + "parameters": [ + { + "name": "action", + "in": "query", + "required": false, + "description": "Filter by exact action name.", + "schema": { + "description": "Filter by exact action name.", + "type": "string" + } + }, + { + "name": "resourceType", + "in": "query", + "required": false, + "description": "Filter by resource type. Accepts a comma-separated set; members are trimmed and deduplicated, and member order affects neither the result nor the cursor.", + "schema": { + "description": "Filter by resource type. Accepts a comma-separated set; members are trimmed and deduplicated, and member order affects neither the result nor the cursor.", + "type": "string" + } + }, + { + "name": "resourceId", + "in": "query", + "required": false, + "description": "Filter by exact resource identifier.", + "schema": { + "description": "Filter by exact resource identifier.", + "type": "string" + } + }, + { + "name": "workspaceId", + "in": "query", + "required": false, + "description": "Filter to actions in one workspace.", + "schema": { + "description": "Filter to actions in one workspace.", + "type": "string", + "minLength": 1, + "maxLength": 128 + } + }, + { + "name": "startDate", + "in": "query", + "required": false, + "description": "Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant.", + "schema": { + "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))$", + "description": "Only include runs started at or after this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant." + } + }, + { + "name": "endDate", + "in": "query", + "required": false, + "description": "Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant.", + "schema": { + "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))$", + "description": "Only include runs started at or before this UTC ISO 8601 timestamp, e.g. `2026-08-06T00:00:00Z`. A date without a time, or a timestamp carrying a UTC offset instead of `Z`, is rejected, as is year `0000`, which names no storable instant." + } + }, + { + "name": "includeDeparted", + "in": "query", + "required": false, + "description": "Include actions by users who have left the organization.", + "schema": { + "description": "Include actions by users who have left the organization.", + "type": "boolean" + } + }, + { + "name": "limit", + "in": "query", + "required": false, + "description": "Maximum audit entries to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "schema": { + "default": 50, + "description": "Maximum audit entries to return per page. Must be a whole number from 1 to 100. Defaults to 50.", + "type": "integer", + "minimum": 1, + "maximum": 100 + } + }, + { + "name": "cursor", + "in": "query", + "required": false, + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "schema": { + "description": "Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.", + "type": "string", + "minLength": 1 + } + }, + { + "name": "organizationId", + "in": "query", + "required": false, + "description": "Organization whose audit trail should be queried. Defaults to the caller's own organization when omitted. A caller that belongs to no organization, or that names one it is not a member of, is refused with a 403.", + "schema": { + "description": "Organization whose audit trail should be queried. Defaults to the caller's own organization when omitted. A caller that belongs to no organization, or that names one it is not a member of, is refused with a 403.", + "type": "string", + "minLength": 1 + } + }, + { + "name": "actorEmail", + "in": "query", + "required": false, + "description": "Filter by actor email address.", + "schema": { + "description": "Filter by actor email address.", + "type": "string", + "format": "email", + "pattern": "^(?!\\.)(?!.*\\.\\.)([A-Za-z0-9_'+\\-\\.]*)[A-Za-z0-9_+-]@([A-Za-z0-9][A-Za-z0-9\\-]*\\.)+[A-Za-z]{2,}$" + } + } + ], + "responses": { + "200": { + "description": "A page of audit-log entries.", + "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/V2AuditLogListResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/audit-logs/{auditLogId}": { + "get": { + "operationId": "getAuditLog", + "summary": "Get Audit Log", + "description": "Get one organization audit-log entry. Requires an Enterprise subscription and organization admin or owner access. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "audit_logs.read_detail", + "x-oauth-scope": "api:read", + "tags": ["Audit Logs"], + "parameters": [ + { + "name": "auditLogId", + "in": "path", + "required": true, + "description": "Audit-log entry identifier.", + "schema": { + "type": "string", + "minLength": 1, + "description": "Audit-log entry identifier." + } + }, + { + "name": "organizationId", + "in": "query", + "required": false, + "description": "Organization whose audit-log entry should be returned. Defaults to the caller's own organization when omitted. A caller that belongs to no organization, or that names one it is not a member of, is refused with a 403.", + "schema": { + "description": "Organization whose audit-log entry should be returned. Defaults to the caller's own organization when omitted. A caller that belongs to no organization, or that names one it is not a member of, is refused with a 403.", + "type": "string", + "minLength": 1 + } + } + ], + "responses": { + "200": { + "description": "The requested audit-log entry.", + "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/V2AuditLogResponse" + } + } + } + }, + "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/move": { + "post": { + "operationId": "moveFileItems", + "summary": "Move Files", + "description": "Move up to 1,000 files to a folder path or the workspace root.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.move", + "x-oauth-scope": "api:write", + "tags": ["Files"], + "requestBody": { + "required": true, + "description": "Files and destination selected for a bulk move.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/MoveFileItemsRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Count of moved files.", + "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" + } + } + } + }, + "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}/share": { + "get": { + "operationId": "getFileShare", + "summary": "Get File Share", + "description": "Get a file's public-share configuration. An unshared file returns `data: null`; a disabled share returns its configuration with `isActive: false`.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "files.share.read", + "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." + } + } + ], + "responses": { + "200": { + "description": "Current nullable file-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" + } + } + } + }, + "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" + } + } + }, + "patch": { + "operationId": "upsertFileShare", + "summary": "Enable or Disable File Share", + "description": "Create or update a file's public share. `isActive` is required; other fields describe their behavior when access modes change. Enabling a protected mode on a previously unshared file requires its credential in the same request. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.share.update", + "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": "Desired public-share state and access policy.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpsertFileShareRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The updated file share.", + "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" + } + } + } + }, + "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" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/files/{fileId}/content": { + "patch": { + "operationId": "editFileContent", + "summary": "Edit File Content", + "description": "Edit part of a UTF-8 file; use Replace File Content to replace it entirely. Search-and-replace requires one exact match unless `replaceAll` is true. Anchored modes match trimmed complete lines; their input descriptions specify boundary handling. Non-UTF-8 files return `400`. Concurrent writes return `409`; re-read before retrying.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.update_content", + "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 and the change to apply.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/EditFileContentRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The edited file and its new line count.", + "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/V2EditedFileResponse" + } + } + } + }, + "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" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + }, + "put": { + "operationId": "updateFileContent", + "summary": "Replace File Content", + "description": "Replace the complete contents of an existing file from UTF-8 or base64 input. A stale `expectedRevision`, or a write that raced this one, returns `409`; re-read before retrying.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.update_content", + "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 and complete replacement content.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateFileContentRequest" + } + } + } + }, + "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/V2WrittenFileResponse" + } + } + } + }, + "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/search": { + "get": { + "operationId": "searchFileContent", + "summary": "Search File Content", + "description": "Search indexed text in active workspace files and return matching lines with file IDs and line numbers. `folderPaths` limits both results and reported coverage. Missing matches are inconclusive if `complete` is false or `indexStatus.skippedFiles` or `indexStatus.partialFiles` is nonzero. `truncated` means additional matches exist beyond `maxResults`.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "files.search_content", + "x-oauth-scope": "api:read", + "tags": ["Files"], + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace to search.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace to search." + } + }, + { + "name": "query", + "in": "query", + "required": true, + "description": "Regular expression, or exact text when `mode` is `exact`.", + "schema": { + "type": "string", + "description": "Regular expression, or exact text when `mode` is `exact`.", + "minLength": 3, + "maxLength": 512 + } + }, + { + "name": "mode", + "in": "query", + "required": false, + "description": "How `query` is read.", + "schema": { + "default": "regex", + "description": "How `query` is read.", + "type": "string", + "enum": ["exact", "regex"] + } + }, + { + "name": "maxResults", + "in": "query", + "required": false, + "description": "Maximum matching lines to return.", + "schema": { + "default": 50, + "description": "Maximum matching lines to return.", + "type": "integer", + "minimum": 1, + "maximum": 200 + } + }, + { + "name": "folderPaths", + "in": "query", + "required": false, + "description": "Folders the search is confined to, comma-separated. Absent searches the whole workspace. The scope also narrows `indexStatus`, so `complete` describes the folders searched rather than the workspace.", + "schema": { + "description": "Folders the search is confined to, comma-separated. Absent searches the whole workspace. The scope also narrows `indexStatus`, so `complete` describes the folders searched rather than the workspace.", + "type": "string" + } + }, + { + "name": "includeSubfolders", + "in": "query", + "required": false, + "description": "Whether the scope descends into nested folders. Absent means yes. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", + "schema": { + "description": "Whether the scope descends into nested folders. Absent means yes. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", + "enum": [ + "true", + "1", + "yes", + "on", + "y", + "enabled", + "false", + "0", + "no", + "off", + "n", + "disabled" + ], + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Matching lines and the index coverage they were drawn from.", + "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/V2FileSearchResultsResponse" + } + } + } + }, + "400": { + "$ref": "#/components/responses/BadRequest" + }, + "401": { + "$ref": "#/components/responses/Unauthorized" + }, + "403": { + "$ref": "#/components/responses/Forbidden" + }, + "404": { + "$ref": "#/components/responses/NotFound" + }, + "423": { + "$ref": "#/components/responses/Locked" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/files/bulk-delete": { + "post": { + "operationId": "bulkDeleteFiles", + "summary": "Delete Files", + "description": "Archive up to 1,000 workspace files while retaining their stored bytes. Use Restore File to recover each file.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.delete", + "x-oauth-scope": "api:write", + "tags": ["Files"], + "requestBody": { + "required": true, + "description": "Workspace and files selected for deletion.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BulkDeleteFilesRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Count of deleted files.", + "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/BulkDeleteFilesResponse" + } + } + } + }, + "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" + }, + "415": { + "$ref": "#/components/responses/UnsupportedMediaType" + }, + "429": { + "$ref": "#/components/responses/RateLimited" + }, + "500": { + "$ref": "#/components/responses/InternalError" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/files/folders": { + "get": { + "operationId": "listFilesFolders", + "summary": "List Folders", + "description": "List workspace file folders with parent-path filtering and sorting. Use `scope=archived` to find paths accepted by Restore Folder. Returns the complete set in one page; `nextCursor` is always null.\n\nOAuth scope: `api:read`.", + "x-sim-operation": "files.folders.list", + "x-oauth-scope": "api:read", + "tags": ["Files"], + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace whose folders should be listed.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace whose folders should be listed." + } + }, + { + "name": "parentPath", + "in": "query", + "required": false, + "description": "Restrict results to direct children of this parent path. Unknown folder paths contribute no matches.", + "schema": { + "description": "Restrict results to direct children of this parent path. Unknown folder paths contribute no matches.", + "$ref": "#/components/schemas/FolderPathInput" + } + }, + { + "name": "search", + "in": "query", + "required": false, + "description": "Case-insensitive substring match against the folder name.", + "schema": { + "description": "Case-insensitive substring match against the folder name.", + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + { + "name": "sortBy", + "in": "query", + "required": false, + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", + "schema": { + "default": "name", + "description": "Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.", + "type": "string", + "enum": ["name", "createdAt", "updatedAt"] + } + }, + { + "name": "sortOrder", + "in": "query", + "required": false, + "description": "Sort direction.", + "schema": { + "default": "asc", + "description": "Sort direction.", + "type": "string", + "enum": ["asc", "desc"] + } + }, + { + "name": "scope", + "in": "query", + "required": false, + "description": "Which lifecycle set to list: `active` (default) returns live folders only; `archived` returns folders a recursive delete soft-deleted, which is how a caller finds a path to hand to the folder restore. Authorization is identical for both.", + "schema": { + "default": "active", + "description": "Which lifecycle set to list: `active` (default) returns live folders only; `archived` returns folders a recursive delete soft-deleted, which is how a caller finds a path to hand to the folder restore. Authorization is identical for both.", + "type": "string", + "enum": ["active", "archived"] + } + }, + { + "name": "recursive", + "in": "query", + "required": false, + "description": "Whether parentPath includes every descendant instead of direct children only.", + "schema": { + "description": "Whether parentPath includes every descendant instead of direct children only.", + "enum": [ + "true", + "1", + "yes", + "on", + "y", + "enabled", + "false", + "0", + "no", + "off", + "n", + "disabled" + ], + "type": "string" + } + }, + { + "name": "depth", + "in": "query", + "required": false, + "description": "Deepest level below parentPath to include when recursive is true.", + "schema": { + "description": "Deepest level below parentPath to include when recursive is true.", + "type": "integer", + "minimum": 1, + "maximum": 64 + } + } + ], + "responses": { + "200": { + "description": "Workspace file folders.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileFolderListResponse" + } + } + } + }, + "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" + } + } + }, + "post": { + "operationId": "createFilesFolder", + "summary": "Create Folder", + "description": "Create a folder at the supplied workspace path.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.folders.create", + "x-oauth-scope": "api:write", + "tags": ["Files"], + "requestBody": { + "required": true, + "description": "Workspace and canonical path for a new folder.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateFileFolderRequest" + } + } + } + }, + "responses": { + "201": { + "description": "The created folder.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileFolderResponse" + } + } + } + }, + "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" + } + } + }, + "patch": { + "operationId": "relocateFilesFolder", + "summary": "Rename or Move Folder", + "description": "Rename or move a folder and atomically update all descendant paths.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.folders.update", + "x-oauth-scope": "api:write", + "tags": ["Files"], + "requestBody": { + "required": true, + "description": "Current and destination canonical paths for a folder.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RelocateFileFolderRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The relocated folder.", + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileFolderResponse" + } + } + } + }, + "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" + } + } + }, + "delete": { + "operationId": "deleteFilesFolder", + "summary": "Delete Folder", + "description": "Archive an empty folder, or set `recursive=true` to archive its files and subfolders. Use Restore Folder to recover the archived contents.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.folders.delete", + "x-oauth-scope": "api:write", + "tags": ["Files"], + "parameters": [ + { + "name": "workspaceId", + "in": "query", + "required": true, + "description": "Workspace containing the folder.", + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "description": "Workspace containing the folder." + } + }, + { + "name": "path", + "in": "query", + "required": true, + "description": "Path of the folder to delete.", + "schema": { + "description": "Path of the folder to delete.", + "$ref": "#/components/schemas/NonRootFolderPathInput" + } + }, + { + "name": "recursive", + "in": "query", + "required": false, + "description": "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", + "schema": { + "description": "Delete the folder's nested files and folders too. An empty folder deletes either way; a non-empty one needs this. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.", + "enum": [ + "true", + "1", + "yes", + "on", + "y", + "enabled", + "false", + "0", + "no", + "off", + "n", + "disabled" + ], + "default": "false", + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Folder deletion confirmation and deleted item counts.", + "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/DeleteFileFolderResponse" + } + } + } + }, + "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" + }, + "503": { + "$ref": "#/components/responses/ServiceUnavailable" + } + } + } + }, + "/api/v2/files/folders/restore": { + "post": { + "operationId": "restoreFilesFolder", + "summary": "Restore Folder", + "description": "Restore a folder and the files and subfolders archived with it. Use the path from List Folders with `scope=archived`. A path that is not archived returns `404`.\n\nOAuth scope: `api:write`.", + "x-sim-operation": "files.folders.restore", + "x-oauth-scope": "api:write", + "tags": ["Files"], + "requestBody": { + "required": true, + "description": "Workspace scope and archived folder path.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RestoreFileFolderRequest" + } + } + } + }, + "responses": { + "200": { + "description": "The restored folder and what it brought back.", + "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/FileFolderRestoreResponse" + } + } + } + }, + "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" + } + } + } + } + }, + "components": { + "securitySchemes": { + "apiKey": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "Your Sim API key, personal or workspace-scoped. Generate one under Settings, then API Keys. Operations that reject workspace keys say so in their own description." + }, + "oauthBearer": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "OAuth 2.0 access token", + "description": "A Sim OAuth access token obtained by a registered client through the authorization-code flow. Each operation declares its required scope: api:read permits reads and searches; api:write also permits changes and execution and implies api:read. Scope requirements follow the application operation, independent of HTTP method or workspace role." + } + }, + "headers": { + "Content-Type": { + "description": "MIME type of the file, defaulting to application/octet-stream when the stored type is unavailable.", + "schema": { + "type": "string", + "title": "Content type", + "description": "MIME type of the file, defaulting to application/octet-stream when the stored type is unavailable." + } + }, + "Content-Disposition": { + "description": "Attachment disposition containing sanitized and RFC 5987 encoded filenames.", + "schema": { + "type": "string", + "title": "Content disposition", + "description": "Attachment disposition containing sanitized and RFC 5987 encoded filenames." + } + }, + "Content-Length": { + "description": "File size in bytes.", + "schema": { + "type": "string", + "pattern": "^(0|[1-9]\\d*)$", + "title": "Content length", + "description": "File size in bytes." + } + }, + "X-RateLimit-Limit": { + "description": "Maximum requests allowed in the current window.", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "title": "Rate limit", + "description": "Maximum requests allowed in the current window." + } + }, + "X-RateLimit-Remaining": { + "description": "Requests remaining in the current window.", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "title": "Rate limit remaining", + "description": "Requests remaining in the current window." + } + }, + "X-RateLimit-Reset": { + "description": "ISO 8601 timestamp when the current rate-limit window resets.", + "schema": { + "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))$", + "title": "Rate limit reset", + "description": "ISO 8601 timestamp when the current rate-limit window resets." + } + }, + "Retry-After": { + "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset.", + "schema": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "title": "Retry after", + "description": "Seconds to wait before retrying, sent on `429` and `503`. Add jitter rather than retrying at exactly this offset." + } + }, + "X-Run-Id": { + "description": "Identifier assigned to the workflow run.", + "schema": { + "type": "string", + "minLength": 1, + "title": "Run identifier", + "description": "Identifier assigned to the workflow run." + } + } + }, + "responses": { + "BadRequest": { + "description": "The request is invalid. This includes a query parameter sent with no value (`?limit=`, `?search=`), which is rejected rather than read as zero, empty, or the parameter default — omit the parameter instead.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "BAD_REQUEST", + "message": "Invalid request" + } + } + } + } + }, + "Unauthorized": { + "description": "The API credential is missing or invalid.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "UNAUTHORIZED", + "message": "Authentication required" + } + } + } + } + }, + "Forbidden": { + "description": "The caller lacks the rights this operation requires. When the cause is one a caller can act on, `error.details.code` names it. A resource in a workspace the caller cannot reach at all answers `404` instead, so absence and denial are indistinguishable.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "FORBIDDEN", + "message": "Insufficient workspace permissions", + "details": { + "code": "INSUFFICIENT_WORKSPACE_ROLE" + } + } + } + } + } + }, + "NotFound": { + "description": "The requested resource was not found.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "NOT_FOUND", + "message": "Not found" + } + } + } + } + }, + "Conflict": { + "description": "The request conflicts with current resource state.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "CONFLICT", + "message": "The request conflicts with the current resource state" + } + } + } + } + }, + "PayloadTooLarge": { + "description": "The request, or a resource collection it must materialize, exceeds the allowed size: an oversized request body, a generated artifact past the download ceiling, or a workspace folder tree too large to load in full.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "PAYLOAD_TOO_LARGE", + "message": "Request body is too large" + } + } + } + } + }, + "UnsupportedMediaType": { + "description": "The request uses an unsupported media type.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "UNSUPPORTED_MEDIA_TYPE", + "message": "Request body must be sent as application/json" + } + } + } + } + }, + "Locked": { + "description": "The resource is temporarily locked or unavailable; retry the request.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "LOCKED", + "message": "The file or its search index is temporarily locked; retry the request" + } + } + } + } + }, + "RateLimited": { + "description": "The caller exceeded the request rate limit.", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/Retry-After" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "RATE_LIMITED", + "message": "API rate limit exceeded", + "details": { + "retryAfter": "2026-01-01T00:00:30.000Z" + } + } + } + } + } + }, + "InternalError": { + "description": "An unexpected server error occurred.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "INTERNAL_ERROR", + "message": "Internal server error" + } + } + } + } + }, + "ServiceUnavailable": { + "description": "A required service is temporarily unavailable. `Retry-After` carries the seconds to wait; treat it as a floor and add jitter. The header is omitted when `error.details.code` is `ASYNC_ENQUEUE_AMBIGUOUS`, because the run may already have started — reconcile against the returned run id instead of retrying.", + "headers": { + "Retry-After": { + "$ref": "#/components/headers/Retry-After" + } + }, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/V2Error" + }, + "example": { + "error": { + "code": "SERVICE_UNAVAILABLE", "message": "Service temporarily unavailable" } - } + } + } + } + } + }, + "schemas": { + "V2ActionableForbiddenDetails": { + "type": "object", + "properties": { + "code": { + "$ref": "#/components/schemas/V2ForbiddenDetailCode" + } + }, + "required": ["code"], + "additionalProperties": { + "description": "Additional context for this refusal." + }, + "title": "Actionable forbidden details", + "description": "Machine-readable cause and optional context for an actionable `403` response." + }, + "V2ForbiddenDetailCode": { + "type": "string", + "enum": [ + "INSUFFICIENT_WORKSPACE_ROLE", + "PERSONAL_API_KEYS_DISABLED", + "WORKSPACE_KEY_OPERATION_NOT_PERMITTED", + "PRINCIPAL_KIND_NOT_PERMITTED", + "ORGANIZATION_MEMBERSHIP_REQUIRED", + "ORGANIZATION_ADMIN_REQUIRED", + "ENTERPRISE_PLAN_REQUIRED", + "SSO_DISABLED", + "SSO_DOMAIN_NOT_VERIFIED", + "SSO_PROVIDER_LIMIT_REACHED", + "ORGANIZATION_PLAN_REQUIRED", + "AUDIT_LOGS_DISABLED", + "ACCESS_REQUESTS_DISABLED", + "ACCESS_REQUEST_ORGANIZATION_REQUIRED", + "SKILL_EDITOR_ACCESS_REQUIRED", + "SECRET_ADMIN_ACCESS_REQUIRED", + "WORKSPACE_RESOURCE_LIMIT_REACHED", + "PUBLIC_SHARING_NOT_ALLOWED", + "CREDENTIAL_ADMIN_ACCESS_REQUIRED", + "MCP_SERVER_URL_NOT_ALLOWED", + "WORKSPACE_PLAN_CAPABILITY_REQUIRED", + "CHAT_AUTH_MODE_NOT_PERMITTED", + "CONNECTOR_MANAGED_RESOURCE_READ_ONLY", + "PERMISSION_GROUP_CAPABILITY_BLOCKED", + "INTEGRATION_NOT_ALLOWED", + "INSUFFICIENT_SCOPE", + "SCIM_MANAGED_MEMBERSHIP" + ], + "title": "Forbidden detail code", + "description": "Stable cause code for an actionable `403` response." + }, + "V2Error": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "code": { + "type": "string", + "description": "Stable machine-readable error code." + }, + "message": { + "type": "string", + "description": "Human-readable explanation of the error." + }, + "details": { + "description": "Structured error context whose keys depend on the error. Actionable `403` responses use the `V2ActionableForbiddenDetails` shape; validation failures may return issue arrays instead.", + "anyOf": [ + { + "$ref": "#/components/schemas/V2ActionableForbiddenDetails" + }, + { + "description": "Other structured context defined by the specific error." + } + ] + } + }, + "required": ["code", "message"], + "additionalProperties": false, + "description": "Canonical error details." + } + }, + "required": ["error"], + "additionalProperties": false, + "title": "v2 error response", + "description": "Canonical error envelope returned by the public v2 API.", + "examples": [ + { + "error": { + "code": "BAD_REQUEST", + "message": "The request is invalid." + } + } + ] + }, + "V2CopyFileItemsResponse": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "files": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Identifier of the new file." + }, + "name": { + "type": "string", + "description": "Name of the copied file, including its extension." + }, + "size": { + "type": "number", + "description": "Current content size in bytes." + }, + "type": { + "type": "string", + "description": "MIME type of the copied file." + }, + "width": { + "description": "Known image width in pixels, when available.", + "anyOf": [ + { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "height": { + "description": "Known image height in pixels, when available.", + "anyOf": [ + { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991 + }, + { + "type": "null" + } + ] + }, + "uploadedBy": { + "type": "string", + "minLength": 1, + "description": "User who performed the copy, when available." + }, + "folderId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Containing folder identifier, or null at the owner root." + }, + "folderPath": { + "description": "Containing folder path, or null at the owner root.", + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "deletedAt": { + "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": "Archive time, or null for an active file." + }, + "uploadedAt": { + "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))$", + "description": "Time the new file was created." + }, + "updatedAt": { + "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))$", + "description": "Time the file metadata last changed." + }, + "contentUpdatedAt": { + "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 current file content was written." + }, + "revision": { + "type": "string", + "minLength": 1, + "description": "Current content revision for subsequent edits." + }, + "owner": { + "type": "object", + "properties": { + "entityType": { + "type": "string", + "enum": ["workspace", "project"], + "description": "Resource scope that owns these files." + }, + "entityId": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Identifier of the owning workspace or Project." + } + }, + "required": ["entityType", "entityId"], + "additionalProperties": false, + "description": "Canonical destination owner of the copied file." + } + }, + "required": [ + "id", + "name", + "size", + "type", + "uploadedBy", + "folderId", + "deletedAt", + "uploadedAt", + "updatedAt", + "contentUpdatedAt", + "revision", + "owner" + ], + "additionalProperties": false + }, + "description": "Created files with new identities." + }, + "folders": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Identifier of the new folder." + }, + "userId": { + "type": "string", + "minLength": 1, + "description": "User who performed the copy, when available." + }, + "name": { + "type": "string", + "description": "Name of the copied folder." + }, + "parentId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Parent folder identifier, or null at the owner root." + }, + "path": { + "type": "string", + "description": "Path of the copied folder within its owner." + }, + "sortOrder": { + "type": "number", + "description": "Display ordering value within the parent folder." + }, + "deletedAt": { + "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": "Archive time, or null for an active folder." + }, + "createdAt": { + "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))$", + "description": "Time the new folder was created." + }, + "updatedAt": { + "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))$", + "description": "Time the folder metadata last changed." + }, + "owner": { + "type": "object", + "properties": { + "entityType": { + "type": "string", + "enum": ["workspace", "project"], + "description": "Resource scope that owns these files." + }, + "entityId": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Identifier of the owning workspace or Project." + } + }, + "required": ["entityType", "entityId"], + "additionalProperties": false, + "description": "Canonical destination owner of the copied folder." + } + }, + "required": [ + "id", + "userId", + "name", + "parentId", + "path", + "sortOrder", + "deletedAt", + "createdAt", + "updatedAt", + "owner" + ], + "additionalProperties": false + }, + "description": "Created folders in their new hierarchy." + } + }, + "required": ["files", "folders"], + "additionalProperties": false, + "description": "Response data." + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Copy file items response", + "description": "Created files and folders, without private storage keys." + }, + "CopyFileItemsRequest": { + "type": "object", + "properties": { + "source": { + "type": "object", + "properties": { + "owner": { + "type": "object", + "properties": { + "entityType": { + "type": "string", + "enum": ["workspace", "project"], + "description": "Resource scope that owns these files." + }, + "entityId": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Identifier of the owning workspace or Project." + } + }, + "required": ["entityType", "entityId"], + "additionalProperties": false, + "description": "Owner from which to read the selected files and folders." + }, + "fileIds": { + "default": [], + "description": "Identifiers of individual files to copy.", + "maxItems": 1000, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } + }, + "folderIds": { + "default": [], + "description": "Identifiers of folders to copy with their active descendants and files.", + "maxItems": 1000, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 200 + } + } + }, + "required": ["owner"], + "additionalProperties": false, + "description": "Selection to read under the source owner." + }, + "destination": { + "type": "object", + "properties": { + "owner": { + "type": "object", + "properties": { + "entityType": { + "type": "string", + "enum": ["workspace", "project"], + "description": "Resource scope that owns these files." + }, + "entityId": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Identifier of the owning workspace or Project." + } + }, + "required": ["entityType", "entityId"], + "additionalProperties": false, + "description": "Owner in which to create the copied files and folders." + }, + "folderId": { + "default": null, + "description": "Existing destination folder identifier. Omit or use null for the owner root.", + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 200 + }, + { + "type": "null" + } + ] + } + }, + "required": ["owner"], + "additionalProperties": false, + "description": "Destination requiring file write access." + } + }, + "required": ["source", "destination"], + "additionalProperties": false, + "title": "Copy file items request", + "description": "Exact source owner and selected identifiers, plus the destination owner and folder." + }, + "V2ProjectFileUnzipResult": { + "title": "Project unzip result", + "description": "Outcome of unzipping a Project archive into a sibling folder.", + "$ref": "#/components/schemas/V2FileUnzipResult" + }, + "V2FileUnzipResult": { + "type": "object", + "properties": { + "folderPath": { + "type": "string", + "title": "Folder path", + "description": "Canonical path of the folder the archive was unpacked into. May differ from the archive name when a sibling folder already claimed it.", + "maxLength": 4096 + }, + "extractedFileCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of files written into the destination folder." + }, + "skippedFileCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of archive entries skipped as unsafe, empty, or noise." + } + }, + "required": ["folderPath", "extractedFileCount", "skippedFileCount"], + "additionalProperties": false, + "title": "Unzip result", + "description": "Outcome of unzipping a workspace archive into a folder." + }, + "V2ProjectFileUnzipResponse": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "folderPath": { + "type": "string", + "title": "Folder path", + "description": "Canonical path of the folder the archive was unpacked into. May differ from the archive name when a sibling folder already claimed it.", + "maxLength": 4096 + }, + "extractedFileCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of files written into the destination folder." + }, + "skippedFileCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of archive entries skipped as unsafe, empty, or noise." + } + }, + "required": ["folderPath", "extractedFileCount", "skippedFileCount"], + "additionalProperties": false, + "description": "Response data.", + "$ref": "#/components/schemas/V2ProjectFileUnzipResult" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Unzip Project file response", + "description": "Counts and destination folder for the unpacked archive." + }, + "V2FileSearchResults": { + "type": "object", + "properties": { + "results": { + "type": "array", + "items": { + "type": "object", + "properties": { + "fileId": { + "type": "string", + "minLength": 1, + "maxLength": 128, + "pattern": "^[A-Za-z0-9_-]+$", + "description": "File the line belongs to." + }, + "lineNumber": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991, + "description": "1-based line the match sits on." + }, + "text": { + "type": "string", + "description": "The matching line." + } + }, + "required": ["fileId", "lineNumber", "text"], + "additionalProperties": false + }, + "description": "Matching lines, one entry per line." + }, + "count": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of results returned." + }, + "truncated": { + "type": "boolean", + "description": "True when more matches exist beyond `maxResults`." + }, + "complete": { + "type": "boolean", + "description": "True when no files in the searched scope have pending or failed indexing. Missing matches remain inconclusive unless this is true and both `indexStatus.skippedFiles` and `indexStatus.partialFiles` are zero." + }, + "indexStatus": { + "type": "object", + "properties": { + "readyFiles": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Files whose current revision is indexed and searchable." + }, + "pendingFiles": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Files not yet indexed at their current revision. Their content was not searched." + }, + "failedFiles": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Files whose indexing failed. Their content was not searched." + }, + "skippedFiles": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Files deliberately not indexed, such as binaries and files above the size ceiling." + }, + "partialFiles": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Files indexed only in part, so matches beyond the indexed portion are not found." + } + }, + "required": [ + "readyFiles", + "pendingFiles", + "failedFiles", + "skippedFiles", + "partialFiles" + ], + "additionalProperties": false, + "description": "Index coverage across the searched scope." + } + }, + "required": ["results", "count", "truncated", "complete", "indexStatus"], + "additionalProperties": false, + "title": "File search results", + "description": "Matching lines from indexed workspace file content, with index coverage." + }, + "V2ProjectFileSearchResultsResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2FileSearchResults" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "File search results response", + "description": "Matching lines from indexed file content." + }, + "V2ProjectFile": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique file identifier.", + "examples": ["wf_V1StGXR8z5jdHi6BmyT91"] + }, + "name": { + "type": "string", + "description": "Original file name.", + "examples": ["data.csv"] + }, + "size": { + "type": "number", + "minimum": 0, + "description": "Size in bytes of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source, not the rendered document, so it does not predict how many bytes downloading the file returns.", + "examples": [1024] + }, + "type": { + "type": "string", + "description": "MIME type of the stored file. For a generated document (docx, pptx, pdf, xlsx) this is the generation source type, not the rendered document type a download serves.", + "examples": ["text/csv"] + }, + "key": { + "type": "string", + "description": "Storage key for the file.", + "examples": ["workspace/example/data.csv"] + }, + "folderPath": { + "type": "string", + "title": "Folder path", + "description": "Canonical containing-folder path. `/` is the Project root.", + "maxLength": 4096 + }, + "uploadedAt": { + "type": "string", + "description": "ISO 8601 timestamp when the file was uploaded.", + "format": "date-time", + "examples": ["2026-01-15T10:30:00Z"] + }, + "updatedAt": { + "type": "string", + "description": "ISO 8601 timestamp of the last content or metadata write.", + "format": "date-time", + "examples": ["2026-01-15T10:30:00Z"] + }, + "deletedAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp when the file was archived by deleting it, or null while the file is active. Only an archived-scope file list returns files with a non-null value.", + "format": "date-time", + "examples": ["2026-01-16T09:00:00Z"] + }, + "owner": { + "type": "object", + "properties": { + "entityType": { + "type": "string", + "const": "project", + "description": "The file owner is a Project." + }, + "entityId": { + "type": "string", + "minLength": 1, + "description": "Identifier of the owning Project." + } + }, + "required": ["entityType", "entityId"], + "additionalProperties": false, + "description": "Canonical owner of the shared file." + }, + "uploadedBy": { + "type": "string", + "minLength": 1, + "description": "Creator or successor identifier." + }, + "revision": { + "description": "Opaque token for the content this write produced. Send it back as `expectedRevision` on the next write. Absent for a file with no recorded content version.", + "type": "string" + } + }, + "required": [ + "id", + "name", + "size", + "type", + "key", + "folderPath", + "uploadedAt", + "updatedAt", + "deletedAt", + "owner", + "uploadedBy" + ], + "additionalProperties": false, + "title": "Project file" + }, + "V2ProjectFileMetadataResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2ProjectFile" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Project file metadata response", + "description": "Metadata for one authorized Project file." + }, + "RenameProjectFileRequest": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "New file name, including its extension." + } + }, + "required": ["name"], + "additionalProperties": false, + "title": "Rename Project File request", + "description": "The new file name." + }, + "V2MoveProjectFileItemsResponse": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "movedFiles": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of files moved." + }, + "movedFolders": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of folders moved." + }, + "movedFileIds": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Identifiers of moved files." + }, + "movedFolderIds": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Identifiers of moved folders." + } + }, + "required": ["movedFiles", "movedFolders", "movedFileIds", "movedFolderIds"], + "additionalProperties": false, + "description": "Response data." + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Project file operation response", + "description": "Counts and identifiers of moved items." + }, + "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.", + "maxLength": 4096, + "type": "string" + }, + "MoveProjectFileItemsRequest": { + "type": "object", + "properties": { + "fileIds": { + "default": [], + "description": "Identifiers of the files to move.", + "maxItems": 1000, + "type": "array", + "items": { + "type": "string" + } + }, + "folderIds": { + "default": [], + "description": "Identifiers of folders to move with their contents.", + "maxItems": 1000, + "type": "array", + "items": { + "type": "string" + } + }, + "targetFolderPath": { + "description": "Existing destination folder path within the Project. Omit to move items to the Project root.", + "$ref": "#/components/schemas/FolderPathInput" + } + }, + "additionalProperties": false, + "title": "Move Project File Items request", + "description": "Selected files and folders and their destination." + }, + "V2ArchiveProjectFileItemsResponse": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "deletedItems": { + "type": "object", + "properties": { + "files": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of affected files." + }, + "folders": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of affected folders." + } + }, + "required": ["files", "folders"], + "additionalProperties": false, + "description": "Counts of affected file items." + }, + "affectedIds": { + "type": "object", + "properties": { + "fileIds": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Identifiers of affected files." + }, + "folderIds": { + "type": "array", + "items": { + "type": "string", + "minLength": 1 + }, + "description": "Identifiers of affected folders." + } + }, + "required": ["fileIds", "folderIds"], + "additionalProperties": false, + "description": "Identifiers of affected file items." + } + }, + "required": ["deletedItems", "affectedIds"], + "additionalProperties": false, + "description": "Response data." + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Project file operation response", + "description": "Counts and identifiers of archived items." + }, + "ArchiveProjectFileItemsRequest": { + "type": "object", + "properties": { + "fileIds": { + "default": [], + "description": "Identifiers of the files to archive.", + "maxItems": 1000, + "type": "array", + "items": { + "type": "string" + } + }, + "folderIds": { + "default": [], + "description": "Identifiers of folders to archive recursively, including their files and descendants.", + "maxItems": 1000, + "type": "array", + "items": { + "type": "string" + } + } + }, + "additionalProperties": false, + "title": "Archive Project File Items request", + "description": "Files and folders to archive." + }, + "RestoreProjectFileRequest": { + "type": "object", + "properties": {}, + "additionalProperties": false, + "title": "Restore Project file request", + "description": "An empty object; the file is identified by its path parameters." + }, + "CreateProjectFileRequest": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "File name, including its extension. Path separators and dot segments are rejected." + }, + "contentType": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "MIME type. When omitted, it is inferred from the file extension." + }, + "content": { + "default": "", + "description": "Initial file content. Omit or send an empty string for a zero-byte file. The 70,000,000-character bound guards the JSON envelope; the decoded bytes must be at most 50 MiB, and a longer base64 payload is rejected with `413`. Use an upload session for anything larger.", + "type": "string", + "maxLength": 70000000 + }, + "encoding": { + "default": "utf-8", + "description": "Encoding of the content field.", + "type": "string", + "enum": ["utf-8", "base64"] + }, + "folderPath": { + "description": "Canonical containing-folder path. Omit for the Project root.", + "$ref": "#/components/schemas/FolderPathInput" + } + }, + "required": ["name"], + "additionalProperties": false, + "title": "Create Project file request", + "description": "Name, content, encoding, and containing folder within the Project." + }, + "UpdateProjectFileContentRequest": { + "type": "object", + "properties": { + "content": { + "type": "string", + "maxLength": 70000000, + "description": "Complete replacement content for the file. The 70,000,000-character bound guards the JSON envelope; the decoded bytes must be at most 50 MiB, and a longer base64 payload is rejected with `413`." + }, + "encoding": { + "default": "utf-8", + "description": "Encoding of the content field.", + "type": "string", + "enum": ["utf-8", "base64"] + }, + "expectedRevision": { + "description": "Revision from Get File Metadata or an earlier write; the request is refused with `409` when the content moved on.", + "type": "string", + "minLength": 1 + } + }, + "required": ["content"], + "additionalProperties": false, + "title": "Project content replacement", + "description": "Complete replacement bytes and an optional optimistic concurrency revision." + }, + "V2ProjectFileListResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/V2ProjectFile" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "Project file list response", + "description": "A page of files owned by the requested Project." + }, + "ExportProjectFileSnapshotRequest": { + "type": "object", + "properties": { + "content": { + "type": "string", + "maxLength": 5242880, + "description": "Visible Markdown snapshot to export. This does not replace the stored file or create a version." + } + }, + "required": ["content"], + "additionalProperties": false, + "title": "Project Markdown snapshot", + "description": "The visible document content to export." + }, + "ProjectFileVersion": { + "title": "Project file version", + "description": "One recorded version of a shared Project file.", + "$ref": "#/components/schemas/V2FileVersion" + }, + "V2FileVersion": { + "type": "object", + "properties": { + "fileId": { + "type": "string", + "description": "File this version belongs to." + }, + "version": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647, + "description": "Version number, increasing by one per recorded version. Numbers are never reused, so a gap means an older version was removed by retention or deleted.", + "examples": [3] + }, + "isCurrent": { + "type": "boolean", + "description": "Whether this version holds the current content of the file." + }, + "size": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Size in bytes of the stored content of this version." + }, + "contentType": { + "type": "string", + "description": "MIME type of the stored content of this version." + }, + "source": { + "type": "string", + "enum": ["upload", "user", "api", "copilot", "workflow", "collab", "revert", "unknown"], + "description": "What wrote this version: `upload` (the original upload), `user` (a save in the Sim editor), `api` (an API, CLI, or MCP write), `copilot` (Sim, the agent), `workflow` (a workflow run), `collab` (collaborative editing), `revert` (a revert to an earlier version), or `unknown` (content written before version history existed, or by a writer with no source of its own)." + }, + "authors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "User identifier." + }, + "email": { + "anyOf": [ + { + "type": "string", + "format": "email", + "pattern": "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + }, + { + "type": "null" + } + ], + "description": "Current email address of the user, or null when the account no longer exists." + } + }, + "required": ["id", "email"], + "additionalProperties": false + }, + "description": "Users who wrote this version, in order of first contribution. Empty for actorless writers such as workspace API keys. A collaborative version lists every editor in its window." + }, + "restoredFromVersion": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + }, + { + "type": "null" + } + ], + "description": "For a `revert` version, the version whose content it restored; otherwise null." + }, + "createdAt": { + "type": "string", + "description": "ISO 8601 timestamp when this content became current.", + "format": "date-time", + "examples": ["2026-01-15T10:30:00Z"] + }, + "updatedAt": { + "type": "string", + "description": "ISO 8601 timestamp of the last write folded into this version. Equals `createdAt` unless edits were coalesced into it.", + "format": "date-time", + "examples": ["2026-01-15T10:38:00Z"] + }, + "supersededAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp when a newer version replaced this one, or null for the current version. Retention ages versions from this time.", + "format": "date-time", + "examples": ["2026-01-16T09:00:00Z"] + } + }, + "required": [ + "fileId", + "version", + "isCurrent", + "size", + "contentType", + "source", + "authors", + "restoredFromVersion", + "createdAt", + "updatedAt", + "supersededAt" + ], + "additionalProperties": false, + "title": "File version", + "description": "One recorded version of the content of a workspace file." + }, + "V2ProjectFileVersionListResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/ProjectFileVersion" + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Opaque cursor for the next page. Send it back as `cursor`; `null` means there is nothing further to fetch. Never construct one yourself." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "List Project File Versions response", + "description": "A cursor-paginated page of Project file versions." + }, + "V2ProjectFileVersionResponse": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "fileId": { + "type": "string", + "description": "File this version belongs to." + }, + "version": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647, + "description": "Version number, increasing by one per recorded version. Numbers are never reused, so a gap means an older version was removed by retention or deleted.", + "examples": [3] + }, + "isCurrent": { + "type": "boolean", + "description": "Whether this version holds the current content of the file." + }, + "size": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Size in bytes of the stored content of this version." + }, + "contentType": { + "type": "string", + "description": "MIME type of the stored content of this version." + }, + "source": { + "type": "string", + "enum": [ + "upload", + "user", + "api", + "copilot", + "workflow", + "collab", + "revert", + "unknown" + ], + "description": "What wrote this version: `upload` (the original upload), `user` (a save in the Sim editor), `api` (an API, CLI, or MCP write), `copilot` (Sim, the agent), `workflow` (a workflow run), `collab` (collaborative editing), `revert` (a revert to an earlier version), or `unknown` (content written before version history existed, or by a writer with no source of its own)." + }, + "authors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "User identifier." + }, + "email": { + "anyOf": [ + { + "type": "string", + "format": "email", + "pattern": "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + }, + { + "type": "null" + } + ], + "description": "Current email address of the user, or null when the account no longer exists." + } + }, + "required": ["id", "email"], + "additionalProperties": false + }, + "description": "Users who wrote this version, in order of first contribution. Empty for actorless writers such as workspace API keys. A collaborative version lists every editor in its window." + }, + "restoredFromVersion": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + }, + { + "type": "null" + } + ], + "description": "For a `revert` version, the version whose content it restored; otherwise null." + }, + "createdAt": { + "type": "string", + "description": "ISO 8601 timestamp when this content became current.", + "format": "date-time", + "examples": ["2026-01-15T10:30:00Z"] + }, + "updatedAt": { + "type": "string", + "description": "ISO 8601 timestamp of the last write folded into this version. Equals `createdAt` unless edits were coalesced into it.", + "format": "date-time", + "examples": ["2026-01-15T10:38:00Z"] + }, + "supersededAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp when a newer version replaced this one, or null for the current version. Retention ages versions from this time.", + "format": "date-time", + "examples": ["2026-01-16T09:00:00Z"] + } + }, + "required": [ + "fileId", + "version", + "isCurrent", + "size", + "contentType", + "source", + "authors", + "restoredFromVersion", + "createdAt", + "updatedAt", + "supersededAt" + ], + "additionalProperties": false, + "description": "Response data.", + "$ref": "#/components/schemas/ProjectFileVersion" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Get Project File Version response", + "description": "Metadata for the selected version." + }, + "V2ProjectFileVersionRevertResponse": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "reverted": { + "type": "boolean", + "description": "False if the selected version is already current." + }, + "file": { + "$ref": "#/components/schemas/V2ProjectFile" + }, + "version": { + "type": "object", + "properties": { + "fileId": { + "type": "string", + "description": "File this version belongs to." + }, + "version": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647, + "description": "Version number, increasing by one per recorded version. Numbers are never reused, so a gap means an older version was removed by retention or deleted.", + "examples": [3] + }, + "isCurrent": { + "type": "boolean", + "description": "Whether this version holds the current content of the file." + }, + "size": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Size in bytes of the stored content of this version." + }, + "contentType": { + "type": "string", + "description": "MIME type of the stored content of this version." + }, + "source": { + "type": "string", + "enum": [ + "upload", + "user", + "api", + "copilot", + "workflow", + "collab", + "revert", + "unknown" + ], + "description": "What wrote this version: `upload` (the original upload), `user` (a save in the Sim editor), `api` (an API, CLI, or MCP write), `copilot` (Sim, the agent), `workflow` (a workflow run), `collab` (collaborative editing), `revert` (a revert to an earlier version), or `unknown` (content written before version history existed, or by a writer with no source of its own)." + }, + "authors": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "User identifier." + }, + "email": { + "anyOf": [ + { + "type": "string", + "format": "email", + "pattern": "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" + }, + { + "type": "null" + } + ], + "description": "Current email address of the user, or null when the account no longer exists." + } + }, + "required": ["id", "email"], + "additionalProperties": false + }, + "description": "Users who wrote this version, in order of first contribution. Empty for actorless writers such as workspace API keys. A collaborative version lists every editor in its window." + }, + "restoredFromVersion": { + "anyOf": [ + { + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + }, + { + "type": "null" + } + ], + "description": "For a `revert` version, the version whose content it restored; otherwise null." + }, + "createdAt": { + "type": "string", + "description": "ISO 8601 timestamp when this content became current.", + "format": "date-time", + "examples": ["2026-01-15T10:30:00Z"] + }, + "updatedAt": { + "type": "string", + "description": "ISO 8601 timestamp of the last write folded into this version. Equals `createdAt` unless edits were coalesced into it.", + "format": "date-time", + "examples": ["2026-01-15T10:38:00Z"] + }, + "supersededAt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ISO 8601 timestamp when a newer version replaced this one, or null for the current version. Retention ages versions from this time.", + "format": "date-time", + "examples": ["2026-01-16T09:00:00Z"] + } + }, + "required": [ + "fileId", + "version", + "isCurrent", + "size", + "contentType", + "source", + "authors", + "restoredFromVersion", + "createdAt", + "updatedAt", + "supersededAt" + ], + "additionalProperties": false, + "description": "The current version after the revert.", + "$ref": "#/components/schemas/ProjectFileVersion" + }, + "revision": { + "description": "Opaque token for the content this write produced. Send it back as `expectedRevision` on the next write. Absent for a file with no recorded content version.", + "type": "string" + } + }, + "required": ["reverted", "file", "version"], + "additionalProperties": false, + "description": "Response data." + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Revert Project File Version response", + "description": "The file and current version after the revert." + }, + "RevertProjectFileVersionRequest": { + "type": "object", + "properties": { + "expectedCurrentVersion": { + "description": "Revert only while this is still the current version; otherwise the request fails with `409`. Omit to revert whatever is current. Collaborative edits and repeated workflow writes that fold into the current version keep its number, so prefer `expectedRevision` to guard content.", + "type": "integer", + "minimum": 1, + "maximum": 2147483647 + }, + "expectedRevision": { + "description": "Revert only while the file still holds the content this revision names, as returned by Get File Metadata or an earlier write; otherwise the request fails with `409`. Unlike a version number, it also catches edits that folded into the current version.", + "type": "string", + "minLength": 1 + } + }, + "additionalProperties": false, + "title": "Revert Project file version request", + "description": "Optional revision or current-version preconditions." + }, + "V2ProjectFileVersionDeleteResponse": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "fileId": { + "type": "string", + "description": "File whose version was deleted." + }, + "version": { + "type": "integer", + "minimum": 1, + "maximum": 2147483647, + "description": "Version number that was deleted." + }, + "deleted": { + "type": "boolean", + "const": true, + "description": "The version is no longer available; stored-object cleanup is retried asynchronously when needed." + } + }, + "required": ["fileId", "version", "deleted"], + "additionalProperties": false, + "description": "Response data." + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Delete Project File Version response", + "description": "Deletion acknowledgement for the superseded version." + }, + "V2ProjectFileFolderListResponse": { + "type": "object", + "properties": { + "data": { + "type": "array", + "items": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Stable folder identifier." + }, + "userId": { + "type": "string", + "minLength": 1, + "description": "Creator or successor identifier." + }, + "name": { + "type": "string", + "description": "Folder name." + }, + "parentId": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ], + "description": "Parent folder identifier, or null at the root." + }, + "path": { + "type": "string", + "description": "Display path with slash characters in folder names escaped." + }, + "sortOrder": { + "type": "number", + "description": "Position within its parent folder." + }, + "deletedAt": { + "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": "Archive time, or null for an active folder." + }, + "createdAt": { + "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))$", + "description": "Time the folder was created." + }, + "updatedAt": { + "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))$", + "description": "Time the folder was last changed." + }, + "owner": { + "type": "object", + "properties": { + "entityType": { + "type": "string", + "const": "project", + "description": "The folder is owned by a Project." + }, + "entityId": { + "type": "string", + "minLength": 1, + "description": "Identifier of the owning Project." + } + }, + "required": ["entityType", "entityId"], + "additionalProperties": false, + "description": "Canonical Project owner of the folder." + } + }, + "required": [ + "id", + "userId", + "name", + "parentId", + "path", + "sortOrder", + "deletedAt", + "createdAt", + "updatedAt", + "owner" + ], + "additionalProperties": false + }, + "description": "Items in the current page." + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Always `null` — this list has no `cursor` or `limit` param and returns its whole bounded set in one page. Present so the list can gain pages later without a shape change." + } + }, + "required": ["data", "nextCursor"], + "additionalProperties": false, + "title": "Project folder list response", + "description": "Folders in their existing manual order; nextCursor is always null." + }, + "V2ProjectFileFolderResponse": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Stable folder identifier." + }, + "userId": { + "type": "string", + "minLength": 1, + "description": "Creator or successor identifier." + }, + "name": { + "type": "string", + "description": "Folder name." + }, + "parentId": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ], + "description": "Parent folder identifier, or null at the root." + }, + "path": { + "type": "string", + "description": "Display path with slash characters in folder names escaped." + }, + "sortOrder": { + "type": "number", + "description": "Position within its parent folder." + }, + "deletedAt": { + "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": "Archive time, or null for an active folder." + }, + "createdAt": { + "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))$", + "description": "Time the folder was created." + }, + "updatedAt": { + "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))$", + "description": "Time the folder was last changed." + }, + "owner": { + "type": "object", + "properties": { + "entityType": { + "type": "string", + "const": "project", + "description": "The folder is owned by a Project." + }, + "entityId": { + "type": "string", + "minLength": 1, + "description": "Identifier of the owning Project." + } + }, + "required": ["entityType", "entityId"], + "additionalProperties": false, + "description": "Canonical Project owner of the folder." + } + }, + "required": [ + "id", + "userId", + "name", + "parentId", + "path", + "sortOrder", + "deletedAt", + "createdAt", + "updatedAt", + "owner" + ], + "additionalProperties": false, + "description": "Response data." + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Project folder response", + "description": "One folder with canonical ownership and creator attribution." + }, + "CreateProjectFileFolderRequest": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "description": "Name for the new folder." + }, + "parentId": { + "description": "Parent folder identifier; omit or use null for the root.", + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + } + }, + "required": ["name"], + "additionalProperties": false, + "title": "Create Project folder request", + "description": "The folder name and optional parent identifier." + }, + "UpdateProjectFileFolderRequest": { + "type": "object", + "properties": { + "name": { + "description": "New folder name; omit to leave unchanged.", + "type": "string", + "minLength": 1 + }, + "parentId": { + "description": "New parent folder identifier; null moves to the root, omission leaves the parent unchanged.", + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ] + }, + "sortOrder": { + "description": "New manual position; omit to leave unchanged.", + "type": "integer", + "minimum": -9007199254740991, + "maximum": 9007199254740991 + } + }, + "additionalProperties": false, + "title": "Update Project folder request", + "description": "The fields to change on the folder." + }, + "V2RestoreProjectFileFolderResponse": { + "type": "object", + "properties": { + "data": { + "type": "object", + "properties": { + "folder": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "description": "Stable folder identifier." + }, + "userId": { + "type": "string", + "minLength": 1, + "description": "Creator or successor identifier." + }, + "name": { + "type": "string", + "description": "Folder name." + }, + "parentId": { + "anyOf": [ + { + "type": "string", + "minLength": 1 + }, + { + "type": "null" + } + ], + "description": "Parent folder identifier, or null at the root." + }, + "path": { + "type": "string", + "description": "Display path with slash characters in folder names escaped." + }, + "sortOrder": { + "type": "number", + "description": "Position within its parent folder." + }, + "deletedAt": { + "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": "Archive time, or null for an active folder." + }, + "createdAt": { + "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))$", + "description": "Time the folder was created." + }, + "updatedAt": { + "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))$", + "description": "Time the folder was last changed." + }, + "owner": { + "type": "object", + "properties": { + "entityType": { + "type": "string", + "const": "project", + "description": "The folder is owned by a Project." + }, + "entityId": { + "type": "string", + "minLength": 1, + "description": "Identifier of the owning Project." + } + }, + "required": ["entityType", "entityId"], + "additionalProperties": false, + "description": "Canonical Project owner of the folder." + } + }, + "required": [ + "id", + "userId", + "name", + "parentId", + "path", + "sortOrder", + "deletedAt", + "createdAt", + "updatedAt", + "owner" + ], + "additionalProperties": false, + "description": "Restored Project folder." + }, + "restoredItems": { + "type": "object", + "properties": { + "files": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of affected files." + }, + "folders": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Number of affected folders." + } + }, + "required": ["files", "folders"], + "additionalProperties": false, + "description": "Counts of affected file items." + } + }, + "required": ["folder", "restoredItems"], + "additionalProperties": false, + "description": "Response data." + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Restore Project folder response", + "description": "The restored folder and affected item counts." + }, + "RestoreProjectFileFolderRequest": { + "type": "object", + "properties": {}, + "additionalProperties": false, + "title": "Restore Project folder request", + "description": "An empty object; the folder is identified in the path." + }, + "V2FileShare": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique share identifier." + }, + "token": { + "type": "string", + "description": "Server-generated token embedded in the public share URL." + }, + "url": { + "type": "string", + "format": "uri", + "description": "Public share URL.", + "examples": ["https://www.sim.ai/f/share-token-example"] + }, + "isActive": { + "type": "boolean", + "description": "Whether the public share currently resolves." + }, + "resourceType": { + "type": "string", + "enum": ["file", "folder"], + "description": "Kind of resource being shared." + }, + "resourceId": { + "type": "string", + "description": "Identifier of the shared resource." + }, + "authType": { + "type": "string", + "enum": ["public", "password", "email", "sso"], + "description": "How access to the share is gated." + }, + "hasPassword": { + "type": "boolean", + "description": "Whether a password is stored for this share." + }, + "allowedEmails": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 320 + }, + "description": "Allowed addresses or @domain patterns for email and SSO shares." + } + }, + "required": [ + "id", + "token", + "url", + "isActive", + "resourceType", + "resourceId", + "authType", + "hasPassword", + "allowedEmails" + ], + "additionalProperties": false, + "title": "File share", + "description": "Public-safe share configuration for a file." + }, + "V2GetProjectFileShareResponse": { + "type": "object", + "properties": { + "data": { + "anyOf": [ + { + "$ref": "#/components/schemas/V2FileShare" + }, + { + "type": "null" + } + ], + "description": "Response data." + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Get Project file share response", + "description": "Current public-share state for a Project file." + }, + "V2UpdateProjectFileShareResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2FileShare" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Update Project file share response", + "description": "Updated public-share state for a Project file." + }, + "UpdateProjectFileShareRequest": { + "type": "object", + "properties": { + "isActive": { + "type": "boolean", + "description": "Whether the share should resolve. Disabling preserves the token and the whole access configuration, so re-enabling restores the share as it was; enabling rewrites the credentials the resulting mode does not use." + }, + "authType": { + "description": "How access to the share is gated. The stored mode is kept when omitted. Enabling `public` clears the stored password and empties `allowedEmails`; `password` empties `allowedEmails`; `email` and `sso` clear the stored password.", + "type": "string", + "enum": ["public", "password", "email", "sso"] + }, + "password": { + "description": "Literal password of 15 to 1024 characters. Kept when omitted; enabling password access without a supplied or stored password is rejected.", + "type": "string", + "minLength": 15, + "maxLength": 1024 + }, + "allowedEmails": { + "description": "Allowed addresses or `@domain` patterns for email and SSO shares. Kept when omitted; enabling `email` or `sso` with an empty resulting list is a 400.", + "maxItems": 200, + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 320 + } + } + }, + "required": ["isActive"], + "additionalProperties": false, + "title": "Update Project file share request", + "description": "Desired public-share state and access policy. Share tokens are generated by the server." + }, + "V2ProjectFileUpload": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Upload session identifier." + }, + "status": { + "type": "string", + "enum": [ + "uploading", + "completing", + "finalizing", + "completed", + "failed", + "aborting", + "aborted", + "expired" + ], + "description": "Current upload session status." + }, + "name": { + "type": "string", + "description": "File name supplied when the session was created." + }, + "contentType": { + "type": "string", + "description": "MIME type supplied when the session was created." + }, + "size": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Expected file size in bytes." + }, + "expiresAt": { + "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))$", + "description": "ISO 8601 time when the upload session expires." + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Failure message, or null when no failure has occurred." + }, + "file": { + "anyOf": [ + { + "$ref": "#/components/schemas/V2ProjectFile" + }, + { + "type": "null" + } + ], + "description": "Registered Project file after finalization, or null before registration or after archival." + } + }, + "required": ["id", "status", "name", "contentType", "size", "expiresAt", "error", "file"], + "additionalProperties": false, + "title": "Project file upload session" + }, + "V2PutUploadTransfer": { + "type": "object", + "properties": { + "method": { + "type": "string", + "const": "put", + "description": "Upload strategy discriminator." + }, + "url": { + "type": "string", + "format": "uri", + "description": "Signed URL to which the file bytes are uploaded. Upload bytes with `PUT` and exactly the supplied headers; never construct or modify the signed URL. Treat any `2xx` as success. Sim-hosted URLs return an empty `204` and v2 JSON errors. Object-storage URLs may return `200` or `201` and provider-specific errors, often XML." + }, + "headers": { + "type": "object", + "propertyNames": { + "type": "string" + }, + "additionalProperties": { + "type": "string" + }, + "description": "Headers that must be included with the upload request." + }, + "expiresAt": { + "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))$", + "description": "ISO 8601 expiration time for this signed URL. This is the URL's own expiry and is normally earlier than the upload session's expiresAt: the session stays open for later part, status, completion, and abort requests, but the bytes must be uploaded before this time. Once it passes, the storage provider rejects the upload and a new upload session must be created." + } + }, + "required": ["method", "url", "headers", "expiresAt"], + "additionalProperties": false, + "title": "Direct upload transfer", + "description": "Instructions for uploading bytes to one signed URL." + }, + "V2MultipartUploadTransfer": { + "type": "object", + "properties": { + "method": { + "type": "string", + "const": "multipart", + "description": "Upload strategy discriminator." + }, + "partSize": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 9007199254740991, + "description": "Required size of each non-final part in bytes." + }, + "partCount": { + "type": "integer", + "exclusiveMinimum": 0, + "maximum": 640, + "description": "Total number of upload parts." + } + }, + "required": ["method", "partSize", "partCount"], + "additionalProperties": false, + "title": "Multipart upload transfer", + "description": "Instructions for splitting bytes into a multipart upload." + }, + "V2CreateProjectFileUploadData": { + "type": "object", + "properties": { + "session": { + "description": "New Project upload session.", + "$ref": "#/components/schemas/V2ProjectFileUpload" + }, + "uploadToken": { + "type": "string", + "minLength": 1, + "description": "Signed control token required by later upload-session requests." + }, + "transfer": { + "oneOf": [ + { + "$ref": "#/components/schemas/V2PutUploadTransfer" + }, + { + "$ref": "#/components/schemas/V2MultipartUploadTransfer" + } + ], + "description": "Instructions for transferring the file bytes." + } + }, + "required": ["session", "uploadToken", "transfer"], + "additionalProperties": false, + "title": "Create Project file upload data" + }, + "V2CreateProjectFileUploadResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2CreateProjectFileUploadData" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Create Project File Upload response", + "description": "The authorized upload state or transfer instructions." + }, + "ProjectUploadCreateRequest": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "File name, including its extension." + }, + "contentType": { + "type": "string", + "minLength": 1, + "maxLength": 255, + "description": "MIME type of the uploaded file." + }, + "size": { + "type": "integer", + "minimum": 0, + "maximum": 5368709120, + "description": "Exact file size in bytes." + }, + "folderPath": { + "description": "Canonical destination folder path. Specify either folderId or folderPath, not both.", + "$ref": "#/components/schemas/FolderPathInput" + }, + "folderId": { + "description": "Destination folder identifier; omit or use null for the Project root.", + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + { + "type": "null" + } + ] } - } - } - }, - "schemas": { - "V2ActionableForbiddenDetails": { + }, + "required": ["name", "contentType", "size"], + "additionalProperties": false, + "title": "Create Project File Upload request", + "description": "The parameters for this upload operation." + }, + "V2ProjectFileUploadResponse": { "type": "object", "properties": { - "code": { - "$ref": "#/components/schemas/V2ForbiddenDetailCode" + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2ProjectFileUpload" } }, - "required": ["code"], - "additionalProperties": { - "description": "Additional context for this refusal." - }, - "title": "Actionable forbidden details", - "description": "Machine-readable cause and optional context for an actionable `403` response." - }, - "V2ForbiddenDetailCode": { - "type": "string", - "enum": [ - "INSUFFICIENT_WORKSPACE_ROLE", - "PERSONAL_API_KEYS_DISABLED", - "WORKSPACE_KEY_OPERATION_NOT_PERMITTED", - "PRINCIPAL_KIND_NOT_PERMITTED", - "ORGANIZATION_MEMBERSHIP_REQUIRED", - "ORGANIZATION_ADMIN_REQUIRED", - "ENTERPRISE_PLAN_REQUIRED", - "SSO_DISABLED", - "SSO_DOMAIN_NOT_VERIFIED", - "SSO_PROVIDER_LIMIT_REACHED", - "ORGANIZATION_PLAN_REQUIRED", - "AUDIT_LOGS_DISABLED", - "ACCESS_REQUESTS_DISABLED", - "ACCESS_REQUEST_ORGANIZATION_REQUIRED", - "SKILL_EDITOR_ACCESS_REQUIRED", - "SECRET_ADMIN_ACCESS_REQUIRED", - "WORKSPACE_RESOURCE_LIMIT_REACHED", - "PUBLIC_SHARING_NOT_ALLOWED", - "CREDENTIAL_ADMIN_ACCESS_REQUIRED", - "MCP_SERVER_URL_NOT_ALLOWED", - "WORKSPACE_PLAN_CAPABILITY_REQUIRED", - "CHAT_AUTH_MODE_NOT_PERMITTED", - "CONNECTOR_MANAGED_RESOURCE_READ_ONLY", - "PERMISSION_GROUP_CAPABILITY_BLOCKED", - "INTEGRATION_NOT_ALLOWED", - "INSUFFICIENT_SCOPE", - "SCIM_MANAGED_MEMBERSHIP" - ], - "title": "Forbidden detail code", - "description": "Stable cause code for an actionable `403` response." + "required": ["data"], + "additionalProperties": false, + "title": "Project file upload response", + "description": "The authorized upload state or transfer instructions." }, - "V2Error": { + "V2UploadPartUrl": { "type": "object", "properties": { - "error": { + "partNumber": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991, + "description": "Multipart part number." + }, + "url": { + "type": "string", + "format": "uri", + "description": "Signed URL for this upload part. Upload bytes with `PUT` and exactly the supplied headers; never construct or modify the signed URL. Treat any `2xx` as success. Sim-hosted URLs return an empty `204` and v2 JSON errors. Object-storage URLs may return `200` or `201` and provider-specific errors, often XML. Do not retain part `ETag` values; after every part succeeds, call the completion endpoint without a request body." + }, + "headers": { "type": "object", - "properties": { - "code": { - "type": "string", - "description": "Stable machine-readable error code." - }, - "message": { - "type": "string", - "description": "Human-readable explanation of the error." - }, - "details": { - "description": "Structured error context whose keys depend on the error. Actionable `403` responses use the `V2ActionableForbiddenDetails` shape; validation failures may return issue arrays instead.", - "anyOf": [ - { - "$ref": "#/components/schemas/V2ActionableForbiddenDetails" - }, - { - "description": "Other structured context defined by the specific error." - } - ] - } + "propertyNames": { + "type": "string" }, - "required": ["code", "message"], - "additionalProperties": false, - "description": "Canonical error details." + "additionalProperties": { + "type": "string" + }, + "description": "Headers that must be included with the part upload." + }, + "expiresAt": { + "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))$", + "description": "ISO 8601 expiration time for the signed URL." } }, - "required": ["error"], + "required": ["partNumber", "url", "headers", "expiresAt"], "additionalProperties": false, - "title": "v2 error response", - "description": "Canonical error envelope returned by the public v2 API.", - "examples": [ - { - "error": { - "code": "BAD_REQUEST", - "message": "The request is invalid." - } + "title": "Upload part URL", + "description": "A signed URL and required headers for one multipart upload part." + }, + "V2PartUrlsData": { + "type": "object", + "properties": { + "parts": { + "maxItems": 100, + "type": "array", + "items": { + "$ref": "#/components/schemas/V2UploadPartUrl" + }, + "description": "Signed URLs for requested parts." } - ] + }, + "required": ["parts"], + "additionalProperties": false, + "title": "Upload part URLs", + "description": "Signed transfer URLs for the requested multipart upload parts." }, - "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.", - "maxLength": 4096, - "type": "string" + "V2ProjectFileUploadPartUrlsResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2PartUrlsData" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "Get Project File Upload Part URLs response", + "description": "The authorized upload state or transfer instructions." + }, + "ProjectFileUploadPartUrlsRequest": { + "type": "object", + "properties": { + "partNumbers": { + "minItems": 1, + "maxItems": 100, + "type": "array", + "items": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991 + }, + "description": "Multipart part numbers for which signed URLs should be created." + } + }, + "required": ["partNumbers"], + "additionalProperties": false, + "title": "Get Project File Upload Part URLs request", + "description": "The parameters for this upload operation." }, "V2File": { "type": "object", @@ -4237,112 +9461,51 @@ }, "name": { "type": "string", - "description": "File name supplied when the session was created." - }, - "contentType": { - "type": "string", - "description": "MIME type supplied when the session was created." - }, - "size": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Expected file size in bytes." - }, - "expiresAt": { - "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))$", - "description": "ISO 8601 time when the upload session expires." - }, - "error": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "description": "Failure message, or null when no failure has occurred." - }, - "file": { - "anyOf": [ - { - "$ref": "#/components/schemas/V2File" - }, - { - "type": "null" - } - ], - "description": "Registered file after finalization, or null before finalization completes." - } - }, - "required": ["id", "status", "name", "contentType", "size", "expiresAt", "error", "file"], - "additionalProperties": false, - "title": "File upload session", - "description": "Current state of a resumable workspace-file upload session." - }, - "V2PutUploadTransfer": { - "type": "object", - "properties": { - "method": { - "type": "string", - "const": "put", - "description": "Upload strategy discriminator." - }, - "url": { - "type": "string", - "format": "uri", - "description": "Signed URL to which the file bytes are uploaded. Upload bytes with `PUT` and exactly the supplied headers; never construct or modify the signed URL. Treat any `2xx` as success. Sim-hosted URLs return an empty `204` and v2 JSON errors. Object-storage URLs may return `200` or `201` and provider-specific errors, often XML." - }, - "headers": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string" - }, - "description": "Headers that must be included with the upload request." - }, - "expiresAt": { - "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))$", - "description": "ISO 8601 expiration time for this signed URL. This is the URL's own expiry and is normally earlier than the upload session's expiresAt: the session stays open for later part, status, completion, and abort requests, but the bytes must be uploaded before this time. Once it passes, the storage provider rejects the upload and a new upload session must be created." - } - }, - "required": ["method", "url", "headers", "expiresAt"], - "additionalProperties": false, - "title": "Direct upload transfer", - "description": "Instructions for uploading bytes to one signed URL." - }, - "V2MultipartUploadTransfer": { - "type": "object", - "properties": { - "method": { - "type": "string", - "const": "multipart", - "description": "Upload strategy discriminator." + "description": "File name supplied when the session was created." }, - "partSize": { + "contentType": { + "type": "string", + "description": "MIME type supplied when the session was created." + }, + "size": { "type": "integer", - "exclusiveMinimum": 0, + "minimum": 0, "maximum": 9007199254740991, - "description": "Required size of each non-final part in bytes." + "description": "Expected file size in bytes." }, - "partCount": { - "type": "integer", - "exclusiveMinimum": 0, - "maximum": 640, - "description": "Total number of upload parts." + "expiresAt": { + "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))$", + "description": "ISO 8601 time when the upload session expires." + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Failure message, or null when no failure has occurred." + }, + "file": { + "anyOf": [ + { + "$ref": "#/components/schemas/V2File" + }, + { + "type": "null" + } + ], + "description": "Registered file after finalization, or null before finalization completes." } }, - "required": ["method", "partSize", "partCount"], + "required": ["id", "status", "name", "contentType", "size", "expiresAt", "error", "file"], "additionalProperties": false, - "title": "Multipart upload transfer", - "description": "Instructions for splitting bytes into a multipart upload." + "title": "File upload session", + "description": "Current state of a resumable workspace-file upload session." }, "V2CreateFileUploadData": { "type": "object", @@ -4436,59 +9599,6 @@ "title": "File upload response", "description": "Current upload-session state." }, - "V2UploadPartUrl": { - "type": "object", - "properties": { - "partNumber": { - "type": "integer", - "minimum": 1, - "maximum": 9007199254740991, - "description": "Multipart part number." - }, - "url": { - "type": "string", - "format": "uri", - "description": "Signed URL for this upload part. Upload bytes with `PUT` and exactly the supplied headers; never construct or modify the signed URL. Treat any `2xx` as success. Sim-hosted URLs return an empty `204` and v2 JSON errors. Object-storage URLs may return `200` or `201` and provider-specific errors, often XML. Do not retain part `ETag` values; after every part succeeds, call the completion endpoint without a request body." - }, - "headers": { - "type": "object", - "propertyNames": { - "type": "string" - }, - "additionalProperties": { - "type": "string" - }, - "description": "Headers that must be included with the part upload." - }, - "expiresAt": { - "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))$", - "description": "ISO 8601 expiration time for the signed URL." - } - }, - "required": ["partNumber", "url", "headers", "expiresAt"], - "additionalProperties": false, - "title": "Upload part URL", - "description": "A signed URL and required headers for one multipart upload part." - }, - "V2PartUrlsData": { - "type": "object", - "properties": { - "parts": { - "maxItems": 100, - "type": "array", - "items": { - "$ref": "#/components/schemas/V2UploadPartUrl" - }, - "description": "Signed URLs for requested parts." - } - }, - "required": ["parts"], - "additionalProperties": false, - "title": "Upload part URLs", - "description": "Signed transfer URLs for the requested multipart upload parts." - }, "CreateFileUploadPartUrlsResponse": { "type": "object", "properties": { @@ -4582,184 +9692,67 @@ "type": "integer", "minimum": 0, "maximum": 9007199254740991, - "description": "Source bytes read from storage before extraction." - }, - "lineRange": { - "description": "Present when `offset` or `limit` narrowed the response. `totalLines` is what separates a file that ended from a window that stopped early.", - "type": "object", - "properties": { - "offset": { - "type": "integer", - "minimum": 1, - "maximum": 9007199254740991, - "description": "First line returned, 1-based." - }, - "lineCount": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Lines returned." - }, - "totalLines": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Lines the whole file holds." - }, - "totalLinesExact": { - "type": "boolean", - "description": "False when text extraction was truncated, so `totalLines` counts only the extracted prefix and is not the end of the file." - } - }, - "required": ["offset", "lineCount", "totalLines", "totalLinesExact"], - "additionalProperties": false - } - }, - "required": [ - "fileId", - "name", - "path", - "type", - "text", - "truncated", - "degraded", - "degradedReason", - "charCount", - "byteCount" - ], - "additionalProperties": false, - "title": "Extracted file text", - "description": "Text extracted from a workspace file, with extraction-quality flags." - }, - "FileTextResponse": { - "type": "object", - "properties": { - "data": { - "description": "Response data.", - "$ref": "#/components/schemas/V2FileText" - } - }, - "required": ["data"], - "additionalProperties": false, - "title": "File text response", - "description": "Text extracted from a workspace file." - }, - "V2FileVersion": { - "type": "object", - "properties": { - "fileId": { - "type": "string", - "description": "File this version belongs to." - }, - "version": { - "type": "integer", - "minimum": 1, - "maximum": 2147483647, - "description": "Version number, increasing by one per recorded version. Numbers are never reused, so a gap means an older version was removed by retention or deleted.", - "examples": [3] - }, - "isCurrent": { - "type": "boolean", - "description": "Whether this version holds the current content of the file." - }, - "size": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Size in bytes of the stored content of this version." - }, - "contentType": { - "type": "string", - "description": "MIME type of the stored content of this version." - }, - "source": { - "type": "string", - "enum": ["upload", "user", "api", "copilot", "workflow", "collab", "revert", "unknown"], - "description": "What wrote this version: `upload` (the original upload), `user` (a save in the Sim editor), `api` (an API, CLI, or MCP write), `copilot` (Sim, the agent), `workflow` (a workflow run), `collab` (collaborative editing), `revert` (a revert to an earlier version), or `unknown` (content written before version history existed, or by a writer with no source of its own)." - }, - "authors": { - "type": "array", - "items": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "User identifier." - }, - "email": { - "anyOf": [ - { - "type": "string", - "format": "email", - "pattern": "^[a-zA-Z0-9.!#$%&'*+/=?^_`{|}~-]+@[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?(?:\\.[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?)*$" - }, - { - "type": "null" - } - ], - "description": "Current email address of the user, or null when the account no longer exists." - } - }, - "required": ["id", "email"], - "additionalProperties": false - }, - "description": "Users who wrote this version, in order of first contribution. Empty for actorless writers such as workspace API keys. A collaborative version lists every editor in its window." - }, - "restoredFromVersion": { - "anyOf": [ - { - "type": "integer", - "minimum": 1, - "maximum": 2147483647 - }, - { - "type": "null" - } - ], - "description": "For a `revert` version, the version whose content it restored; otherwise null." - }, - "createdAt": { - "type": "string", - "description": "ISO 8601 timestamp when this content became current.", - "format": "date-time", - "examples": ["2026-01-15T10:30:00Z"] - }, - "updatedAt": { - "type": "string", - "description": "ISO 8601 timestamp of the last write folded into this version. Equals `createdAt` unless edits were coalesced into it.", - "format": "date-time", - "examples": ["2026-01-15T10:38:00Z"] - }, - "supersededAt": { - "anyOf": [ - { - "type": "string" + "description": "Source bytes read from storage before extraction." + }, + "lineRange": { + "description": "Present when `offset` or `limit` narrowed the response. `totalLines` is what separates a file that ended from a window that stopped early.", + "type": "object", + "properties": { + "offset": { + "type": "integer", + "minimum": 1, + "maximum": 9007199254740991, + "description": "First line returned, 1-based." }, - { - "type": "null" + "lineCount": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Lines returned." + }, + "totalLines": { + "type": "integer", + "minimum": 0, + "maximum": 9007199254740991, + "description": "Lines the whole file holds." + }, + "totalLinesExact": { + "type": "boolean", + "description": "False when text extraction was truncated, so `totalLines` counts only the extracted prefix and is not the end of the file." } - ], - "description": "ISO 8601 timestamp when a newer version replaced this one, or null for the current version. Retention ages versions from this time.", - "format": "date-time", - "examples": ["2026-01-16T09:00:00Z"] + }, + "required": ["offset", "lineCount", "totalLines", "totalLinesExact"], + "additionalProperties": false } }, "required": [ "fileId", - "version", - "isCurrent", - "size", - "contentType", - "source", - "authors", - "restoredFromVersion", - "createdAt", - "updatedAt", - "supersededAt" + "name", + "path", + "type", + "text", + "truncated", + "degraded", + "degradedReason", + "charCount", + "byteCount" ], "additionalProperties": false, - "title": "File version", - "description": "One recorded version of the content of a workspace file." + "title": "Extracted file text", + "description": "Text extracted from a workspace file, with extraction-quality flags." + }, + "FileTextResponse": { + "type": "object", + "properties": { + "data": { + "description": "Response data.", + "$ref": "#/components/schemas/V2FileText" + } + }, + "required": ["data"], + "additionalProperties": false, + "title": "File text response", + "description": "Text extracted from a workspace file." }, "V2FileVersionListResponse": { "type": "object", @@ -5126,33 +10119,6 @@ } ] }, - "V2FileUnzipResult": { - "type": "object", - "properties": { - "folderPath": { - "type": "string", - "title": "Folder path", - "description": "Canonical path of the folder the archive was unpacked into. May differ from the archive name when a sibling folder already claimed it.", - "maxLength": 4096 - }, - "extractedFileCount": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Number of files written into the destination folder." - }, - "skippedFileCount": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Number of archive entries skipped as unsafe, empty, or noise." - } - }, - "required": ["folderPath", "extractedFileCount", "skippedFileCount"], - "additionalProperties": false, - "title": "Unzip result", - "description": "Outcome of unzipping a workspace archive into a folder." - }, "FileUnzipResponse": { "type": "object", "properties": { @@ -5319,70 +10285,6 @@ } ] }, - "V2FileShare": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique share identifier." - }, - "token": { - "type": "string", - "description": "Server-generated token embedded in the public share URL." - }, - "url": { - "type": "string", - "format": "uri", - "description": "Public share URL.", - "examples": ["https://www.sim.ai/f/share-token-example"] - }, - "isActive": { - "type": "boolean", - "description": "Whether the public share currently resolves." - }, - "resourceType": { - "type": "string", - "enum": ["file", "folder"], - "description": "Kind of resource being shared." - }, - "resourceId": { - "type": "string", - "description": "Identifier of the shared resource." - }, - "authType": { - "type": "string", - "enum": ["public", "password", "email", "sso"], - "description": "How access to the share is gated." - }, - "hasPassword": { - "type": "boolean", - "description": "Whether a password is stored for this share." - }, - "allowedEmails": { - "type": "array", - "items": { - "type": "string", - "minLength": 1, - "maxLength": 320 - }, - "description": "Allowed addresses or @domain patterns for email and SSO shares." - } - }, - "required": [ - "id", - "token", - "url", - "isActive", - "resourceType", - "resourceId", - "authType", - "hasPassword", - "allowedEmails" - ], - "additionalProperties": false, - "title": "File share", - "description": "Public-safe share configuration for a workspace file." - }, "V2FileMetadata": { "type": "object", "properties": { @@ -6155,101 +11057,6 @@ } ] }, - "V2FileSearchResults": { - "type": "object", - "properties": { - "results": { - "type": "array", - "items": { - "type": "object", - "properties": { - "fileId": { - "type": "string", - "minLength": 1, - "maxLength": 128, - "pattern": "^[A-Za-z0-9_-]+$", - "description": "File the line belongs to." - }, - "lineNumber": { - "type": "integer", - "minimum": 1, - "maximum": 9007199254740991, - "description": "1-based line the match sits on." - }, - "text": { - "type": "string", - "description": "The matching line." - } - }, - "required": ["fileId", "lineNumber", "text"], - "additionalProperties": false - }, - "description": "Matching lines, one entry per line." - }, - "count": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Number of results returned." - }, - "truncated": { - "type": "boolean", - "description": "True when more matches exist beyond `maxResults`." - }, - "complete": { - "type": "boolean", - "description": "True when no files in the searched scope have pending or failed indexing. Missing matches remain inconclusive unless this is true and both `indexStatus.skippedFiles` and `indexStatus.partialFiles` are zero." - }, - "indexStatus": { - "type": "object", - "properties": { - "readyFiles": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Files whose current revision is indexed and searchable." - }, - "pendingFiles": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Files not yet indexed at their current revision. Their content was not searched." - }, - "failedFiles": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Files whose indexing failed. Their content was not searched." - }, - "skippedFiles": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Files deliberately not indexed, such as binaries and files above the size ceiling." - }, - "partialFiles": { - "type": "integer", - "minimum": 0, - "maximum": 9007199254740991, - "description": "Files indexed only in part, so matches beyond the indexed portion are not found." - } - }, - "required": [ - "readyFiles", - "pendingFiles", - "failedFiles", - "skippedFiles", - "partialFiles" - ], - "additionalProperties": false, - "description": "Index coverage across the searched scope." - } - }, - "required": ["results", "count", "truncated", "complete", "indexStatus"], - "additionalProperties": false, - "title": "File search results", - "description": "Matching lines from indexed workspace file content, with index coverage." - }, "V2FileSearchResultsResponse": { "type": "object", "properties": { diff --git a/apps/realtime/src/access-revalidation.ts b/apps/realtime/src/access-revalidation.ts index 95fb65ff1db..5905fd401ba 100644 --- a/apps/realtime/src/access-revalidation.ts +++ b/apps/realtime/src/access-revalidation.ts @@ -3,6 +3,7 @@ import { ROOM_MEMBERSHIP_ACTIONS, satisfiesRoomMembership } from '@sim/platform- import type { AccessRevokedBroadcast } from '@sim/realtime-protocol/events' import { FILE_DOC_EVENTS, type FileDocPermission } from '@sim/realtime-protocol/file-doc' import { + isProjectRoom, parseRoomName, projectFileDocTarget, ROOM_TYPES, @@ -329,7 +330,7 @@ export function startAccessRevalidationSweep(roomManager: IRoomManager): AccessR // resolution keeps running in the background and is re-raced when the // rotation returns to this socket, so it is acted on once it settles. const role = await Promise.race([ - room.type === ROOM_TYPES.PROJECT_FILE_DOC + isProjectRoom(room) ? resolveCurrentRoomPermission(userId, room, fallbackRoleFor(room.type), socket.id) : resolveCurrentRoomPermission(userId, room, fallbackRoleFor(room.type)), sleep(Math.min(SCAN_SOCKET_TIMEOUT_MS, remainingBudget)).then(() => SCAN_TIMED_OUT), diff --git a/apps/realtime/src/handlers/file-doc-app.ts b/apps/realtime/src/handlers/file-doc-app.ts index 85372d7cafa..546c032acfb 100644 --- a/apps/realtime/src/handlers/file-doc-app.ts +++ b/apps/realtime/src/handlers/file-doc-app.ts @@ -195,13 +195,14 @@ export async function fetchProjectFileDocAccess( /** Fetch a seed under the joining socket's current Project read access. */ export async function fetchProjectFileDocSeed( target: ProjectDocumentRequest -): Promise<{ update: Uint8Array; version: number }> { +): Promise<{ update: Uint8Array; version: number } | null> { const response = await postToApp( projectDocumentPath(target, 'seed'), {}, FILE_DOC_TIMEOUTS.seedRequestMs, target ) + if (response.status === 404) return null if (!response.ok) throw new Error(`Project document seed failed: ${response.status}`) const body = (await response.json()) as { update?: unknown; version?: unknown } if (typeof body.update !== 'string' || typeof body.version !== 'number') diff --git a/apps/realtime/src/handlers/file-doc-editor.integration.ts b/apps/realtime/src/handlers/file-doc-editor.integration.ts index efe7759ec92..ba2af1035b5 100644 --- a/apps/realtime/src/handlers/file-doc-editor.integration.ts +++ b/apps/realtime/src/handlers/file-doc-editor.integration.ts @@ -22,8 +22,11 @@ beforeAll(async () => { await store.init() }) -async function fixture() { - const name = `project-file-doc:${generateId()}/${generateId()}` +async function fixture(scope: 'project' | 'workspace' = 'project') { + const name = + scope === 'project' + ? `project-file-doc:${generateId()}/${generateId()}` + : `workspace-file-doc:${generateId()}` const doc = new Y.Doc() const generation = generateId() doc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.flag, true) @@ -75,16 +78,15 @@ describe('Project document editor attribution over real Redis', () => { } ) - check( - 'Project updates without an authenticated editor are rejected before acceptance', - async () => { - const f = await fixture() + for (const scope of ['workspace', 'project'] as const) { + check(`${scope} acknowledged updates require an authenticated editor`, async () => { + const f = await fixture(scope) await expect( store.publishClientUpdateAndWait(f.name, 'unknown', update(f.doc, 'X'), f.generation) - ).rejects.toThrow('Project document updates require an authenticated editor') + ).rejects.toThrow('Document updates require an authenticated editor') expect(await redis.xLen(`filedoc:stream:${f.name}`)).toBe(1) - } - ) + }) + } check( 'real compaction preserves the editor for a headless replica reconstructing accepted bytes', diff --git a/apps/realtime/src/handlers/file-doc-owner.ts b/apps/realtime/src/handlers/file-doc-owner.ts new file mode 100644 index 00000000000..6a6502bb771 --- /dev/null +++ b/apps/realtime/src/handlers/file-doc-owner.ts @@ -0,0 +1,76 @@ +import type { FileDocOwner, FileDocTarget } from '@sim/realtime-protocol/file-doc-target' +import { + fetchFileDocPersist, + fetchFileDocSeed, + fetchProjectFileDocPersist, + fetchProjectFileDocSeed, + type PersistResult, +} from '@/handlers/file-doc-app' +import type { FileDocEditor } from '@/handlers/file-doc-store' + +interface PersistenceActor { + userId: string + connectionId: string | null +} + +interface FileDocOwnerAdapter { + requiresCurrentActor: boolean + tracksLifecycle: boolean + seed( + target: FileDocTarget, + actor?: FileDocEditor + ): Promise<{ update: Uint8Array; version: number } | null> + persist( + target: FileDocTarget, + actor: PersistenceActor, + state: Uint8Array, + version?: number + ): Promise +} + +function requireOwner(target: FileDocTarget, entityType: FileDocOwner['entityType']): string { + if (!target.owner || target.owner.entityType !== entityType || !target.owner.entityId) + throw new Error('Document has no canonical owner context') + return target.owner.entityId +} + +function projectRequest(target: FileDocTarget, actor?: PersistenceActor) { + if (!actor?.userId || !actor.connectionId) + throw new Error('Document callback requires an authenticated editor') + return { + projectId: requireOwner(target, 'project'), + fileId: target.fileId, + userId: actor.userId, + connectionId: actor.connectionId, + } +} + +const OWNER_ADAPTERS: Record = { + workspace: { + requiresCurrentActor: false, + tracksLifecycle: false, + seed: (target) => fetchFileDocSeed(requireOwner(target, 'workspace'), target.fileId), + persist: (target, actor, state, version) => + fetchFileDocPersist( + requireOwner(target, 'workspace'), + target.fileId, + actor.userId, + state, + version + ), + }, + project: { + requiresCurrentActor: true, + tracksLifecycle: true, + seed: (target, actor) => fetchProjectFileDocSeed(projectRequest(target, actor)), + persist: (target, actor, state, version) => + fetchProjectFileDocPersist(projectRequest(target, actor), state, version), + }, +} + +/** Select callbacks only after the document's canonical owner has been resolved. */ +export function fileDocOwnerAdapter(owner: FileDocOwner): FileDocOwnerAdapter { + const type = owner.entityType + if (!Object.hasOwn(OWNER_ADAPTERS, type)) throw new Error('Unsupported document owner') + return OWNER_ADAPTERS[type] +} diff --git a/apps/realtime/src/handlers/file-doc-project.integration.ts b/apps/realtime/src/handlers/file-doc-project.integration.ts index 6b5e3f8531a..94c3023b3f9 100644 --- a/apps/realtime/src/handlers/file-doc-project.integration.ts +++ b/apps/realtime/src/handlers/file-doc-project.integration.ts @@ -1,6 +1,7 @@ import { mkdir, writeFile } from 'node:fs/promises' import { createServer, type Server as HttpServer } from 'node:http' import { dirname, resolve } from 'node:path' +import { createLogger } from '@sim/logger' import { ROOM_ACCESS_REVOKED_EVENT, type RoomAccessRevokedBroadcast, @@ -38,6 +39,7 @@ import { ROLE_REVALIDATION_TTL_MS, } from '@/middleware/permissions' import { MemoryRoomManager } from '@/rooms' +import { createHttpHandler } from '@/routes/http' const projectId = generateId() const fileId = generateId() @@ -57,6 +59,7 @@ let http: HttpServer let io: Server let socketUrl: string const capturedActors: string[] = [] +const seedFailures = new Map() const accessGates = new Map< string, { @@ -114,6 +117,11 @@ beforeAll(async () => { return } if (request.url.endsWith('/seed')) { + const failure = seedFailures.get(actor) + if (failure) { + response.writeHead(failure).end('{}') + return + } response.end(JSON.stringify({ update: seed, version: 1 })) return } @@ -132,6 +140,7 @@ beforeAll(async () => { http = createServer() io = new Server(http) const manager = new MemoryRoomManager(io) + http.on('request', createHttpHandler(manager, createLogger('OwnerProtocolFixture'))) io.on('connection', (socket) => { const authed = socket as AuthenticatedSocket authed.userId = socket.handshake.auth.actor @@ -143,7 +152,7 @@ beforeAll(async () => { socketUrl = await listen(http) }) -async function startJoin(actor: string, assertedProject = projectId) { +async function startJoin(actor: string, assertedProject = projectId, owner?: unknown) { const socket = connect(socketUrl, { transports: ['websocket'], auth: { actor } }) clients.push(socket) await new Promise((resolve) => socket.once('connect', resolve)) @@ -156,15 +165,15 @@ async function startJoin(actor: string, assertedProject = projectId) { client.destroy() socket.emit(FILE_DOC_EVENTS.JOIN, { fileId, - projectId: assertedProject, + ...(owner === undefined ? { projectId: assertedProject } : { owner }), clientId, schemaVersion: FILE_DOC_SCHEMA_VERSION, }) return { socket, joined } } -async function join(actor: string, assertedProject = projectId) { - const pending = await startJoin(actor, assertedProject) +async function join(actor: string, assertedProject = projectId, owner?: unknown) { + const pending = await startJoin(actor, assertedProject, owner) return { socket: pending.socket, joined: await pending.joined } } @@ -187,6 +196,29 @@ function check(name: string, run: () => Promise) { } describe('Project documents across the Socket.IO and internal HTTP boundary', () => { + for (const status of [404, 503]) { + check( + `a seed response of ${status} distinguishes a missing file from a failed join`, + async () => { + const actor = `seed-reader-${generateId()}` + actors.set(actor, 'read') + seedFailures.set(actor, status) + const pending = await startJoin(actor) + try { + await expect(pending.joined).rejects.toThrow(status === 404 ? 'NOT_FOUND' : 'JOIN_FAILED') + expect( + io.sockets.sockets + .get(pending.socket.id ?? '') + ?.rooms.has(`project-file-doc:${projectId}/${fileId}`) + ).toBe(false) + } finally { + seedFailures.delete(actor) + pending.socket.disconnect() + } + } + ) + } + check('readers subscribe but cannot submit edits or become the persistence actor', async () => { const reader = await join('reader') expect(reader.joined.canWrite).toBe(false) @@ -350,12 +382,15 @@ describe('Project documents across the Socket.IO and internal HTTP boundary', () try { socket.emit(FILE_DOC_EVENTS.JOIN, { fileId, - projectId, + owner: { entityType: 'project', entityId: projectId }, clientId: provider.clientID, schemaVersion: FILE_DOC_SCHEMA_VERSION, }) await gate.entered.promise - const otherOwner = { fileId, projectId: generateId() } + const otherOwner = { + fileId, + owner: { entityType: 'project' as const, entityId: generateId() }, + } if (action === 'join') socket.emit(FILE_DOC_EVENTS.JOIN, { ...otherOwner, @@ -501,6 +536,44 @@ describe('Project documents across the Socket.IO and internal HTTP boundary', () } ) + check( + 'owner-shaped clients share the existing room and preserve editor attribution', + async () => { + const owner = { entityType: 'project', entityId: projectId } + const writer = await join('writer', projectId, owner) + expect(writer.joined.docId).toBe(docId) + const edit = new Y.Doc() + Y.applyUpdate(edit, Buffer.from(seed, 'base64')) + edit.getText('body').insert(0, 'owner-shaped edit') + const reply = await writer.socket.timeout(1500).emitWithAck(FILE_DOC_EVENTS.UPDATE, { + fileId, + owner, + docId, + updateId: generateId(), + update: Y.encodeStateAsUpdate(edit), + }) + expect(reply).toMatchObject({ status: 'accepted' }) + await flushAllFileDocRooms() + expect(capturedActors.at(-1)).toBe('writer') + edit.destroy() + await expect(join('reader', projectId, { ...owner, entityId: generateId() })).rejects.toThrow( + 'NOT_FOUND' + ) + await expect( + join('reader', projectId, { entityType: 'organization', entityId: projectId }) + ).rejects.toThrow('INVALID_PAYLOAD') + const mismatched = await writer.socket.timeout(1500).emitWithAck(FILE_DOC_EVENTS.UPDATE, { + fileId, + owner, + projectId: generateId(), + docId, + updateId: generateId(), + update: Y.encodeStateAsUpdate(document), + }) + expect(mismatched).toMatchObject({ status: 'rejected', code: 'INVALID_UPDATE' }) + } + ) + check( 'callback outages preserve membership and acknowledge writes as retryable; revocations still evict', async () => { @@ -677,6 +750,52 @@ describe('Project documents across the Socket.IO and internal HTTP boundary', () expect(restored.joined.docId).toBe(docId) } ) + + check( + 'HTTP owner targeting fences the same generation and refuses unsupported or conflicting owners', + async () => { + const owner = { entityType: 'project', entityId: projectId } + async function post(action: string, body: object) { + return fetch(`${socketUrl}/api/file-doc/${action}`, { + method: 'POST', + headers: { 'content-type': 'application/json', 'x-api-key': env.INTERNAL_API_SECRET }, + body: JSON.stringify(body), + }) + } + for (const action of ['apply-edit', 'invalidate', 'retire']) { + const body = { + fileId, + owner, + markdown: '', + version: 100, + retiredDocId: docId, + replacementDocId: generateId(), + } + expect((await post(action, { ...body, projectId: generateId() })).status).toBe(400) + expect( + ( + await post(action, { + ...body, + owner: { entityType: 'organization', entityId: projectId }, + }) + ).status + ).toBe(400) + } + const joined = await join('reader', projectId, owner) + const invalidation = new Promise<{ docId: string }>((resolve) => + joined.socket.once(FILE_DOC_EVENTS.INVALIDATED, resolve) + ) + const response = await post('retire', { + fileId, + owner, + retiredDocId: docId, + replacementDocId: generateId(), + }) + expect(response.status).toBe(200) + expect(await response.json()).toEqual({ status: 'applied' }) + expect((await invalidation).docId).toBe(docId) + } + ) }) afterAll(async () => { diff --git a/apps/realtime/src/handlers/file-doc-store.test.ts b/apps/realtime/src/handlers/file-doc-store.test.ts index ba2b21ad6a1..3a15ec102d9 100644 --- a/apps/realtime/src/handlers/file-doc-store.test.ts +++ b/apps/realtime/src/handlers/file-doc-store.test.ts @@ -461,7 +461,13 @@ describe('FileDocStore', () => { 'replaced by a newer durable version' ) await expect( - store.publishClientUpdateAndWait(NAME, 'stale-update', updateFor('stale acknowledged write')) + store.publishClientUpdateAndWait( + NAME, + 'stale-update', + updateFor('stale acknowledged write'), + undefined, + { userId: 'editor', connectionId: 'socket-editor' } + ) ).rejects.toThrow('replaced by a newer durable version') const fresh = seedFor('fresh generation') @@ -488,15 +494,24 @@ describe('FileDocStore', () => { await store.seedIfEmpty(NAME, seedFor('base'), 10) const generation = await store.getDocumentGeneration(NAME) const delta = updateFor('edit') - await store.publishClientUpdateAndWait(NAME, 'accepted-update', delta, generation) + await store.publishClientUpdateAndWait(NAME, 'accepted-update', delta, generation, { + userId: 'editor', + connectionId: 'socket-editor', + }) state.backing!.streams.delete(`filedoc:stream:${NAME}`) await expect(store.publishAndWait(NAME, delta, generation)).rejects.toThrow('replaced') await expect( - store.publishClientUpdateAndWait(NAME, 'new-update', delta, generation) + store.publishClientUpdateAndWait(NAME, 'new-update', delta, generation, { + userId: 'editor', + connectionId: 'socket-editor', + }) ).rejects.toThrow('replaced') await expect( - store.publishClientUpdateAndWait(NAME, 'accepted-update', delta, generation) + store.publishClientUpdateAndWait(NAME, 'accepted-update', delta, generation, { + userId: 'editor', + connectionId: 'socket-editor', + }) ).rejects.toThrow('replaced') expect(state.backing!.streams.has(`filedoc:stream:${NAME}`)).toBe(false) }) @@ -753,8 +768,14 @@ describe('FileDocStore', () => { const store = await newStore() const update = updateFor('retry-safe') - await store.publishClientUpdateAndWait(NAME, 'update-1', update) - await store.publishClientUpdateAndWait(NAME, 'update-1', update) + await store.publishClientUpdateAndWait(NAME, 'update-1', update, undefined, { + userId: 'editor', + connectionId: 'socket-editor', + }) + await store.publishClientUpdateAndWait(NAME, 'update-1', update, undefined, { + userId: 'editor', + connectionId: 'socket-editor', + }) expect(state.backing!.streams.get(`filedoc:stream:${NAME}`)).toHaveLength(2) }) @@ -763,8 +784,14 @@ describe('FileDocStore', () => { seedLegacyStream() const store = await newStore() - await store.publishClientUpdateAndWait(NAME, 'update-1', updateFor('first')) - await store.publishClientUpdateAndWait(NAME, 'update-1', updateFor('second')) + await store.publishClientUpdateAndWait(NAME, 'update-1', updateFor('first'), undefined, { + userId: 'editor', + connectionId: 'socket-editor', + }) + await store.publishClientUpdateAndWait(NAME, 'update-1', updateFor('second'), undefined, { + userId: 'editor', + connectionId: 'socket-editor', + }) expect(state.backing!.streams.get(`filedoc:stream:${NAME}`)).toHaveLength(3) }) @@ -814,7 +841,10 @@ describe('FileDocStore', () => { state.backing!.onSnapshot = async () => { await replacer.invalidateDocument(NAME, 20) await replacer.seedIfEmpty(NAME, freshSeed, 20) - await replacer.publishClientUpdateAndWait(NAME, 'fresh-edit', freshEdit, 'new-generation') + await replacer.publishClientUpdateAndWait(NAME, 'fresh-edit', freshEdit, 'new-generation', { + userId: 'editor', + connectionId: 'socket-editor', + }) expect(state.backing!.streams.get(streamKey)?.map((entry) => entry.id)).toEqual([ '1000-0', '1000-1', @@ -827,7 +857,10 @@ describe('FileDocStore', () => { '1000-0', '1000-1', ]) - await replacer.publishClientUpdateAndWait(NAME, 'fresh-edit', freshEdit, 'new-generation') + await replacer.publishClientUpdateAndWait(NAME, 'fresh-edit', freshEdit, 'new-generation', { + userId: 'editor', + connectionId: 'socket-editor', + }) expect(state.backing!.streams.get(streamKey)).toHaveLength(2) const persisted = await replacer.getStreamState(NAME) expect(persisted).not.toBeNull() @@ -846,7 +879,10 @@ describe('FileDocStore', () => { await expect(store.attachRoom(NAME, doc)).rejects.toThrow('not initialized') await expect( - store.publishClientUpdateAndWait(NAME, 'update-1', updateFor('x')) + store.publishClientUpdateAndWait(NAME, 'update-1', updateFor('x'), undefined, { + userId: 'editor', + connectionId: 'socket-editor', + }) ).rejects.toThrow('not initialized') await expect(store.seedIfEmpty(NAME, seedFor('seed'))).rejects.toThrow('not initialized') await expect(store.getStreamState(NAME)).rejects.toThrow('not initialized') diff --git a/apps/realtime/src/handlers/file-doc-store.ts b/apps/realtime/src/handlers/file-doc-store.ts index b8fe6e8f3b0..e0aa9cd1e53 100644 --- a/apps/realtime/src/handlers/file-doc-store.ts +++ b/apps/realtime/src/handlers/file-doc-store.ts @@ -608,11 +608,8 @@ export class FileDocStore { expectedGeneration = this.rooms.get(name)?.generation ?? '', editor?: FileDocEditor ): Promise { - if ( - name.startsWith('project-file-doc:') && - (!editor?.userId.trim() || !editor.connectionId.trim()) - ) { - throw new Error('Project document updates require an authenticated editor') + if (!editor?.userId.trim() || !editor.connectionId.trim()) { + throw new Error('Document updates require an authenticated editor') } if (!this.enabled) return if (!this.write) throw new Error('FileDocStore is not initialized') diff --git a/apps/realtime/src/handlers/file-doc.multireplica.test.ts b/apps/realtime/src/handlers/file-doc.multireplica.test.ts index e8fb74d7dd7..d78cbb84c19 100644 --- a/apps/realtime/src/handlers/file-doc.multireplica.test.ts +++ b/apps/realtime/src/handlers/file-doc.multireplica.test.ts @@ -62,9 +62,11 @@ describe('applyMarkdownToLiveFileDoc — multi-replica (store-enabled) ordering' it('drops a stale durable write against the SHARED synced version', async () => { // A durable write (e.g. a concurrent human save on another process) records the shared synced version. - expect(await applyMarkdownToLiveFileDoc('file-1', '# durable', { version: 100 })).toBe( - 'applied' - ) + expect( + await applyMarkdownToLiveFileDoc({ type: 'workspace-file-doc', id: 'file-1' }, '# durable', { + version: 100, + }) + ).toBe('applied') expect(fakeStore.setSyncedVersion).toHaveBeenCalledWith(ROOM_NAME, 100, 'shared-generation') expect(fakeStore.getStreamState).toHaveBeenCalledWith(ROOM_NAME, 'shared-generation') expect(fakeStore.publishAndWait).toHaveBeenCalledWith( @@ -76,15 +78,23 @@ describe('applyMarkdownToLiveFileDoc — multi-replica (store-enabled) ordering' // A durable write with an OLDER version than the SHARED synced version is stale — rejected under the // lock before any diff is built, so it can't regress the doc across replicas. - expect(await applyMarkdownToLiveFileDoc('file-1', '# older durable', { version: 50 })).toBe( - 'stale' - ) + expect( + await applyMarkdownToLiveFileDoc( + { type: 'workspace-file-doc', id: 'file-1' }, + '# older durable', + { version: 50 } + ) + ).toBe('stale') expect(mockFetchFileDocMerge).not.toHaveBeenCalled() // A newer durable write applies and advances the shared synced version. - expect(await applyMarkdownToLiveFileDoc('file-1', '# durable again', { version: 150 })).toBe( - 'applied' - ) + expect( + await applyMarkdownToLiveFileDoc( + { type: 'workspace-file-doc', id: 'file-1' }, + '# durable again', + { version: 150 } + ) + ).toBe('applied') expect(fakeStore.setSyncedVersion).toHaveBeenCalledWith(ROOM_NAME, 150, 'shared-generation') // setSyncedVersion fired only for the two applied durable writes, never for the stale one. expect(fakeStore.setSyncedVersion).toHaveBeenCalledTimes(2) @@ -98,7 +108,11 @@ describe('applyMarkdownToLiveFileDoc — multi-replica (store-enabled) ordering' fakeStore.isAgentStreaming.mockResolvedValue(true) expect( - await applyMarkdownToLiveFileDoc('file-1', '# streamed by a client', { version: 100 }) + await applyMarkdownToLiveFileDoc( + { type: 'workspace-file-doc', id: 'file-1' }, + '# streamed by a client', + { version: 100 } + ) ).toBe('applied') expect(mockFetchFileDocMerge).not.toHaveBeenCalled() // content deferred to the client expect(fakeStore.publishAndWait).not.toHaveBeenCalled() @@ -106,9 +120,13 @@ describe('applyMarkdownToLiveFileDoc — multi-replica (store-enabled) ordering' // Once streaming stops the flag clears and the (now near-noop) durable merge resumes normally. fakeStore.isAgentStreaming.mockResolvedValue(false) - expect(await applyMarkdownToLiveFileDoc('file-1', '# final durable', { version: 150 })).toBe( - 'applied' - ) + expect( + await applyMarkdownToLiveFileDoc( + { type: 'workspace-file-doc', id: 'file-1' }, + '# final durable', + { version: 150 } + ) + ).toBe('applied') expect(mockFetchFileDocMerge).toHaveBeenCalledTimes(1) }) }) diff --git a/apps/realtime/src/handlers/file-doc.test.ts b/apps/realtime/src/handlers/file-doc.test.ts index c7510db74f0..5d316725b91 100644 --- a/apps/realtime/src/handlers/file-doc.test.ts +++ b/apps/realtime/src/handlers/file-doc.test.ts @@ -4,6 +4,7 @@ import { FILE_DOC_SCHEMA_VERSION, FILE_DOC_SEED, } from '@sim/realtime-protocol/file-doc' +import { fileDocAdmissionRoom } from '@sim/realtime-protocol/file-doc-target' import { ROOM_TYPES } from '@sim/realtime-protocol/rooms' import { flushMicrotasks } from '@sim/testing/helpers' import { sleep } from '@sim/utils/helpers' @@ -36,7 +37,6 @@ vi.mock('@/handlers/file-doc-app', () => ({ import { applyMarkdownToLiveFileDoc, cleanupFileDocForSocket, - fileDocAdmissionRoom, flushAllFileDocRooms, invalidateLiveFileDocument, setupWorkspaceFileDocHandlers, @@ -375,7 +375,8 @@ describe('setupWorkspaceFileDocHandlers', () => { source.getText(FILE_DOC_FIELD).insert(0, 'Stale text') const acknowledge = vi.fn() try { - if (timing === 'before append') await invalidateLiveFileDocument('file-1', 2) + if (timing === 'before append') + await invalidateLiveFileDocument({ type: 'workspace-file-doc', id: 'file-1' }, 2) sent.length = 0 handlers[FILE_DOC_EVENTS.UPDATE]( { @@ -388,7 +389,7 @@ describe('setupWorkspaceFileDocHandlers', () => { ) if (timing !== 'before append') { expect(publish).toHaveBeenCalledTimes(1) - await invalidateLiveFileDocument('file-1', 2) + await invalidateLiveFileDocument({ type: 'workspace-file-doc', id: 'file-1' }, 2) if (timing === 'during append and reseed') { mockFetchFileDocSeed.mockResolvedValue({ ...seedResult('Replacement', 'doc-new'), @@ -793,7 +794,7 @@ describe('setupWorkspaceFileDocHandlers', () => { } finishSubscription() await joining - expect(memberships.has(fileDocAdmissionRoom('file-1'))).toBe(false) + expect(memberships.has(fileDocAdmissionRoom({ fileId: 'file-1' }))).toBe(false) expect(memberships.has(ROOM_NAME)).toBe(access === 'unchanged') if (access === 'unchanged') { expect(joinSuccessFileId(pending.socket)).toBe('file-1') @@ -948,19 +949,31 @@ describe('setupWorkspaceFileDocHandlers', () => { mockFetchFileDocMerge.mockResolvedValue(Y.encodeStateAsUpdate(new Y.Doc())) // A newer durable version lands and is recorded as the synced version. - expect(await applyMarkdownToLiveFileDoc('file-1', '# newer', { version: 100 })).toBe('applied') + expect( + await applyMarkdownToLiveFileDoc({ type: 'workspace-file-doc', id: 'file-1' }, '# newer', { + version: 100, + }) + ).toBe('applied') mockFetchFileDocMerge.mockClear() // An older durable version arriving out of order (e.g. a concurrent write on another process) is // stale: skipped before any diff is computed, so the live doc never regresses to older content and // no diff is published that a later persist could write back. - expect(await applyMarkdownToLiveFileDoc('file-1', '# older, stale', { version: 50 })).toBe( - 'stale' - ) + expect( + await applyMarkdownToLiveFileDoc( + { type: 'workspace-file-doc', id: 'file-1' }, + '# older, stale', + { version: 50 } + ) + ).toBe('stale') // The same version is idempotent — also skipped. - expect(await applyMarkdownToLiveFileDoc('file-1', '# same version', { version: 100 })).toBe( - 'stale' - ) + expect( + await applyMarkdownToLiveFileDoc( + { type: 'workspace-file-doc', id: 'file-1' }, + '# same version', + { version: 100 } + ) + ).toBe('stale') expect(mockFetchFileDocMerge).not.toHaveBeenCalled() }) @@ -978,8 +991,8 @@ describe('setupWorkspaceFileDocHandlers', () => { .mockReturnValueOnce(new Promise((resolve) => (resolveFirst = resolve))) .mockResolvedValueOnce(noOpUpdate) - const first = applyMarkdownToLiveFileDoc('file-1', '# One') - const second = applyMarkdownToLiveFileDoc('file-1', '# Two') + const first = applyMarkdownToLiveFileDoc({ type: 'workspace-file-doc', id: 'file-1' }, '# One') + const second = applyMarkdownToLiveFileDoc({ type: 'workspace-file-doc', id: 'file-1' }, '# Two') await flushMicrotasks(SEED_CHAIN_TICKS) expect(mockFetchFileDocMerge).toHaveBeenCalledTimes(1) // second is queued behind the first diff --git a/apps/realtime/src/handlers/file-doc.ts b/apps/realtime/src/handlers/file-doc.ts index 418f7b81b2b..611cdf1f2f5 100644 --- a/apps/realtime/src/handlers/file-doc.ts +++ b/apps/realtime/src/handlers/file-doc.ts @@ -42,12 +42,15 @@ import { toFileDocBytes, } from '@sim/realtime-protocol/file-doc' import { - projectFileDocRoom, - projectFileDocTarget, - ROOM_TYPES, - type RoomRef, - roomName, -} from '@sim/realtime-protocol/rooms' + type FileDocOwner, + type FileDocTarget, + fileDocAdmissionRoom, + fileDocOwnerWireFields, + fileDocRoom, + fileDocTargetFromRoom, + parseFileDocTarget, +} from '@sim/realtime-protocol/file-doc-target' +import { ROOM_TYPES, type RoomRef, roomName } from '@sim/realtime-protocol/rooms' import { getErrorMessage } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' import * as decoding from 'lib0/decoding' @@ -57,13 +60,8 @@ import * as awarenessProtocol from 'y-protocols/awareness' import * as syncProtocol from 'y-protocols/sync' import * as Y from 'yjs' import { resolveAvatarUrl } from '@/handlers/avatar' -import { - fetchFileDocMerge, - fetchFileDocPersist, - fetchFileDocSeed, - fetchProjectFileDocPersist, - fetchProjectFileDocSeed, -} from '@/handlers/file-doc-app' +import { fetchFileDocMerge } from '@/handlers/file-doc-app' +import { fileDocOwnerAdapter } from '@/handlers/file-doc-owner' import { type FileDocEditor, FileDocInvalidatedError, @@ -123,7 +121,7 @@ const MERGE_LOCK_RETRIES = Math.ceil( const AGENT_STREAM_FLAG_TTL_MS = 10_000 /** One presence ownership within a room: a (socket, clientID) pair. */ -interface FileDocOwner { +interface FileDocPresenceOwner { /** * An awareness clientID this socket declared at join. The socket may only publish/remove awareness * for a clientID it owns, so an authenticated peer cannot forge or clear another collaborator's @@ -146,13 +144,13 @@ interface FileDocOwner { interface FileDocRoom { /** The `workspace_files.id` this room edits. */ fileId: string - projectId?: string + owner: FileDocOwner lastEditorConnectionId: string | null doc: Y.Doc awareness: awarenessProtocol.Awareness /** socketId → (clientId → its presence ownership). A socket owns one entry per collaborative provider - * it mounted for this file (see {@link FileDocOwner}); an empty inner map is never kept. */ - owners: Map> + * it mounted for this file (see {@link FileDocPresenceOwner}); an empty inner map is never kept. */ + owners: Map> /** * The in-flight server seed for this room, or `null`. Concurrent joins await THIS promise rather * than each starting a fetch — and, unlike a "started" boolean, awaiting it is what lets a second @@ -160,8 +158,6 @@ interface FileDocRoom { * settles, so a failed seed is re-attempted by a later join (a genuinely empty file stays empty). */ seeding: Promise | null - /** The workspace this file belongs to, captured at join — needed to persist back to markdown. */ - workspaceId: string | null /** The last collaborator to edit here, for persist attribution (blob metadata) only. */ lastEditorUserId: string | null /** @@ -238,19 +234,6 @@ interface AwarenessChange { removed: number[] } -const fileDocRoom = (fileId: string, projectId?: string): RoomRef => - projectId - ? projectFileDocRoom(projectId, fileId) - : { - type: ROOM_TYPES.WORKSPACE_FILE_DOC, - id: fileId, - } - -/** Pending admissions receive invalidations here, never document or presence frames. */ -export function fileDocAdmissionRoom(fileId: string, projectId?: string): string { - return `file-doc-admission:${projectId ? `${projectId}/` : ''}${fileId}` -} - /** * A `y-protocols` transaction/awareness origin is the emitting socket id (a * string) when it came from a client, and something else (`null` / `'local'` / @@ -347,9 +330,9 @@ function broadcastLocal( */ function schedulePersist(name: string, room: FileDocRoom): void { if ( - room.projectId + fileDocOwnerAdapter(room.owner).requiresCurrentActor ? !getFileDocStore().enabled && !room.lastEditorConnectionId - : !room.workspaceId || !room.lastEditorUserId + : !room.owner || !room.lastEditorUserId ) return const now = Date.now() @@ -389,20 +372,19 @@ async function persistRoom(name: string, room: FileDocRoom, final: boolean): Pro // Never project a doc no user actually edited back over the file (see {@link FileDocRoom.edited}). if ( !room.edited || - (room.projectId + (fileDocOwnerAdapter(room.owner).requiresCurrentActor ? !getFileDocStore().enabled && !room.lastEditorConnectionId - : !room.workspaceId || !room.lastEditorUserId) + : !room.owner || !room.lastEditorUserId) ) return const store = getFileDocStore() const generation = docIdOf(room.doc) - const workspaceId = room.workspaceId const userId = room.lastEditorUserId const localEditor: FileDocEditor | null = userId && room.lastEditorConnectionId ? { userId, connectionId: room.lastEditorConnectionId } : null - let projectEditor = localEditor + let snapshotEditor = localEditor // Synchronous fallback capture — before any await, since the caller may destroy `room.doc` the moment // this yields. Only meaningful once seeded; used only when the authoritative stream state is absent. const localState = isDocSeeded(room.doc) ? Y.encodeStateAsUpdate(room.doc) : null @@ -417,26 +399,30 @@ async function persistRoom(name: string, room: FileDocRoom, final: boolean): Pro // would let the If-Match pass and clobber the reconciled edit). Fall back to that snapshot once the // room is torn down (last-leave), where the doc is gone and there is nothing left to reconcile. if (fileDocRooms.get(name) === room && isDocSeeded(room.doc)) { - if (room.projectId) { - projectEditor = + if (fileDocOwnerAdapter(room.owner).requiresCurrentActor) { + snapshotEditor = room.lastEditorUserId && room.lastEditorConnectionId ? { userId: room.lastEditorUserId, connectionId: room.lastEditorConnectionId } : null } return Y.encodeStateAsUpdate(room.doc) } - if (room.projectId) projectEditor = localEditor + if (fileDocOwnerAdapter(room.owner).requiresCurrentActor) snapshotEditor = localEditor return localState } try { - if (room.projectId) { + if (fileDocOwnerAdapter(room.owner).requiresCurrentActor) { const snapshot = await store.getStreamSnapshot(name, generation) - projectEditor = snapshot?.editor ?? null + snapshotEditor = snapshot?.editor ?? null return snapshot?.docState ?? null } return (await store.getStreamState(name)) ?? localState } catch (streamError) { - if (room.projectId || streamError instanceof FileDocInvalidatedError) throw streamError + if ( + fileDocOwnerAdapter(room.owner).requiresCurrentActor || + streamError instanceof FileDocInvalidatedError + ) + throw streamError // A transient Redis read must NOT drop the write when we already hold a valid local snapshot — // else the last-disconnect flush loses the session's edits as the room is torn down. But once a // reconcile has run, `localState` is NULLED (it predates the merged-in out-of-band edit), so a @@ -487,17 +473,15 @@ async function persistRoom(name: string, room: FileDocRoom, final: boolean): Pro const docState = await captureState() if (!docState) return // nothing seeded/authoritative to persist yet if (!(await store.isDocumentGenerationCurrent(name, generation))) return - if (room.projectId && !projectEditor) return - const result = - room.projectId && projectEditor - ? await fetchProjectFileDocPersist( - { projectId: room.projectId, fileId: room.fileId, ...projectEditor }, - docState, - ifMatch - ) - : workspaceId && userId - ? await fetchFileDocPersist(workspaceId, room.fileId, userId, docState, ifMatch) - : null + if (fileDocOwnerAdapter(room.owner).requiresCurrentActor && !snapshotEditor) return + const actor = fileDocOwnerAdapter(room.owner).requiresCurrentActor + ? snapshotEditor + : userId + ? { userId, connectionId: room.lastEditorConnectionId } + : null + const result = actor + ? await fileDocOwnerAdapter(room.owner).persist(room, actor, docState, ifMatch) + : null if (!result) return if (result.status === 'missing') return // the file was deleted; nothing to write if (result.status === 'deferred') { @@ -659,16 +643,14 @@ export async function flushAllFileDocRooms(): Promise { async function ensureRoomReady( name: string, room: FileDocRoom, - workspaceId: string | null, actor?: FileDocEditor ): Promise { await room.hydrated // The room can be dropped and re-created while the catch-up is in flight (a fast open→close); the // join re-checks identity after this and abandons a stale room rather than serving from it. if (fileDocRooms.get(name) !== room) return - if (!room.projectId && !workspaceId) - throw new Error(`File document ${room.fileId} has no owner context`) - await ensureServerSeed(name, room, workspaceId, actor) + if (!room.owner) throw new Error(`File document ${room.fileId} has no owner context`) + await ensureServerSeed(name, room, actor) if (fileDocRooms.get(name) === room && !isDocSeeded(room.doc)) { throw new Error(`File document ${room.fileId} could not be seeded`) } @@ -679,14 +661,9 @@ async function ensureRoomReady( * Clear settled attempts to allow retry after transient failures. Existing empty files have a named * seed; a missing file must fail admission rather than create an editable blank room. */ -function ensureServerSeed( - name: string, - room: FileDocRoom, - workspaceId: string | null, - actor?: FileDocEditor -): Promise { +function ensureServerSeed(name: string, room: FileDocRoom, actor?: FileDocEditor): Promise { if (isDocSeeded(room.doc)) return Promise.resolve() - room.seeding ??= runServerSeed(name, room, workspaceId, actor).finally(() => { + room.seeding ??= runServerSeed(name, room, actor).finally(() => { room.seeding = null }) return room.seeding @@ -707,7 +684,6 @@ class FileDocNotFoundError extends Error {} async function runServerSeed( name: string, room: FileDocRoom, - workspaceId: string | null, actor?: FileDocEditor ): Promise { const store = getFileDocStore() @@ -717,7 +693,7 @@ async function runServerSeed( // fix for split-brain seeding). Returns a lock token here (single-pod: a sentinel token). const token = await store.shouldSeed(name) if (token) { - await seedUnderLock(name, room, workspaceId, token, actor) + await seedUnderLock(name, room, token, actor) return } // No token: a peer holds the lock with its fetch in flight, or the stream is already seeded (which @@ -733,32 +709,25 @@ async function runServerSeed( async function seedUnderLock( name: string, room: FileDocRoom, - workspaceId: string | null, token: string, actor?: FileDocEditor ): Promise { const store = getFileDocStore() // Release the lock on EVERY exit from here (one `finally`, impossible to leak). try { - const seed = - room.projectId && actor - ? await fetchProjectFileDocSeed({ - projectId: room.projectId, - fileId: room.fileId, - ...actor, - }) - : workspaceId - ? await fetchFileDocSeed(workspaceId, room.fileId) - : null + const seed = await fileDocOwnerAdapter(room.owner).seed(room, actor) if (fileDocRooms.get(name) !== room || isDocSeeded(room.doc)) return if (!seed) throw new FileDocNotFoundError('File not found') /** * Publish before local apply: the atomic empty-stream append, not the expiring lock, prevents * independent Yjs histories from entering the same room. */ - const didSeed = room.projectId - ? await store.seedIfEmpty(name, seed.update, seed.version, true) - : await store.seedIfEmpty(name, seed.update, seed.version) + const didSeed = await store.seedIfEmpty( + name, + seed.update, + seed.version, + fileDocOwnerAdapter(room.owner).tracksLifecycle + ) /** * Record only our winning seed's version before the readiness guard: the tailer may already have * applied it during the append, but persistence still needs the matching local version. @@ -778,7 +747,7 @@ async function seedUnderLock( } } catch (error) { if (error instanceof FileDocNotFoundError) throw error - logger.warn(`Server seed failed for file ${room.fileId} (workspace ${workspaceId})`, error) + logger.warn(`Server seed failed for file ${room.fileId}`, error) } finally { await store.releaseSeedLock(name, token) } @@ -819,12 +788,14 @@ interface MergeOrder { * reconcile treats it distinctly (retry later) rather than as "nothing to reconcile into". */ export function applyMarkdownToLiveFileDoc( - fileId: string, + ref: RoomRef, markdown: string, - order: MergeOrder = {}, - projectId?: string + order: MergeOrder = {} ): Promise<'applied' | 'no-live-room' | 'merge-unavailable' | 'stale'> { - const name = roomName(fileDocRoom(fileId, projectId)) + const address = fileDocTargetFromRoom(ref) + if (!address) throw new Error('Invalid file document room') + const { fileId } = address + const name = roomName(ref) return serializeFileDocMutation(name, () => mergeMarkdownIntoRoom(name, fileId, markdown, order)) } @@ -852,11 +823,11 @@ async function acquireFileDocMergeSlot(name: string): Promise { /** Serializes and version-orders an unsupported durable replacement with live Markdown merges. */ export function invalidateLiveFileDocument( - fileId: string, - version: number, - projectId?: string + ref: RoomRef, + version: number ): Promise<{ status: 'applied'; docId?: string } | { status: 'stale' }> { - const name = roomName(fileDocRoom(fileId, projectId)) + if (!fileDocTargetFromRoom(ref)) throw new Error('Invalid file document room') + const name = roomName(ref) return serializeFileDocMutation(name, async () => { const store = getFileDocStore() const token = await acquireFileDocMergeSlot(name) @@ -871,15 +842,17 @@ export function invalidateLiveFileDocument( } /** Lifecycle retirement compares identities, independently of the unchanged Markdown timestamp. */ -export function retireLiveProjectFileDocument( - target: { projectId: string; fileId: string; retiredDocId: string; replacementDocId: string }, +export function retireLiveFileDocument( + target: FileDocTarget & { retiredDocId: string; replacementDocId: string }, io: Server ): Promise<{ status: 'applied'; docId: string } | { status: 'stale' }> { - const name = roomName(projectFileDocRoom(target.projectId, target.fileId)) + if (!fileDocOwnerAdapter(target.owner).tracksLifecycle) + throw new Error('Document owner does not support lifecycle retirement') + const name = roomName(fileDocRoom(target)) return serializeFileDocMutation(name, async () => { const store = getFileDocStore() const token = await acquireFileDocMergeSlot(name) - if (!token) throw new Error('Project document retirement slot is temporarily unavailable') + if (!token) throw new Error('Document retirement slot is temporarily unavailable') try { const result = await store.retireDocumentGeneration( name, @@ -887,14 +860,11 @@ export function retireLiveProjectFileDocument( target.replacementDocId ) if (result.status === 'applied') { - io.to([name, fileDocAdmissionRoom(target.fileId, target.projectId)]).emit( - FILE_DOC_EVENTS.INVALIDATED, - { - fileId: target.fileId, - docId: target.retiredDocId, - message: 'This document was archived or restored. Reload to continue.', - } - ) + io.to([name, fileDocAdmissionRoom(target)]).emit(FILE_DOC_EVENTS.INVALIDATED, { + fileId: target.fileId, + docId: target.retiredDocId, + message: 'This document was archived or restored. Reload to continue.', + }) } const existing = fileDocRooms.get(name) if (existing && docIdOf(existing.doc) === target.retiredDocId) @@ -1009,8 +979,8 @@ async function mergeMarkdownIntoRoom( * relay handlers exactly once: document updates and awareness changes are * broadcast to the room. */ -function getOrCreateRoom(io: Server, ref: RoomRef): FileDocRoom { - const name = roomName(ref) +function getOrCreateRoom(io: Server, target: FileDocTarget): FileDocRoom { + const name = roomName(fileDocRoom(target)) const existing = fileDocRooms.get(name) if (existing) return existing @@ -1022,14 +992,12 @@ function getOrCreateRoom(io: Server, ref: RoomRef): FileDocRoom { // Started BEFORE the room is registered so no join can observe a room without its hydration handle. const hydrated = getFileDocStore().attachRoom(name, doc) const room: FileDocRoom = { - fileId: projectFileDocTarget(ref)?.fileId ?? ref.id, - projectId: projectFileDocTarget(ref)?.projectId, + ...target, lastEditorConnectionId: null, doc, awareness, owners: new Map(), seeding: null, - workspaceId: null, lastEditorUserId: null, edited: false, seededObserved: false, @@ -1159,19 +1127,19 @@ function isFileDocWriteAllowed( name: string ): boolean | Promise { const userId = socket.userId - const fileId = fileDocRooms.get(name)?.fileId - if (!userId || !fileId) return false + const document = fileDocRooms.get(name) + if (!userId || !document) return false - const projectId = fileDocRooms.get(name)?.projectId - const room = fileDocRoom(fileId, projectId) - if (projectId) + const { fileId, owner } = document + const room = fileDocRoom({ fileId, owner }) + if (fileDocOwnerAdapter(owner).requiresCurrentActor) return (async () => { const permission = await resolveCurrentRoomPermission(userId, room, 'read', socket.id) if (socketToRoomName.get(socket.id) !== name) return false const canWrite = permission === 'write' || permission === 'admin' socket.emit(FILE_DOC_EVENTS.PERMISSION, { fileId, - projectId, + ...fileDocOwnerWireFields(owner), canWrite, } satisfies FileDocPermission) if (permission === null) @@ -1219,7 +1187,11 @@ async function handleMessage(socket: AuthenticatedSocket, io: Server, data: unkn if (!name) return const room = fileDocRooms.get(name) if (!room) return - if (!room.projectId && !isFileDocWriteAllowed(socket, io, name)) return + if ( + !fileDocOwnerAdapter(room.owner).requiresCurrentActor && + !isFileDocWriteAllowed(socket, io, name) + ) + return const bytes = toFileDocBytes(data) if (!bytes) return @@ -1243,12 +1215,12 @@ async function handleMessage(socket: AuthenticatedSocket, io: Server, data: unkn } const decoder = decoding.createDecoder(bytes) const messageType = decoding.readVarUint(decoder) - if (room.projectId) { + if (fileDocOwnerAdapter(room.owner).requiresCurrentActor) { const userId = socket.userId if (!userId) return const permission = await resolveCurrentRoomPermission( userId, - fileDocRoom(room.fileId, room.projectId), + fileDocRoom(room), 'read', socket.id ) @@ -1256,7 +1228,7 @@ async function handleMessage(socket: AuthenticatedSocket, io: Server, data: unkn if (permission === null) { evictSocketFromRoom( socket, - fileDocRoom(room.fileId, room.projectId), + fileDocRoom(room), 'Your access to this document has been revoked', io ) @@ -1278,7 +1250,8 @@ async function handleMessage(socket: AuthenticatedSocket, io: Server, data: unkn // Attribute a server-side persist of the resulting edit to the actual editor (blob metadata). A // socket's providers are all the same user, so any owner's userId identifies the editor. const editor = room.owners.get(socket.id)?.values().next().value?.userId - if (editor && !room.projectId) room.lastEditorUserId = editor + if (editor && !fileDocOwnerAdapter(room.owner).requiresCurrentActor) + room.lastEditorUserId = editor const encoder = encoding.createEncoder() encoding.writeVarUint(encoder, FILE_DOC_MESSAGE_TYPE.SYNC) // `socket.id` is the transaction origin, so the doc's `update` handler @@ -1351,15 +1324,10 @@ async function handleClientUpdate( } const candidate = data as Partial + const target = parseFileDocTarget(candidate) const update = toFileDocBytes(candidate.update) if ( - typeof candidate.fileId !== 'string' || - candidate.fileId.length === 0 || - (candidate.projectId !== undefined && - (typeof candidate.projectId !== 'string' || - !candidate.projectId || - /[/:]/.test(candidate.projectId) || - /[/:]/.test(candidate.fileId))) || + !target || typeof candidate.docId !== 'string' || candidate.docId.length === 0 || typeof candidate.updateId !== 'string' || @@ -1374,12 +1342,17 @@ async function handleClientUpdate( } const name = socketToRoomName.get(socket.id) - if (!name || name !== roomName(fileDocRoom(candidate.fileId, candidate.projectId))) { + if (!name || name !== roomName(fileDocRoom(target))) { reject('NOT_JOINED', true, candidate.updateId) return } const room = fileDocRooms.get(name) - if (!room) { + if ( + !room || + (target.owner && + (target.owner.entityType !== room.owner.entityType || + target.owner.entityId !== room.owner.entityId)) + ) { reject('NOT_JOINED', true, candidate.updateId) return } @@ -1534,7 +1507,8 @@ export function setupWorkspaceFileDocHandlers( } socket.on(FILE_DOC_EVENTS.JOIN, async (payload: JoinFileDocPayload) => { - const { fileId, clientId, projectId } = payload + const { fileId, clientId } = payload + const target = parseFileDocTarget(payload) // Hoisted so the catch can tell whether this join was superseded (a switch to another file) // before surfacing a retryable error for the abandoned one. let generation: number | undefined @@ -1566,13 +1540,7 @@ export function setupWorkspaceFileDocHandlers( return } if ( - typeof fileId !== 'string' || - fileId.length === 0 || - (projectId !== undefined && - (typeof projectId !== 'string' || - !projectId || - /[/:]/.test(projectId) || - /[/:]/.test(fileId))) || + !target || // A Yjs clientID is a uint32; reject malformed values before they can become ownership keys. !isYjsClientId(clientId) ) { @@ -1591,7 +1559,7 @@ export function setupWorkspaceFileDocHandlers( return } - const room = fileDocRoom(fileId, projectId) + const room = fileDocRoom(target) const name = roomName(room) // Co-mounted providers share a generation only while their owner-qualified target matches. @@ -1603,7 +1571,7 @@ export function setupWorkspaceFileDocHandlers( generation = joinGeneration.get(socket.id) ?? 0 } - const admissionName = fileDocAdmissionRoom(fileId, projectId) + const admissionName = fileDocAdmissionRoom(target) const authorizeJoin = () => resolveRoomJoinAuth({ @@ -1623,18 +1591,32 @@ export function setupWorkspaceFileDocHandlers( }) const authorized = await authorizeJoin() if (!authorized) return + if ( + target.owner?.entityType === 'workspace' && + target.owner.entityId !== authorized.workspaceId + ) { + emitJoinError(socket, fileId, clientId, 'File not found', 'NOT_FOUND', false) + return + } + + const owner = + target.owner ?? + (authorized.workspaceId + ? { entityType: 'workspace' as const, entityId: authorized.workspaceId } + : null) + if (!owner) throw new Error('Document authorization did not resolve its owner') // Server-authenticated identity for the presence roster (never trusts the client-set // awareness). Resolved here so the generation guard below also covers this await. const avatarUrl = await resolveAvatarUrl(socket, userId) const store = getFileDocStore() - if (projectId && authorized.docId) { + if (fileDocOwnerAdapter(owner).tracksLifecycle && authorized.docId) { const shared = await store.getDocumentGeneration(name) if (shared && shared !== authorized.docId) { - await retireLiveProjectFileDocument( + await retireLiveFileDocument( { - projectId, + owner, fileId, retiredDocId: shared, replacementDocId: authorized.docId, @@ -1656,10 +1638,7 @@ export function setupWorkspaceFileDocHandlers( discardInvalidatedRoom(name, io) } - const entry = getOrCreateRoom(io, room) - // The workspace the server-side persist writes back to — and what the seed is built from, so it - // must be captured BEFORE the room is prepared below. - if (authorized.workspaceId) entry.workspaceId = authorized.workspaceId + const entry = getOrCreateRoom(io, { fileId, owner }) // Hold the room open across the awaits below: it has no owner until this join commits, so a // concurrent last-leave would otherwise tear down the very document being prepared. @@ -1690,7 +1669,7 @@ export function setupWorkspaceFileDocHandlers( // document's history, and it watches that replay on screen (reload right after moving a block // and the block moves again in front of you). Waiting here is what makes the handshake below // authoritative: the client's first sync IS the finished document, in one message. - await ensureRoomReady(name, entry, entry.workspaceId, { userId, connectionId: socket.id }) + await ensureRoomReady(name, entry, { userId, connectionId: socket.id }) // Re-check access immediately before registering, mirroring the workflow join: the // access re-validation sweep records a revocation BEFORE it evicts, so a join that @@ -1751,7 +1730,7 @@ export function setupWorkspaceFileDocHandlers( } if (!canRegisterJoin()) return await changeMembership(() => socket.join(name)) - if (projectId) { + if (fileDocOwnerAdapter(owner).tracksLifecycle) { const currentAccess = await authorizeJoin() if (!currentAccess) return if (!isCurrentJoin()) return @@ -1827,7 +1806,7 @@ export function setupWorkspaceFileDocHandlers( // only after its LAST provider for the file tears down). let clientMap = entry.owners.get(socket.id) if (clientMap === undefined) { - clientMap = new Map() + clientMap = new Map() entry.owners.set(socket.id, clientMap) } clientMap.set(clientId, { clientId, userId, userName, avatarUrl }) @@ -1844,7 +1823,7 @@ export function setupWorkspaceFileDocHandlers( // Attribution for the server-side persist, refreshed to the actual editor on each edit in // `handleMessage`. - if (!projectId) entry.lastEditorUserId = userId + if (!fileDocOwnerAdapter(owner).requiresCurrentActor) entry.lastEditorUserId = userId // Name the document this room holds, so a client that still carries a DIFFERENT one (its room // outlived by a document rebuilt in its place) can refuse to merge instead of unioning two @@ -1855,9 +1834,14 @@ export function setupWorkspaceFileDocHandlers( docId: docIdOf(entry.doc), version: joinedVersion, schemaVersion: FILE_DOC_SCHEMA_VERSION, - ...(store.enabled || projectId ? { acknowledgedUpdates: true as const } : {}), - ...(projectId - ? { projectId, canWrite: finalPermission === 'write' || finalPermission === 'admin' } + ...fileDocOwnerWireFields(entry.owner), + ...(store.enabled || fileDocOwnerAdapter(owner).requiresCurrentActor + ? { acknowledgedUpdates: true as const } + : {}), + ...(fileDocOwnerAdapter(owner).requiresCurrentActor + ? { + canWrite: finalPermission === 'write' || finalPermission === 'admin', + } : {}), }) // Server-authenticated roster → everyone in the room, including this joiner. @@ -1906,7 +1890,8 @@ export function setupWorkspaceFileDocHandlers( } catch (error) { logger.error('Error joining file-doc room:', error) try { - const name = roomName(fileDocRoom(fileId, projectId)) + const name = target ? roomName(fileDocRoom(target)) : null + if (!name) throw error /** * Roll back ownership only if this attempt committed it. A failed provisional admission must * preserve a previous file's binding and any co-mounted provider already in the target room. @@ -1953,9 +1938,9 @@ export function setupWorkspaceFileDocHandlers( socket.on(FILE_DOC_EVENTS.LEAVE, async (payload?: LeaveFileDocPayload) => { try { - const leavingRoom = payload?.fileId - ? roomName(fileDocRoom(payload.fileId, payload.projectId)) - : undefined + const target = payload?.fileId ? parseFileDocTarget(payload) : undefined + if (payload?.fileId && !target) return + const leavingRoom = target ? roomName(fileDocRoom(target)) : undefined if (!leavingRoom || leavingRoom === currentFileRoom) { joinGeneration.set(socket.id, (joinGeneration.get(socket.id) ?? 0) + 1) currentFileRoom = null diff --git a/apps/realtime/src/handlers/file-list-app.ts b/apps/realtime/src/handlers/file-list-app.ts new file mode 100644 index 00000000000..10866500c41 --- /dev/null +++ b/apps/realtime/src/handlers/file-list-app.ts @@ -0,0 +1,59 @@ +import type { RoomAuthorizationResult } from '@sim/platform-authz/rooms' +import { FILE_DOC_INTERNAL_HEADERS, FILE_DOC_TIMEOUTS } from '@sim/realtime-protocol/file-doc' +import { type ProjectRoomRef, projectFileDocTarget, ROOM_TYPES } from '@sim/realtime-protocol/rooms' +import { toRecord } from '@sim/utils/object' +import { env, getBaseUrl } from '@/env' +import { fetchProjectFileDocAccess } from '@/handlers/file-doc-app' + +/** Each Project room revalidates through its own bounded semantic app operation. */ +export async function fetchProjectRoomAccess( + room: ProjectRoomRef, + actor: { userId: string; connectionId: string } +): Promise { + switch (room.type) { + case ROOM_TYPES.PROJECT_FILE_DOC: { + const target = projectFileDocTarget(room) + if (!target) throw new Error('Invalid Project document target') + return fetchProjectFileDocAccess({ ...target, ...actor }) + } + case ROOM_TYPES.PROJECT_FILES: { + if (!room.id || room.id.length > 200 || /[/:\s]/.test(room.id)) { + throw new Error('Invalid Project collection target') + } + const response = await fetch( + `${getBaseUrl()}/api/internal/project-file-list/${encodeURIComponent(room.id)}/access`, + { + method: 'POST', + headers: { + 'x-api-key': env.INTERNAL_API_SECRET, + [FILE_DOC_INTERNAL_HEADERS.userId]: actor.userId, + [FILE_DOC_INTERNAL_HEADERS.connectionId]: actor.connectionId, + }, + signal: AbortSignal.timeout(FILE_DOC_TIMEOUTS.seedRequestMs), + } + ) + if ([403, 404, 409].includes(response.status)) { + await response.body?.cancel() + return { + allowed: false, + status: response.status === 409 ? 404 : response.status, + workspaceId: null, + workspacePermission: null, + } + } + if (!response.ok) { + await response.body?.cancel() + throw new Error(`Project collection authorization failed: ${response.status}`) + } + const result = toRecord(await response.json()) + if (result.projectId !== room.id || result.canRead !== true) { + throw new Error('Project collection authorization returned an invalid target') + } + return { allowed: true, status: 200, workspaceId: null, workspacePermission: 'read' } + } + default: { + const unknownType: never = room.type + throw new Error(`Unsupported Project room ${unknownType}`) + } + } +} diff --git a/apps/realtime/src/handlers/index.ts b/apps/realtime/src/handlers/index.ts index cda7c032d62..f345553caed 100644 --- a/apps/realtime/src/handlers/index.ts +++ b/apps/realtime/src/handlers/index.ts @@ -1,4 +1,4 @@ -import { WORKSPACE_LIST_ROOM_TYPES } from '@sim/realtime-protocol/rooms' +import { INVALIDATION_ROOM_TYPES } from '@sim/realtime-protocol/rooms' import { setupConnectionHandlers } from '@/handlers/connection' import { setupWorkspaceFileDocHandlers } from '@/handlers/file-doc' import { setupOperationsHandlers } from '@/handlers/operations' @@ -17,8 +17,7 @@ export function setupAllHandlers(socket: AuthenticatedSocket, roomManager: IRoom setupSubblocksHandlers(socket, roomManager) setupVariablesHandlers(socket, roomManager) setupPresenceHandlers(socket, roomManager) - // Presence-free, workspace-scoped live-list rooms (share one implementation). - for (const roomType of WORKSPACE_LIST_ROOM_TYPES) { + for (const roomType of INVALIDATION_ROOM_TYPES) { setupWorkspaceInvalidationRoom(socket, roomManager, roomType) } setupWorkspaceFileDocHandlers(socket, roomManager) diff --git a/apps/realtime/src/handlers/room-join-auth.ts b/apps/realtime/src/handlers/room-join-auth.ts index 593c3b069b2..bc7b55748c7 100644 --- a/apps/realtime/src/handlers/room-join-auth.ts +++ b/apps/realtime/src/handlers/room-join-auth.ts @@ -1,7 +1,7 @@ import type { createLogger } from '@sim/logger' import { authorizeRoom } from '@sim/platform-authz/rooms' -import { projectFileDocTarget, ROOM_TYPES, type RoomRef } from '@sim/realtime-protocol/rooms' -import { fetchProjectFileDocAccess } from '@/handlers/file-doc-app' +import { isProjectRoom, type RoomRef } from '@sim/realtime-protocol/rooms' +import { fetchProjectRoomAccess } from '@/handlers/file-list-app' import { beginRoomPermissionRead, commitRoomPermission } from '@/middleware/permissions' type Authorized = Awaited> & { docId?: string | null } @@ -42,12 +42,9 @@ export async function resolveRoomJoinAuth( // is never overwritten by this older result — see {@link commitRoomPermission}. const readSeq = beginRoomPermissionRead() try { - if (room.type === ROOM_TYPES.PROJECT_FILE_DOC) { - const target = projectFileDocTarget(room) - if (!target || !params.connectionId) - throw new Error('Project document requires a socket identity') - authorized = await fetchProjectFileDocAccess({ - ...target, + if (isProjectRoom(room)) { + if (!params.connectionId) throw new Error('Project room requires a socket identity') + authorized = await fetchProjectRoomAccess(room, { userId, connectionId: params.connectionId, }) diff --git a/apps/realtime/src/handlers/workspace-invalidation-room.test.ts b/apps/realtime/src/handlers/workspace-invalidation-room.test.ts index 794970e62e4..7f8f6cd8fba 100644 --- a/apps/realtime/src/handlers/workspace-invalidation-room.test.ts +++ b/apps/realtime/src/handlers/workspace-invalidation-room.test.ts @@ -1,4 +1,5 @@ -import { WORKSPACE_LIST_ROOM_TYPES } from '@sim/realtime-protocol/rooms' +import { INVALIDATION_ROOM_TYPES, invalidationRoomIdKey } from '@sim/realtime-protocol/rooms' +import { createDeferred } from '@sim/testing' import { databaseMock } from '@sim/testing/mocks/database.mock' import { sleep } from '@sim/utils/helpers' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -14,10 +15,14 @@ vi.mock('@sim/platform-authz/rooms', () => ({ authorizeRoom: mockAuthorizeRoom, })) +vi.mock('@/handlers/file-list-app', () => ({ + fetchProjectRoomAccess: mockAuthorizeRoom, +})) + import { setupWorkspaceInvalidationRoom } from '@/handlers/workspace-invalidation-room' import { beginRoomPermissionRead, commitRoomPermission } from '@/middleware/permissions' -type Payload = { workspaceId?: string } +type Payload = { workspaceId?: string; projectId?: string } function createSocket(overrides?: Record) { const handlers: Record Promise | void> = {} @@ -25,6 +30,7 @@ function createSocket(overrides?: Record) { const rooms = new Set() const socket = { id: 'socket-1', + disconnected: false, userId: 'user-1', userName: 'Test User', userImage: 'avatar.png', @@ -68,14 +74,13 @@ function createRoomManager(overrides?: Partial): IRoomManager { } as unknown as IRoomManager } -// The presence-free live-list rooms share one implementation; run the whole suite against each -// so they can never drift. Event names and room names derive from the room type. -describe.each(WORKSPACE_LIST_ROOM_TYPES)('setupWorkspaceInvalidationRoom(%s)', (roomType) => { +/** All invalidation room types share authorization and cancellation behavior. */ +describe.each(INVALIDATION_ROOM_TYPES)('setupWorkspaceInvalidationRoom(%s)', (roomType) => { const joinEvent = `join-${roomType}` const successEvent = `${joinEvent}-success` const errorEvent = `${joinEvent}-error` const leaveEvent = `leave-${roomType}` - const _roomOf = (workspaceId: string) => `${roomType}:${workspaceId}` + const idKey = invalidationRoomIdKey(roomType) const setup = (socket: ReturnType['socket'], roomManager: IRoomManager) => setupWorkspaceInvalidationRoom( @@ -103,7 +108,7 @@ describe.each(WORKSPACE_LIST_ROOM_TYPES)('setupWorkspaceInvalidationRoom(%s)', ( const { socket, handlers } = createSocket() setup(socket, createRoomManager()) - await handlers[joinEvent]({ workspaceId: 'ws-1' }) + await handlers[joinEvent]({ [idKey]: 'ws-1' }) expect(socket.emit).toHaveBeenCalledWith( errorEvent, @@ -112,11 +117,7 @@ describe.each(WORKSPACE_LIST_ROOM_TYPES)('setupWorkspaceInvalidationRoom(%s)', ( }) it('aborts a join superseded during the access re-check await', async () => { - // The access re-resolve is an await like any other: a leave landing during it must - // still cancel this join, or the stale join would leave the room the client - // switched to and commit the abandoned one. Forced down the re-resolve's DB path - // by expiring the cached decision mid-join, so the interleaving is deterministic - // rather than dependent on microtask ordering. + /** Expire the cache to exercise a leave while the permission re-check is pending. */ vi.useFakeTimers() try { const { handlers, socket } = createSocket({ id: 'socket-sup', userId: 'user-sup' }) @@ -141,12 +142,12 @@ describe.each(WORKSPACE_LIST_ROOM_TYPES)('setupWorkspaceInvalidationRoom(%s)', ( await sleep(31_000) } else { // Second call is the re-check's re-resolve: the client leaves during it. - handlers[leaveEvent]({ workspaceId: 'ws-sup' }) + handlers[leaveEvent]({ [idKey]: 'ws-sup' }) } return { allowed: true, status: 200, workspaceId: 'ws-sup', workspacePermission: 'admin' } }) - const joining = handlers[joinEvent]({ workspaceId: 'ws-sup' }) + const joining = handlers[joinEvent]({ [idKey]: 'ws-sup' }) await vi.advanceTimersByTimeAsync(31_000) await joining @@ -178,7 +179,7 @@ describe.each(WORKSPACE_LIST_ROOM_TYPES)('setupWorkspaceInvalidationRoom(%s)', ( return { allowed: true, status: 200, workspaceId: 'ws-race', workspacePermission: 'admin' } }) - await handlers[joinEvent]({ workspaceId: 'ws-race' }) + await handlers[joinEvent]({ [idKey]: 'ws-race' }) expect(socket.emit).toHaveBeenCalledWith( errorEvent, @@ -187,3 +188,180 @@ describe.each(WORKSPACE_LIST_ROOM_TYPES)('setupWorkspaceInvalidationRoom(%s)', ( expect(socket.join).not.toHaveBeenCalled() }) }) + +/** Exercise every owner address through the real authorization and membership handler. */ +describe.each(INVALIDATION_ROOM_TYPES)('concurrent owner subscriptions (%s)', (roomType) => { + const idKey = invalidationRoomIdKey(roomType) + const payload = (id: string): Payload => ({ [idKey]: id }) + const joinEvent = `join-${roomType}` + const leaveEvent = `leave-${roomType}` + const successEvent = `${joinEvent}-success` + const errorEvent = `${joinEvent}-error` + const allowed = { allowed: true, status: 200, workspacePermission: 'admin' } + + function setup() { + const state = createSocket({ disconnected: false }) + setupWorkspaceInvalidationRoom( + state.socket as unknown as Parameters[0], + createRoomManager(), + roomType + ) + return state + } + + function pendingAuthorization() { + const pending = createDeferred() + mockAuthorizeRoom.mockImplementationOnce(() => pending.promise) + return pending + } + + beforeEach(() => { + mockAuthorizeRoom.mockReset().mockResolvedValue(allowed) + }) + + it('keeps both owners subscribed after sequential joins', async () => { + const { handlers, rooms } = setup() + await handlers[joinEvent](payload('owner-a')) + await handlers[joinEvent](payload('owner-b')) + expect(rooms).toEqual(new Set([`${roomType}:owner-a`, `${roomType}:owner-b`])) + }) + + it('allows independent joins to finish in reverse order', async () => { + const { handlers, rooms } = setup() + const first = pendingAuthorization() + const joining = handlers[joinEvent](payload('owner-a')) + await handlers[joinEvent](payload('owner-b')) + first.resolve(allowed) + await joining + expect(rooms).toEqual(new Set([`${roomType}:owner-a`, `${roomType}:owner-b`])) + }) + + it('scoped leave cancels only its pending owner and retains other membership', async () => { + const { handlers, rooms } = setup() + await handlers[joinEvent](payload('owner-c')) + const first = pendingAuthorization() + const joiningA = handlers[joinEvent](payload('owner-a')) + const second = pendingAuthorization() + const joiningB = handlers[joinEvent](payload('owner-b')) + handlers[leaveEvent](payload('owner-a')) + second.resolve(allowed) + first.resolve(allowed) + await Promise.all([joiningA, joiningB]) + expect(rooms).toEqual(new Set([`${roomType}:owner-b`, `${roomType}:owner-c`])) + }) + + it('scoped leave removes only the specified joined owner', async () => { + const { handlers, rooms } = setup() + await handlers[joinEvent](payload('owner-a')) + await handlers[joinEvent](payload('owner-b')) + handlers[leaveEvent](payload('owner-b')) + expect(rooms).toEqual(new Set([`${roomType}:owner-a`])) + }) + + it('leave all cancels every pending join and removes only this room type', async () => { + const { handlers, rooms } = setup() + rooms.add('other:owner') + await handlers[joinEvent](payload('owner-c')) + const first = pendingAuthorization() + const joiningA = handlers[joinEvent](payload('owner-a')) + const second = pendingAuthorization() + const joiningB = handlers[joinEvent](payload('owner-b')) + handlers[leaveEvent]() + first.resolve(allowed) + second.resolve(allowed) + await Promise.all([joiningA, joiningB]) + expect(rooms).toEqual(new Set(['other:owner'])) + }) + + it('does not revive an old attempt after leave and rejoin of the same owner', async () => { + const { handlers, socket, rooms } = setup() + const first = pendingAuthorization() + const joining = handlers[joinEvent](payload('owner-a')) + handlers[leaveEvent](payload('owner-a')) + await handlers[joinEvent](payload('owner-a')) + first.resolve(allowed) + await joining + expect(rooms).toEqual(new Set([`${roomType}:owner-a`])) + expect(socket.emit.mock.calls.filter(([event]) => event === successEvent)).toHaveLength(1) + }) + + it('supersedes a duplicate pending join for the same owner only', async () => { + const { handlers, socket, rooms } = setup() + const first = pendingAuthorization() + const joining = handlers[joinEvent](payload('owner-a')) + await handlers[joinEvent](payload('owner-b')) + await handlers[joinEvent](payload('owner-a')) + first.resolve(allowed) + await joining + expect(rooms).toEqual(new Set([`${roomType}:owner-a`, `${roomType}:owner-b`])) + expect(socket.emit.mock.calls.filter(([event]) => event === successEvent)).toHaveLength(2) + }) + + it('suppresses stale authorization errors after the owner has rejoined', async () => { + const { handlers, socket, rooms } = setup() + const first = pendingAuthorization() + const joining = handlers[joinEvent](payload('owner-a')) + handlers[leaveEvent](payload('owner-a')) + await handlers[joinEvent](payload('owner-a')) + first.reject(new Error('Delayed authorization failure')) + await joining + expect(rooms).toEqual(new Set([`${roomType}:owner-a`])) + expect(socket.emit).not.toHaveBeenCalledWith(errorEvent, expect.anything()) + }) + + it('does not clear a newer pending attempt when the old attempt finishes', async () => { + const { handlers, socket, rooms } = setup() + const first = pendingAuthorization() + const joiningA = handlers[joinEvent](payload('owner-a')) + const second = pendingAuthorization() + const joiningAgain = handlers[joinEvent](payload('owner-a')) + first.resolve(allowed) + await joiningA + expect(rooms.size).toBe(0) + second.resolve(allowed) + await joiningAgain + expect(rooms).toEqual(new Set([`${roomType}:owner-a`])) + expect(socket.emit.mock.calls.filter(([event]) => event === successEvent)).toHaveLength(1) + }) + + it('preserves another owner while rejecting a revoked pending join', async () => { + const { handlers, socket, rooms } = setup() + await handlers[joinEvent](payload('owner-b')) + const first = pendingAuthorization() + const joining = handlers[joinEvent](payload('owner-a')) + commitRoomPermission( + socket.userId, + { type: roomType, id: 'owner-a' }, + null, + beginRoomPermissionRead() + ) + first.resolve(allowed) + await joining + expect(rooms).toEqual(new Set([`${roomType}:owner-b`])) + expect(socket.emit).toHaveBeenCalledWith( + errorEvent, + expect.objectContaining({ [idKey]: 'owner-a', code: 'ACCESS_DENIED' }) + ) + }) + + it('does not commit any pending joins after disconnect', async () => { + const { handlers, socket, rooms } = setup() + const first = pendingAuthorization() + const joining = handlers[joinEvent](payload('owner-a')) + socket.disconnected = true + first.resolve(allowed) + await joining + expect(rooms.size).toBe(0) + expect(socket.emit).not.toHaveBeenCalledWith(successEvent, expect.anything()) + }) + + it('rejects malformed joins without cancelling a valid pending owner', async () => { + const { handlers, rooms } = setup() + const first = pendingAuthorization() + const joining = handlers[joinEvent](payload('owner-a')) + await handlers[joinEvent](payload('')) + first.resolve(allowed) + await joining + expect(rooms).toEqual(new Set([`${roomType}:owner-a`])) + }) +}) diff --git a/apps/realtime/src/handlers/workspace-invalidation-room.ts b/apps/realtime/src/handlers/workspace-invalidation-room.ts index 362e37875f6..5bc3bb89b68 100644 --- a/apps/realtime/src/handlers/workspace-invalidation-room.ts +++ b/apps/realtime/src/handlers/workspace-invalidation-room.ts @@ -1,6 +1,12 @@ import { createLogger } from '@sim/logger' import { ROOM_MEMBERSHIP_ACTIONS, satisfiesRoomMembership } from '@sim/platform-authz/room-policy' -import { type RoomRef, type RoomType, roomName } from '@sim/realtime-protocol/rooms' +import { + type InvalidationRoomType, + invalidationRoomIdKey, + type RoomRef, + roomName, +} from '@sim/realtime-protocol/rooms' +import { toRecord } from '@sim/utils/object' import { resolveRoomJoinAuth } from '@/handlers/room-join-auth' import type { AuthenticatedSocket } from '@/middleware/auth' import { resolveCurrentRoomPermission } from '@/middleware/permissions' @@ -8,52 +14,41 @@ import type { IRoomManager } from '@/rooms' const logger = createLogger('WorkspaceInvalidationRoom') -interface JoinPayload { - workspaceId: string -} - /** - * Wires a workspace-scoped, presence-free "invalidation room" onto a socket: the client joins a - * room named after its workspace, and a `${roomType}-changed` event — fanned out by the server-side + * Wires an owner-scoped, presence-free "invalidation room" onto a socket: the client joins a + * room named after its owner, and a `${roomType}-changed` event — fanned out by the server-side * mutation path over HTTP — reaches every viewer so they refetch. This is the shared core behind the - * workspace-files and workspace-tables browsers; they differ only in `roomType` (which also derives - * the event names, since each room type's wire token IS its event stem: `join-${roomType}`, - * `leave-${roomType}`, `join-${roomType}-success/-error`, and the `${roomType}-changed` broadcast). + * file and workspace resource browsers; their registered owner key preserves each wire payload. + * The `roomType` derives + * the event names: `join-${roomType}`, + * `leave-${roomType}`, `join-${roomType}-success/-error`, and `${roomType}-changed`. * * These rooms carry NO presence — "who's in a resource" comes from the per-resource room (file-doc / * table), and mutations go over HTTP. Membership is tracked natively by Socket.IO (`socket.rooms`), - * so a workspace switch just leaves the prior room — no room-manager presence bookkeeping to sync. + * so multiple owners can subscribe independently without room-manager presence bookkeeping. */ export function setupWorkspaceInvalidationRoom( socket: AuthenticatedSocket, roomManager: IRoomManager, - roomType: RoomType + roomType: InvalidationRoomType ) { + const idKey = invalidationRoomIdKey(roomType) const joinEvent = `join-${roomType}` const leaveEvent = `leave-${roomType}` const successEvent = `${joinEvent}-success` const errorEvent = `${joinEvent}-error` const roomPrefix = `${roomType}:` - const room = (workspaceId: string): RoomRef => ({ type: roomType, id: workspaceId }) + const room = (ownerId: string): RoomRef => ({ type: roomType, id: ownerId }) - // Monotonic per-socket join counter: each join captures its number and, after the async - // authorize, aborts if a newer intent has superseded it — a fast workspace switch A→B can - // otherwise let A's late completion leave B and strand the socket in A, missing B's - // `${roomType}-changed` invalidations. - let joinGeneration = 0 - // The workspace the socket currently intends to be in (set when a join starts). A leave that - // targets this workspace — or an unscoped "leave all" — advances joinGeneration so an in-flight - // join is cancelled instead of completing after the view has closed. A stale/deferred leave for - // a DIFFERENT workspace must NOT advance it, or it would abort the join the client has since - // switched to (the bug that bit the file-doc room in #5941). - let currentWorkspace: string | null = null + /** Unique tokens prevent an older attempt from surviving an owner leave/rejoin cycle. */ + const joinAttempts = new Map() - socket.on(joinEvent, async ({ workspaceId }: JoinPayload) => { - // Validate synchronously BEFORE claiming a generation, so a rejected/malformed join can't - // advance joinGeneration and cancel a legitimate in-flight join for another workspace. + socket.on(joinEvent, async (payload: unknown) => { + const ownerId = toRecord(payload)[idKey] + /** Reject invalid requests before superseding an existing attempt for this owner. */ if (!socket.userId || !socket.userName) { socket.emit(errorEvent, { - workspaceId, + [idKey]: ownerId, error: 'Authentication required', code: 'AUTHENTICATION_REQUIRED', retryable: false, @@ -63,7 +58,7 @@ export function setupWorkspaceInvalidationRoom( if (!roomManager.isReady()) { socket.emit(errorEvent, { - workspaceId, + [idKey]: ownerId, error: 'Realtime unavailable', code: 'ROOM_MANAGER_UNAVAILABLE', retryable: true, @@ -71,106 +66,97 @@ export function setupWorkspaceInvalidationRoom( return } - // Validate the client-supplied id before it reaches the DB query (join payloads are - // otherwise raw client input) and before advancing the generation. - if (typeof workspaceId !== 'string' || workspaceId.length === 0) { + /** Validate owner identifiers before authorization. */ + if ( + typeof ownerId !== 'string' || + ownerId.length === 0 || + (idKey === 'projectId' && + (ownerId.length > 200 || ownerId !== ownerId.trim() || /[/:\s]/.test(ownerId))) + ) { socket.emit(errorEvent, { - workspaceId: typeof workspaceId === 'string' ? workspaceId : '', - error: 'Invalid workspace id', + [idKey]: typeof ownerId === 'string' ? ownerId : '', + error: idKey === 'projectId' ? 'Invalid Project id' : 'Invalid workspace id', code: 'INVALID_PAYLOAD', retryable: false, }) return } - const joinAttempt = (joinGeneration += 1) - currentWorkspace = workspaceId + const joinAttempt = Symbol() + joinAttempts.set(ownerId, joinAttempt) + const isCurrentAttempt = () => joinAttempts.get(ownerId) === joinAttempt && !socket.disconnected try { - const ref = room(workspaceId) + const ref = room(ownerId) const authorized = await resolveRoomJoinAuth({ userId: socket.userId, + connectionId: socket.id, room: ref, action: ROOM_MEMBERSHIP_ACTIONS[roomType], logger, logLabel: `${roomType} room for ${socket.userId}`, messages: { - verifyFailed: 'Failed to verify workspace access', - notFound: 'Workspace not found', - accessDenied: 'Access denied to workspace', + verifyFailed: + idKey === 'projectId' + ? 'Failed to verify Project access' + : 'Failed to verify workspace access', + notFound: idKey === 'projectId' ? 'Project not found' : 'Workspace not found', + accessDenied: + idKey === 'projectId' ? 'Access denied to Project' : 'Access denied to workspace', + }, + emitError: ({ error, code, retryable }) => { + if (isCurrentAttempt()) { + socket.emit(errorEvent, { [idKey]: ownerId, error, code, retryable }) + } }, - emitError: ({ error, code, retryable }) => - socket.emit(errorEvent, { workspaceId, error, code, retryable }), }) - if (!authorized) return + if (!authorized || !isCurrentAttempt()) return - // Re-check access before committing: the access re-validation sweep records a - // revocation BEFORE it evicts, so a join that authorized just before the - // revocation must not complete afterwards and put the socket back in the room. - // RE-RESOLVES rather than peeking — a peek treats an expired entry as unknown and - // fails open, which a join stalled longer than the cache TTL would slip through. - // Normally a cache hit (this join's own authorize just warmed it). Mirrors the - // file-doc and table joins. + /** Re-resolve access so revocation or an expired permission cache cannot admit a stale join. */ const currentPermission = await resolveCurrentRoomPermission( socket.userId, ref, - ROOM_MEMBERSHIP_ACTIONS[roomType] + ROOM_MEMBERSHIP_ACTIONS[roomType], + socket.id ) + if (!isCurrentAttempt()) return if (!satisfiesRoomMembership(currentPermission, roomType)) { socket.emit(errorEvent, { - workspaceId, - error: 'Access denied to workspace', + [idKey]: ownerId, + error: idKey === 'projectId' ? 'Access denied to Project' : 'Access denied to workspace', code: 'ACCESS_DENIED', retryable: false, }) return } - // A newer join started on this socket during the awaits above — including the access - // re-resolve — or it dropped: abort so a stale join can't leave the room the client has - // since switched to. Last await before the commit, so nothing interleaves after it. - if (joinGeneration !== joinAttempt || socket.disconnected) return - - // Leave any previously-joined room of this type (workspace switch), read straight from the - // socket's native room membership so there's no presence store to keep in sync. - const target = roomName(ref) - for (const joined of socket.rooms) { - if (joined !== target && joined.startsWith(roomPrefix)) socket.leave(joined) - } - - socket.join(target) - socket.emit(successEvent, { workspaceId }) + socket.join(roomName(ref)) + socket.emit(successEvent, { [idKey]: ownerId }) } catch (error) { + if (!isCurrentAttempt()) return logger.error(`Error joining ${roomType} room:`, error) try { - socket.leave(roomName(room(workspaceId))) + socket.leave(roomName(room(ownerId))) } catch {} - // Suppress the client-facing error when this join was already superseded: the client has - // switched to a newer workspace, and a retryable error naming the abandoned one could make it - // re-join and cancel the newer join. The leave above still runs. - if (joinGeneration !== joinAttempt || socket.disconnected) return socket.emit(errorEvent, { - workspaceId, + [idKey]: ownerId, error: `Failed to join ${roomType}`, code: 'JOIN_FAILED', retryable: true, }) + } finally { + if (joinAttempts.get(ownerId) === joinAttempt) joinAttempts.delete(ownerId) } }) - socket.on(leaveEvent, (payload?: { workspaceId?: string }) => { - // Cancel an in-flight join whose target the client is now leaving: a join awaiting - // authorization when the view unmounts would otherwise complete afterwards and strand the - // socket in a room it has left. Only when the leave targets the current join intent (or is - // unscoped) — a deferred leave for a different workspace must not abort the join the client - // has since switched to. - if (!payload?.workspaceId || payload.workspaceId === currentWorkspace) { - joinGeneration += 1 - currentWorkspace = null - } - // Scope the leave to a specific workspace when the client provides one: a deferred leave - // from a prior page must not evict a room the socket has since switched into. - const target = payload?.workspaceId ? roomName(room(payload.workspaceId)) : null + socket.on('disconnect', () => joinAttempts.clear()) + + socket.on(leaveEvent, (payload?: unknown) => { + const ownerId = toRecord(payload)[idKey] + if (ownerId !== undefined && (typeof ownerId !== 'string' || !ownerId)) return + if (ownerId) joinAttempts.delete(ownerId) + else joinAttempts.clear() + const target = ownerId ? roomName(room(ownerId)) : null for (const joined of socket.rooms) { if (!joined.startsWith(roomPrefix)) continue if (target && joined !== target) continue diff --git a/apps/realtime/src/middleware/permissions.ts b/apps/realtime/src/middleware/permissions.ts index ab2c2cca569..3298293d29b 100644 --- a/apps/realtime/src/middleware/permissions.ts +++ b/apps/realtime/src/middleware/permissions.ts @@ -13,14 +13,9 @@ import { VARIABLE_OPERATIONS, WORKFLOW_OPERATIONS, } from '@sim/realtime-protocol/constants' -import { - projectFileDocTarget, - ROOM_TYPES, - type RoomRef, - roomName, -} from '@sim/realtime-protocol/rooms' +import { isProjectRoom, ROOM_TYPES, type RoomRef, roomName } from '@sim/realtime-protocol/rooms' import { and, eq, isNull } from 'drizzle-orm' -import { fetchProjectFileDocAccess } from '@/handlers/file-doc-app' +import { fetchProjectRoomAccess } from '@/handlers/file-list-app' const logger = createLogger('SocketPermissions') @@ -228,10 +223,9 @@ async function readAuthoritativeRoomPermission( room: RoomRef, connectionId?: string ): Promise { - if (room.type === ROOM_TYPES.PROJECT_FILE_DOC) { - const target = projectFileDocTarget(room) - if (!target || !connectionId) throw new Error('Project document requires a socket identity') - const authorization = await fetchProjectFileDocAccess({ ...target, userId, connectionId }) + if (isProjectRoom(room)) { + if (!connectionId) throw new Error('Project room requires a socket identity') + const authorization = await fetchProjectRoomAccess(room, { userId, connectionId }) return authorization.allowed ? authorization.workspacePermission : null } if (room.type === ROOM_TYPES.WORKFLOW) { @@ -270,7 +264,7 @@ async function resolveRoleUncached( // already-revoked user — so a recorded revocation survives a transient DB failure // instead of reverting to the stale join-time role. Only trust `fallbackRole` when // nothing has been recorded for this (user, workflow) yet. - if (room.type === ROOM_TYPES.PROJECT_FILE_DOC) throw error + if (isProjectRoom(room)) throw error const lastKnown = roleCache.get(key) return lastKnown !== undefined ? lastKnown.role : fallbackRole } diff --git a/apps/realtime/src/routes/http.ts b/apps/realtime/src/routes/http.ts index c7ed19e10e8..cf0c9f74d12 100644 --- a/apps/realtime/src/routes/http.ts +++ b/apps/realtime/src/routes/http.ts @@ -1,19 +1,23 @@ import type { IncomingMessage, ServerResponse } from 'http' import { FILE_DOC_EVENTS, type FileDocInvalidated } from '@sim/realtime-protocol/file-doc' import { - projectFileDocRoom, - ROOM_TYPES, + fileDocAdmissionRoom, + fileDocRoom, + parseFileDocTarget, +} from '@sim/realtime-protocol/file-doc-target' +import { + INVALIDATION_ROOM_TYPES, + invalidationRoomIdKey, roomName, - WORKSPACE_LIST_ROOM_TYPES, } from '@sim/realtime-protocol/rooms' import { safeCompare } from '@sim/security/compare' import { env } from '@/env' import { applyMarkdownToLiveFileDoc, - fileDocAdmissionRoom, invalidateLiveFileDocument, - retireLiveProjectFileDocument, + retireLiveFileDocument, } from '@/handlers/file-doc' +import { fileDocOwnerAdapter } from '@/handlers/file-doc-owner' import { type IRoomManager, WorkflowRoomService } from '@/rooms' interface Logger { @@ -180,16 +184,19 @@ export function createHttpHandler(roomManager: IRoomManager, logger: Logger) { // HTTP API (not the socket); this is the lossy liveness signal — a missed one only means // stale-until-refetch. Endpoint and event names derive from the room type, mirroring the socket // handler and the client hook. - const listRoomType = WORKSPACE_LIST_ROOM_TYPES.find( - (type) => req.url === `/api/${type}-changed` - ) + const listRoomType = INVALIDATION_ROOM_TYPES.find((type) => req.url === `/api/${type}-changed`) if (req.method === 'POST' && listRoomType) { try { const body = await readRequestBody(req) - const { workspaceId } = JSON.parse(body) - if (!isNonEmptyString(workspaceId)) return sendError(res, 'Invalid workspaceId', 400) - roomManager.emitToRoom({ type: listRoomType, id: workspaceId }, `${listRoomType}-changed`, { - workspaceId, + const idKey = invalidationRoomIdKey(listRoomType) + const ownerId = JSON.parse(body)?.[idKey] + if ( + !isNonEmptyString(ownerId) || + (idKey === 'projectId' && (ownerId.length > 200 || /[/:\s]/.test(ownerId))) + ) + return sendError(res, 'Invalid collection owner', 400) + roomManager.emitToRoom({ type: listRoomType, id: ownerId }, `${listRoomType}-changed`, { + [idKey]: ownerId, timestamp: Date.now(), }) sendSuccess(res) @@ -208,29 +215,17 @@ export function createHttpHandler(roomManager: IRoomManager, logger: Logger) { if (req.method === 'POST' && req.url === '/api/file-doc/apply-edit') { try { const body = await readRequestBody(req) - const { fileId, markdown, version, owner } = JSON.parse(body) - if (!isNonEmptyString(fileId) || typeof markdown !== 'string') { + const input = JSON.parse(body) + const { markdown, version } = input + const target = parseFileDocTarget(input) + if (!target || typeof markdown !== 'string') { return sendError(res, 'Invalid fileId or markdown', 400) } - if ( - owner !== undefined && - (!owner || - owner.entityType !== 'project' || - !isNonEmptyString(owner.entityId) || - /[/:]/.test(owner.entityId) || - /[/:]/.test(fileId)) - ) - return sendError(res, 'Invalid file owner', 400) // `version` (the durable updatedAt this markdown was written with) records that the live doc now // incorporates that durable version, so the persist If-Match guard won't flag it as a conflict. - const result = await applyMarkdownToLiveFileDoc( - fileId, - markdown, - { - version: typeof version === 'number' ? version : undefined, - }, - owner?.entityId - ) + const result = await applyMarkdownToLiveFileDoc(fileDocRoom(target), markdown, { + version: typeof version === 'number' ? version : undefined, + }) res.writeHead(200, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ applied: result === 'applied', status: result })) } catch (error) { @@ -242,30 +237,28 @@ export function createHttpHandler(roomManager: IRoomManager, logger: Logger) { if (req.method === 'POST' && req.url === '/api/file-doc/retire') { try { - const { projectId, fileId, retiredDocId, replacementDocId } = JSON.parse( - await readRequestBody(req) - ) + const input = JSON.parse(await readRequestBody(req)) + const { retiredDocId, replacementDocId } = input + const target = parseFileDocTarget(input) if ( - !isNonEmptyString(projectId) || - /[/:]/.test(projectId) || - !isNonEmptyString(fileId) || - /[/:]/.test(fileId) || + !target?.owner || + !fileDocOwnerAdapter(target.owner).tracksLifecycle || !isNonEmptyString(retiredDocId) || retiredDocId.length > 128 || !isNonEmptyString(replacementDocId) || replacementDocId.length > 128 || retiredDocId === replacementDocId ) - return sendError(res, 'Invalid Project document retirement', 400) - const result = await retireLiveProjectFileDocument( - { projectId, fileId, retiredDocId, replacementDocId }, + return sendError(res, 'Invalid file document retirement', 400) + const result = await retireLiveFileDocument( + { fileId: target.fileId, owner: target.owner, retiredDocId, replacementDocId }, roomManager.io ) res.writeHead(200, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ status: result.status })) } catch (error) { - logger.error('Error retiring Project document:', error) - sendError(res, 'Failed to retire Project document') + logger.error('Error retiring file document:', error) + sendError(res, 'Failed to retire file document') } return } @@ -273,24 +266,16 @@ export function createHttpHandler(roomManager: IRoomManager, logger: Logger) { if (req.method === 'POST' && req.url === '/api/file-doc/invalidate') { try { const body = await readRequestBody(req) - const { fileId, version, owner } = JSON.parse(body) - if (!isNonEmptyString(fileId)) return sendError(res, 'Invalid fileId', 400) - if ( - owner !== undefined && - (!owner || - owner.entityType !== 'project' || - !isNonEmptyString(owner.entityId) || - /[/:]/.test(owner.entityId) || - /[/:]/.test(fileId)) - ) - return sendError(res, 'Invalid file owner', 400) + const input = JSON.parse(body) + const { version } = input + const target = parseFileDocTarget(input) + if (!target) return sendError(res, 'Invalid file target', 400) + const { fileId } = target if (!Number.isSafeInteger(version) || version <= 0) { return sendError(res, 'Invalid version', 400) } - const room = owner - ? projectFileDocRoom(owner.entityId, fileId) - : ({ type: ROOM_TYPES.WORKSPACE_FILE_DOC, id: fileId } as const) - const result = await invalidateLiveFileDocument(fileId, version, owner?.entityId) + const room = fileDocRoom(target) + const result = await invalidateLiveFileDocument(room, version) const payload: FileDocInvalidated = { fileId, version, @@ -299,7 +284,7 @@ export function createHttpHandler(roomManager: IRoomManager, logger: Logger) { } if (result.status === 'applied') roomManager.io - .to([roomName(room), fileDocAdmissionRoom(fileId, owner?.entityId)]) + .to([roomName(room), fileDocAdmissionRoom(target)]) .emit(FILE_DOC_EVENTS.INVALIDATED, payload) res.writeHead(200, { 'Content-Type': 'application/json' }) res.end(JSON.stringify({ status: result.status })) diff --git a/apps/sim/app/api/chat/utils.ts b/apps/sim/app/api/chat/utils.ts index 7775d3032a8..3698bc1eb87 100644 --- a/apps/sim/app/api/chat/utils.ts +++ b/apps/sim/app/api/chat/utils.ts @@ -7,11 +7,11 @@ import { type DeploymentAuthResource, setDeploymentAuthCookie, } from '@/lib/core/security/deployment' -import { - type DeploymentAuthBody, - type DeploymentAuthResult, - validateDeploymentAuth, -} from '@/lib/core/security/deployment-auth' +import { validateDeploymentAuth } from '@/lib/core/security/deployment-auth' +import type { + DeploymentAuthBody, + DeploymentAuthResult, +} from '@/lib/core/security/deployment-credentials' export async function setChatAuthCookie( response: NextResponse, diff --git a/apps/sim/app/api/desktop/tool/file/route.ts b/apps/sim/app/api/desktop/tool/file/route.ts index 9829a64e681..9a2e923dc03 100644 --- a/apps/sim/app/api/desktop/tool/file/route.ts +++ b/apps/sim/app/api/desktop/tool/file/route.ts @@ -21,8 +21,8 @@ import { import { PayloadSizeLimitError, readStreamToBufferWithLimit } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { workspaceFileVfsPath } from '@/lib/uploads/contexts/workspace' +import { encodeFilenameForHeader } from '@/lib/uploads/server/delivery' import { internalFileErrorPolicies } from '@/lib/workspace-files/api' -import { encodeFilenameForHeader } from '@/app/api/files/utils' export const dynamic = 'force-dynamic' diff --git a/apps/sim/app/api/files/copy/route.ts b/apps/sim/app/api/files/copy/route.ts new file mode 100644 index 00000000000..d4755885e0b --- /dev/null +++ b/apps/sim/app/api/files/copy/route.ts @@ -0,0 +1,21 @@ +import { copyFileItemsContract } from '@/lib/api/contracts/file-copy' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { presentCopiedFileItems } from '@/lib/workspace-files/api/copy-presenter' +import { copyFileItems } from '@/lib/workspace-files/application/copy-file-items' +import { fileCopyOperation } from '@/lib/workspace-files/application/copy-operation' + +export const POST = defineInternalJsonRoute({ + contract: copyFileItemsContract, + auth: internalSessionAuth, + operation: fileCopyOperation, + rateLimit: internalRateLimits.user({ bucketName: 'files.copy' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ body }) => body, + useCase: copyFileItems, + present: presentCopiedFileItems, +}) diff --git a/apps/sim/app/api/files/export/[id]/route.ts b/apps/sim/app/api/files/export/[id]/route.ts index 9ec91812e13..1378e64c609 100644 --- a/apps/sim/app/api/files/export/[id]/route.ts +++ b/apps/sim/app/api/files/export/[id]/route.ts @@ -13,6 +13,7 @@ import { captureServerEvent } from '@/lib/posthog/server' import type { StorageContext } from '@/lib/uploads/config' import { getServeStoragePrefix } from '@/lib/uploads/config' import { downloadFile } from '@/lib/uploads/core/storage-service' +import { encodeFilenameForHeader } from '@/lib/uploads/server/delivery' import { extractEmbeddedFileRefs } from '@/lib/uploads/server/embedded-image-refs' import { createMarkdownExport, @@ -27,7 +28,6 @@ import { getWorkspaceFileSize } from '@/lib/uploads/shared/types' import { storedFileId } from '@/lib/uploads/utils/embedded-image-ref' import { formatFileSize } from '@/lib/uploads/utils/file-utils' import { verifyFileAccess } from '@/app/api/files/authorization' -import { encodeFilenameForHeader } from '@/app/api/files/utils' const logger = createLogger('FilesExportAPI') diff --git a/apps/sim/app/api/files/public/[token]/content/route.test.ts b/apps/sim/app/api/files/public/[token]/content/route.test.ts deleted file mode 100644 index c80e4688efe..00000000000 --- a/apps/sim/app/api/files/public/[token]/content/route.test.ts +++ /dev/null @@ -1,90 +0,0 @@ -import { createRouteContext } from '@sim/testing/helpers/http' -import { publicSharesMock, publicSharesMockFns } from '@sim/testing/mocks/public-shares.mock' -import { createMockRequest } from '@sim/testing/mocks/request.mock' -import { storageServiceMock, storageServiceMockFns } from '@sim/testing/mocks/storage-service.mock' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' -import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' - -const { mockEnforceRateLimit, mockValidateDeploymentAuth, mockResolveServableDoc } = vi.hoisted( - () => ({ - mockEnforceRateLimit: vi.fn(), - mockValidateDeploymentAuth: vi.fn(), - mockResolveServableDoc: vi.fn(), - }) -) - -vi.mock('@/lib/public-shares/share-manager', () => publicSharesMock) - -vi.mock('@/lib/public-shares/rate-limit', () => ({ - enforcePublicFileRateLimit: mockEnforceRateLimit, -})) - -vi.mock('@/lib/core/security/deployment-auth', () => ({ - validateDeploymentAuth: mockValidateDeploymentAuth, -})) - -vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock) - -vi.mock('@/lib/uploads/documents/compile', () => ({ - resolveServableDoc: mockResolveServableDoc, -})) - -import { GET } from '@/app/api/files/public/[token]/content/route' - -const { mockResolveActiveShareByToken } = publicSharesMockFns - -const mockDownloadFile = storageServiceMockFns.mockDownloadFile - -const params = (token = 'tok_1') => createRouteContext({ token }) -const request = (token = 'tok_1') => - createMockRequest({ url: `http://localhost/api/files/public/${token}/content` }) - -const passwordShare = { - share: { id: 'sh_1', token: 'tok_1', authType: 'password', password: 'enc:secret' }, - file: { - id: 'wf_1', - key: 'workspace/ws/secret-key.pdf', - workspaceId: 'ws-1', - originalName: 'report.pdf', - contentType: 'application/pdf', - sizeBytes: 4, - }, - workspaceName: 'Acme', - ownerName: 'Jane', -} - -describe('GET /api/files/public/[token]/content', () => { - beforeEach(() => { - mockEnforceRateLimit.mockResolvedValue(null) - mockResolveActiveShareByToken.mockResolvedValue(passwordShare) - mockDownloadFile.mockResolvedValue(Buffer.from('data')) - mockResolveServableDoc.mockResolvedValue({ kind: 'passthrough' }) - }) - - it('returns 401 and never reads storage when a password share is unauthorized', async () => { - mockValidateDeploymentAuth.mockResolvedValueOnce({ - authorized: false, - error: 'auth_required_password', - }) - const res = await GET(request(), params()) - expect(res.status).toBe(401) - expect((await res.json()).error).toBe('auth_required_password') - expect(mockDownloadFile).not.toHaveBeenCalled() - }) - - it('answers 413 rather than 500 when the shared file is too large to serve resident', async () => { - mockValidateDeploymentAuth.mockResolvedValueOnce({ authorized: true }) - mockDownloadFile.mockRejectedValueOnce( - new PayloadSizeLimitError({ - label: 'storage download', - maxBytes: MAX_BUFFERED_TRANSFER_BYTES, - observedBytes: 5 * 1024 * 1024 * 1024, - }) - ) - - const res = await GET(request(), params()) - - expect(res.status).toBe(413) - }) -}) diff --git a/apps/sim/app/api/files/public/[token]/content/route.ts b/apps/sim/app/api/files/public/[token]/content/route.ts index 06114034559..d8fcd3a3d3d 100644 --- a/apps/sim/app/api/files/public/[token]/content/route.ts +++ b/apps/sim/app/api/files/public/[token]/content/route.ts @@ -1,173 +1,65 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' -import type { NextRequest } from 'next/server' -import { NextResponse } from 'next/server' +import { type NextRequest, NextResponse } from 'next/server' import { getPublicFileContentContract } from '@/lib/api/contracts/public-shares' import { parseRequest } from '@/lib/api/server' -import { validateDeploymentAuth } from '@/lib/core/security/deployment-auth' -import { generateRequestId } from '@/lib/core/utils/request' -import { assertKnownSizeWithinLimit } from '@/lib/core/utils/stream-limits' +import { getClientIp } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit' -import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager' -import { downloadFile } from '@/lib/uploads/core/storage-service' -import { resolveServableDoc } from '@/lib/uploads/documents/compile' -import { resolveServableImageBytes } from '@/lib/uploads/server/image-derivative' -import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' -import { isSimPageSource, SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile' -import { renderSimPageDocumentWithAssets } from '@/lib/workspace-files/page-document.server' import { - createErrorResponse, - createFileResponse, - FileNotFoundError, - getContentType, -} from '@/app/api/files/utils' + publicFileAuthDenied, + publicFileBinaryErrorResponse, + publicFileShareCredential, +} from '@/lib/public-shares/api' +import { + authorizePublicFileShare, + checkPublicFileShareContent, + readPublicFileShareContent, +} from '@/lib/public-shares/application' +import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit' +import { FILE_CACHE_CONTROL } from '@/lib/uploads/server/delivery' +import { createConditionalFileResponse } from '@/app/api/files/utils' export const dynamic = 'force-dynamic' - const logger = createLogger('PublicFileContentAPI') -/** - * GET /api/files/public/[token]/content - * Public, unauthenticated bytes for a shared file. Authorized solely by an active - * share token — never by workspace membership. 404 for unknown/inactive/deleted - * shares. Disposition (inline vs attachment) is resolved from the file type by - * {@link createFileResponse}; the public page's Download button uses ``. - * - * Generated office docs are stored as source; {@link resolveServableDoc} swaps in - * their prebuilt compiled binary (read-only, never compiles). Uploaded binaries - * pass through untouched, except under `preview=1`, where a format no browser - * decodes is substituted with a renderable derivative. A generated doc whose - * compiled artifact isn't built yet returns 409 rather than serving raw source - * under a binary content type. - */ +/** Binary delivery rechecks the current bearer grant and preserves the token URL's revalidation policy. */ export const GET = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ token: string }> }) => { - const requestId = generateRequestId() - try { const limited = await enforcePublicFileRateLimit(request, 'content') if (limited) return limited - const parsed = await parseRequest(getPublicFileContentContract, request, context) if (!parsed.success) return parsed.response - const { token } = parsed.data.params - const preview = parsed.data.query.preview === '1' - - const resolved = await resolveActiveShareByToken(token) - if (!resolved) { - throw new FileNotFoundError('Not found') - } - - const auth = await validateDeploymentAuth( - requestId, - resolved.share, - request, - undefined, - 'file' - ) - if (!auth.authorized) { - return NextResponse.json({ error: auth.error ?? 'auth_required_password' }, { status: 401 }) - } - - const { file } = resolved - // The same ceiling the authenticated serve route reads this object under - // (`fetchWorkspaceFileBuffer`). Without it a share link is the one way to ask - // an unauthenticated caller's request to hold a 5 GB workspace file resident. - const raw = await downloadFile({ - key: file.key, - context: 'workspace', - maxBytes: MAX_BUFFERED_TRANSFER_BYTES, + const auth = await authorizePublicFileShare({ + token: parsed.data.params.token, + credential: await publicFileShareCredential(request.cookies.getAll(), getClientIp(request)), }) - - const servable = file.workspaceId - ? await resolveServableDoc(file.workspaceId, raw, file.originalName, { - sourceMime: file.contentType, - }) - : ({ kind: 'passthrough' } as const) - - if (servable.kind === 'unavailable') { - logger.info('Public shared doc not yet compiled', { token, key: file.key }) - return NextResponse.json( - { error: 'This document is still being prepared. Please try again shortly.' }, - { status: 409 } - ) - } - - // This response is `nosniff`, so a stored `application/octet-stream` refuses to render - // even though the bytes are fine. Resolving from the filename also keeps this route on - // the same inline allowlist as the workspace serve route. - let buffer = raw - let contentType = getContentType(file.originalName) - if (servable.kind === 'artifact') { - buffer = servable.buffer - contentType = servable.contentType - } else if ( - // Sim pages store an extensionless name — the record type marks them; - // legacy pages still carry .html. - (file.contentType === SIM_PAGE_CONTENT_TYPE || - file.originalName.toLowerCase().endsWith('.html')) && - isSimPageSource(raw.toString('utf8')) - ) { - // The pdf model for pages: the stored .html is source; a share serves - // the fully styled compiled document, matching the preview and serve - // routes. sim: links resolve to workspace routes (a viewer without - // workspace access simply lands on the sign-in gate). - buffer = Buffer.from( - await renderSimPageDocumentWithAssets(raw.toString('utf8'), { - workspaceId: file.workspaceId ?? undefined, - }), - 'utf8' - ) - contentType = 'text/html' - } else if (preview) { - // Only for a render request: the Download button omits `preview`, so a saved - // file is always the bytes that were shared. - const image = await resolveServableImageBytes(raw, file.key) - if (image) ({ buffer, contentType } = image) + if (!auth.authorized) return publicFileAuthDenied(auth) + if (request.method === 'HEAD') { + await checkPublicFileShareContent(auth.grant) + return new NextResponse(null, { + headers: { + 'Cache-Control': FILE_CACHE_CONTROL.revalidate, + 'X-Content-Type-Options': 'nosniff', + }, + }) } - - // Bounding the source read does not bound the response: each branch above can - // replace it with bytes fetched or produced separately — a compiled artifact, a - // page with its images inlined, a transcoded derivative. This is an anonymous - // route, so the bytes it actually returns are what has to fit. - assertKnownSizeWithinLimit(buffer.length, MAX_BUFFERED_TRANSFER_BYTES, 'served file response') - - logger.info('Public shared file served', { token, key: file.key, size: buffer.length }) - - // Anonymous access: null actor (owner-as-actor would misread as a self-download). - recordAudit({ - workspaceId: file.workspaceId ?? null, - actorId: null, - action: AuditAction.FILE_DOWNLOADED, - resourceType: AuditResourceType.FILE, - resourceId: file.id, - resourceName: file.originalName, - description: `Public share download of "${file.originalName}"`, - metadata: { - access: 'public_share', - anonymous: true, - sharedByUserId: file.userId, - fileName: file.originalName, - bytes: buffer.length, - }, + const result = await readPublicFileShareContent({ + grant: auth.grant, + preview: parsed.data.query.preview === '1', request, }) - - // Revalidate every request: a shared file can be unshared, edited, or deleted, - // so the fixed token URL must never serve stale bytes from a long-lived cache. - return createFileResponse({ - buffer, - contentType, - filename: file.originalName, - cacheControl: 'private, no-cache, must-revalidate', - }) + return createConditionalFileResponse( + { + buffer: result.buffer, + contentType: result.contentType, + filename: result.file.originalName, + cacheControl: FILE_CACHE_CONTROL.revalidate, + }, + request.headers.get('if-none-match') + ) } catch (error) { logger.error('Error serving public shared file:', error) - if (error instanceof FileNotFoundError) { - return createErrorResponse(error) - } - return createErrorResponse(error instanceof Error ? error : new Error('Failed to serve file')) + return publicFileBinaryErrorResponse(error, 'Failed to serve file') } } ) diff --git a/apps/sim/app/api/files/public/[token]/inline/route.test.ts b/apps/sim/app/api/files/public/[token]/inline/route.test.ts deleted file mode 100644 index 33bbace8cd1..00000000000 --- a/apps/sim/app/api/files/public/[token]/inline/route.test.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { createRouteContext } from '@sim/testing/helpers/http' -import { publicSharesMock, publicSharesMockFns } from '@sim/testing/mocks/public-shares.mock' -import { createMockRequest } from '@sim/testing/mocks/request.mock' -import { storageServiceMock, storageServiceMockFns } from '@sim/testing/mocks/storage-service.mock' -import { NextResponse } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' -import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' -import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' - -const { mockRateLimit, mockValidateAuth, mockResolveImage } = vi.hoisted(() => ({ - mockRateLimit: vi.fn(), - mockValidateAuth: vi.fn(), - mockResolveImage: vi.fn(), -})) - -vi.mock('@/lib/public-shares/share-manager', () => publicSharesMock) -vi.mock('@/lib/public-shares/rate-limit', () => ({ enforcePublicFileRateLimit: mockRateLimit })) -vi.mock('@/lib/core/security/deployment-auth', () => ({ validateDeploymentAuth: mockValidateAuth })) -vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock) -vi.mock('@/lib/uploads/server/inline-image', () => ({ - resolveWorkspaceInlineImage: mockResolveImage, -})) - -import { GET } from '@/app/api/files/public/[token]/inline/route' - -const { mockResolveActiveShareByToken: mockResolveShare } = publicSharesMockFns - -const mockDownloadFile = storageServiceMockFns.mockDownloadFile - -const TOKEN = 'tok_share_123456' -const DOC_KEY = 'workspace/ws-1/doc.md' -const IMG_KEY = 'workspace/ws-1/photo.png' -const FILE_ID = 'wf_YwDXi8eWOkTxn0sbgChlB' -const PNG = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00]) - -const params = createRouteContext({ token: TOKEN }) -const req = (q: string) => - createMockRequest({ url: `http://localhost/api/files/public/${TOKEN}/inline?${q}` }) - -const share = { - share: { id: 'sh_1', token: TOKEN, authType: 'public' }, - file: { id: 'wf_doc', key: DOC_KEY, workspaceId: 'ws-1', originalName: 'doc.md' }, - workspaceName: 'Acme', - ownerName: 'Jane', -} - -/** doc bytes embed the image via the view form; image bytes are a real PNG */ -function downloadByKey(docContent = `![a](/api/files/view/${FILE_ID})`) { - return ({ key }: { key: string }) => - Promise.resolve(key === DOC_KEY ? Buffer.from(docContent, 'utf-8') : PNG) -} - -describe('GET /api/files/public/[token]/inline', () => { - beforeEach(() => { - mockRateLimit.mockResolvedValue(null) - mockResolveShare.mockResolvedValue(share) - mockValidateAuth.mockResolvedValue({ authorized: true }) - mockResolveImage.mockResolvedValue({ - key: IMG_KEY, - contentType: 'image/png', - filename: 'photo.png', - }) - mockDownloadFile.mockImplementation(downloadByKey()) - }) - - it('rejects exhausted image budgets before share lookup, authentication, or storage reads', async () => { - const limited = NextResponse.json( - { error: 'Too many requests. Please try again later.' }, - { status: 429, headers: { 'Retry-After': '60' } } - ) - mockRateLimit.mockResolvedValue(limited) - - const response = await GET(req(`fileId=${FILE_ID}`), params) - - expect(response.status).toBe(429) - expect(response.headers.get('Retry-After')).toBe('60') - expect(mockResolveShare).not.toHaveBeenCalled() - expect(mockValidateAuth).not.toHaveBeenCalled() - expect(mockResolveImage).not.toHaveBeenCalled() - expect(mockDownloadFile).not.toHaveBeenCalled() - }) - - it.each(['fileId', 'key'] as const)( - 'serves a referenced %s beyond the export bundle limit', - async (kind) => { - const earlierImages = Array.from( - { length: 50 }, - (_, index) => `![earlier](/api/files/view/wf_earlier_${index})` - ) - const src = - kind === 'fileId' - ? `/api/files/view/${FILE_ID}` - : `/api/files/serve/${encodeURIComponent(IMG_KEY)}` - mockDownloadFile.mockImplementation( - downloadByKey([...earlierImages, `![last](${src})`].join('\n\n')) - ) - - const response = await GET( - req(`${kind}=${encodeURIComponent(kind === 'fileId' ? FILE_ID : IMG_KEY)}`), - params - ) - - expect(response.status).toBe(200) - expect(mockResolveImage).toHaveBeenCalledExactlyOnceWith('ws-1', { - [kind]: kind === 'fileId' ? FILE_ID : IMG_KEY, - }) - } - ) - - it('404s when the reference is not embedded in the shared document', async () => { - mockDownloadFile.mockImplementation(downloadByKey('no images here')) - const res = await GET(req(`fileId=${FILE_ID}`), params) - expect(res.status).toBe(404) - expect(mockResolveImage).not.toHaveBeenCalled() - }) - - it.each([ - `[link](/api/files/view/${FILE_ID})`, - `\`![image](/api/files/view/${FILE_ID})\``, - ``, - ``, - `
`, - `inline text`, - `![external](https://example.com/api/files/view/${FILE_ID})`, - ])('does not extend a share to an image mentioned as %s', async (source) => { - mockDownloadFile.mockImplementation(downloadByKey(source)) - - const response = await GET(req(`fileId=${FILE_ID}`), params) - - expect(response.status).toBe(404) - expect(mockResolveImage).not.toHaveBeenCalled() - expect(mockDownloadFile).toHaveBeenCalledTimes(1) - }) - - it('404s when the referenced file is not in the document workspace', async () => { - mockResolveImage.mockResolvedValue(null) - const res = await GET(req(`fileId=${FILE_ID}`), params) - expect(res.status).toBe(404) - }) - - it('401s and never reads storage when the share is unauthorized', async () => { - mockValidateAuth.mockResolvedValue({ authorized: false, error: 'auth_required_password' }) - const res = await GET(req(`fileId=${FILE_ID}`), params) - expect(res.status).toBe(401) - expect(mockDownloadFile).not.toHaveBeenCalled() - }) - - it('bounds both reads: the doc scan tightly, the served image at the transfer ceiling', async () => { - await GET(req(`fileId=${FILE_ID}`), params) - - const [docRead, imageRead] = mockDownloadFile.mock.calls.map(([args]) => args) - // The doc is scanned and discarded (and decoded to UTF-16 on top of the buffer), - // so it must not inherit the ceiling of a file this route actually serves. - expect(docRead.key).toBe(DOC_KEY) - expect(docRead.maxBytes).toBeGreaterThan(0) - expect(docRead.maxBytes).toBeLessThan(MAX_BUFFERED_TRANSFER_BYTES) - expect(imageRead.key).toBe(IMG_KEY) - expect(imageRead.maxBytes).toBe(MAX_BUFFERED_TRANSFER_BYTES) - }) - - it('fails the referenced-by-doc gate closed when the document is too large to scan', async () => { - mockDownloadFile.mockImplementation(({ key }: { key: string }) => - key === DOC_KEY - ? Promise.reject( - new PayloadSizeLimitError({ - label: 'storage download', - maxBytes: 10 * 1024 * 1024, - observedBytes: 5 * 1024 * 1024 * 1024, - }) - ) - : Promise.resolve(PNG) - ) - - const res = await GET(req(`fileId=${FILE_ID}`), params) - - expect(res.status).toBe(404) - // The gate could not be verified, so the image must never be read at all. - expect(mockDownloadFile).toHaveBeenCalledTimes(1) - }) -}) diff --git a/apps/sim/app/api/files/public/[token]/inline/route.ts b/apps/sim/app/api/files/public/[token]/inline/route.ts index 77e52efe587..4cc895266eb 100644 --- a/apps/sim/app/api/files/public/[token]/inline/route.ts +++ b/apps/sim/app/api/files/public/[token]/inline/route.ts @@ -1,141 +1,65 @@ -import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' import { createLogger } from '@sim/logger' -import type { NextRequest } from 'next/server' -import { NextResponse } from 'next/server' +import { type NextRequest, NextResponse } from 'next/server' import { getPublicInlineFileContract } from '@/lib/api/contracts/public-shares' import { parseRequest } from '@/lib/api/server' -import { validateDeploymentAuth } from '@/lib/core/security/deployment-auth' -import { generateRequestId } from '@/lib/core/utils/request' -import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' +import { getClientIp } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + publicFileAuthDenied, + publicFileBinaryErrorResponse, + publicFileShareCredential, +} from '@/lib/public-shares/api' +import { + authorizePublicFileShare, + readPublicFileShareInline, +} from '@/lib/public-shares/application' import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit' -import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager' -import { downloadFile } from '@/lib/uploads/core/storage-service' -import { hasEmbeddedFileRef } from '@/lib/uploads/server/embedded-image-refs' -import { resolveWorkspaceInlineImage } from '@/lib/uploads/server/inline-image' -import { serveInlineImage } from '@/app/api/files/serve-inline-image' -import { createErrorResponse, FileNotFoundError } from '@/app/api/files/utils' +import { FILE_CACHE_CONTROL } from '@/lib/uploads/server/delivery' +import { createConditionalFileResponse } from '@/app/api/files/utils' export const dynamic = 'force-dynamic' - const logger = createLogger('PublicInlineFileAPI') -/** - * Ceiling on the shared document read for the referenced-by-doc gate below. - * - * Far tighter than the ceiling on a file this route SERVES, because these bytes are - * never served — they are scanned for image references and discarded, and scanning - * decodes them to UTF-16 on top of the buffer, so the resident cost is roughly double - * the read. A share can point at any workspace file, admitted at 5 GB, and this route - * is anonymous; nothing a person writes as a document approaches even this bound. - */ -const MAX_INLINE_REF_SCAN_BYTES = 10 * 1024 * 1024 - -/** - * GET /api/files/public/[token]/inline?key=|fileId= - * - * Cascades a markdown document's public share to the images it embeds, so a logged-out viewer sees them - * instead of broken icons. The share grants the document bytes; this route extends that grant to the - * document's referenced images only, behind three gates that together hold the security boundary: - * - * 1. Referenced-by-doc — the requested key/id must be embedded as an image by the shared document's - * current bytes. The token is a capability for the document and its embeds, never an arbitrary - * workspace file, and never one the document merely links to or mentions in prose. - * 2. Same-workspace — the referenced file must be a `workspace` file in the document's own workspace - * ({@link resolveWorkspaceInlineImage}). This blocks any cross-workspace reference (which an author - * can write but must never resolve) from loading. - * 3. Content-truth — the served content type is sniffed from the bytes, not the client-declared type, - * and only genuine raster images are served. A file spoofing `image/png` while holding HTML/SVG is - * refused rather than rendered inline. - */ +/** The bearer operation extends a document's grant only to its current same-owner raster embeds. */ export const GET = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ token: string }> }) => { - const requestId = generateRequestId() - try { const limited = await enforcePublicFileRateLimit(request, 'inline') if (limited) return limited - const parsed = await parseRequest(getPublicInlineFileContract, request, context) if (!parsed.success) return parsed.response - const { token } = parsed.data.params - const ref = parsed.data.query - - const resolved = await resolveActiveShareByToken(token) - if (!resolved) { - throw new FileNotFoundError('Not found') - } - - const auth = await validateDeploymentAuth( - requestId, - resolved.share, - request, - undefined, - 'file' - ) - if (!auth.authorized) { - return NextResponse.json({ error: auth.error ?? 'auth_required_password' }, { status: 401 }) - } - - const { file: doc } = resolved - if (!doc.workspaceId) { - throw new FileNotFoundError('Not found') - } - - // Referenced-by-doc gate: the share grants exactly the images the document embeds. - // A document too large to scan fails the gate like any other unverifiable - // reference — the grant cannot be extended to an embed we were unable to confirm. - let docText: string - try { - const docBuffer = await downloadFile({ - key: doc.key, - context: 'workspace', - maxBytes: MAX_INLINE_REF_SCAN_BYTES, + const auth = await authorizePublicFileShare({ + token: parsed.data.params.token, + credential: await publicFileShareCredential(request.cookies.getAll(), getClientIp(request)), + }) + if (!auth.authorized) return publicFileAuthDenied(auth) + if (request.method === 'HEAD') { + return new NextResponse(null, { + status: 405, + headers: { + Allow: 'GET', + 'Cache-Control': FILE_CACHE_CONTROL.noStore, + 'X-Content-Type-Options': 'nosniff', + }, }) - docText = docBuffer.toString('utf-8') - } catch (error) { - if (!isPayloadSizeLimitError(error)) throw error - logger.info('Shared document too large to scan for embedded references', { token }) - throw new FileNotFoundError('Not found') - } - const target = ref.fileId ? { fileId: ref.fileId } : ref.key ? { key: ref.key } : null - if (!target || !hasEmbeddedFileRef(docText, target)) { - throw new FileNotFoundError('Not found') } - - // Same-workspace gate: resolve scoped to the document's own workspace. - const image = await resolveWorkspaceInlineImage(doc.workspaceId, ref) - if (!image) { - throw new FileNotFoundError('Not found') - } - - // Content-truth gate (`sniff`): render only genuine raster image bytes; audit after. - const response = await serveInlineImage(image, { sniff: true }) - - // Anonymous access: null actor (owner-as-actor would misread as a self-download). - recordAudit({ - workspaceId: doc.workspaceId, - actorId: null, - action: AuditAction.FILE_DOWNLOADED, - resourceType: AuditResourceType.FILE, - resourceName: image.filename, - description: `Public share inline image "${image.filename}"`, - metadata: { - access: 'public_share', - anonymous: true, - inline: true, - sharedByUserId: doc.userId, - }, + const result = await readPublicFileShareInline({ + grant: auth.grant, + ...parsed.data.query, request, }) - - return response + return createConditionalFileResponse( + { + buffer: result.buffer, + contentType: result.contentType, + filename: result.servedFileName, + cacheControl: FILE_CACHE_CONTROL.revalidate, + }, + request.headers.get('if-none-match') + ) } catch (error) { - if (error instanceof FileNotFoundError) { - return createErrorResponse(error) - } logger.error('Error serving public inline image:', error) - return createErrorResponse(error instanceof Error ? error : new Error('Failed to serve file')) + return publicFileBinaryErrorResponse(error, 'Failed to serve file') } } ) diff --git a/apps/sim/app/api/files/public/[token]/otp/route.test.ts b/apps/sim/app/api/files/public/[token]/otp/route.test.ts deleted file mode 100644 index ee33611b9f6..00000000000 --- a/apps/sim/app/api/files/public/[token]/otp/route.test.ts +++ /dev/null @@ -1,199 +0,0 @@ -import { requestUtilsMockFns } from '@sim/testing' -import { createRouteContext } from '@sim/testing/helpers/http' -import { emailMailerMock, emailMailerMockFns } from '@sim/testing/mocks/email-mailer.mock' -import { emailTemplatesMock, emailTemplatesMockFns } from '@sim/testing/mocks/email-templates.mock' -import { publicSharesMock, publicSharesMockFns } from '@sim/testing/mocks/public-shares.mock' -import { rateLimiterMock, rateLimiterMockFns } from '@sim/testing/mocks/rate-limiter.mock' -import { createMockRequest } from '@sim/testing/mocks/request.mock' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - mockIsEmailAllowed, - mockSetDeploymentAuthCookie, - mockGenerateOTP, - mockStoreOTP, - mockGetOTP, - mockDeleteOTP, - mockIncrementOTPAttempts, - mockDecodeOTPValue, - mockAfterResponse, -} = vi.hoisted(() => ({ - mockIsEmailAllowed: vi.fn(), - mockSetDeploymentAuthCookie: vi.fn(), - mockGenerateOTP: vi.fn(), - mockStoreOTP: vi.fn(), - mockGetOTP: vi.fn(), - mockDeleteOTP: vi.fn(), - mockIncrementOTPAttempts: vi.fn(), - mockDecodeOTPValue: vi.fn(), - mockAfterResponse: vi.fn(), -})) - -vi.mock('@/lib/public-shares/share-manager', () => publicSharesMock) -vi.mock('@/lib/core/security/deployment', () => ({ - isEmailAllowed: mockIsEmailAllowed, - setDeploymentAuthCookie: mockSetDeploymentAuthCookie, -})) -vi.mock('@/lib/core/security/otp', () => ({ - generateOTP: mockGenerateOTP, - storeOTP: mockStoreOTP, - getOTP: mockGetOTP, - deleteOTP: mockDeleteOTP, - incrementOTPAttempts: mockIncrementOTPAttempts, - decodeOTPValue: mockDecodeOTPValue, - MAX_OTP_ATTEMPTS: 5, - OTP_IP_RATE_LIMIT: { maxTokens: 10, refillRate: 10, refillIntervalMs: 1000 }, - OTP_EMAIL_RATE_LIMIT: { maxTokens: 3, refillRate: 3, refillIntervalMs: 1000 }, - OTP_RESOURCE_RATE_LIMIT: { maxTokens: 100, refillRate: 100, refillIntervalMs: 1000 }, -})) -vi.mock('@/components/emails', () => emailTemplatesMock) -vi.mock('@/lib/messaging/email/mailer', () => emailMailerMock) -vi.mock('@/lib/core/rate-limiter', () => rateLimiterMock) -vi.mock('@/lib/core/utils/after-response', () => ({ - afterResponse: mockAfterResponse, -})) - -import { PUT, POST as routePost } from '@/app/api/files/public/[token]/otp/route' - -const { mockSendEmail } = emailMailerMockFns -const { mockRenderOTPEmail } = emailTemplatesMockFns -const { mockResolveActiveShareByToken } = publicSharesMockFns - -const mockCheckRateLimitDirect = rateLimiterMockFns.mockCheckRateLimitDirect - -const POST: typeof routePost = async (...args) => { - const response = await routePost(...args) - const task = mockAfterResponse.mock.calls.at(-1)?.[0] as (() => Promise) | undefined - if (task) await task() - return response -} - -const params = (token = 'tok_1') => createRouteContext({ token }) -const post = (email: string, token = 'tok_1') => - createMockRequest({ - method: 'POST', - url: `http://localhost/api/files/public/${token}/otp`, - body: { email }, - }) -const put = (email: string, otp: string, token = 'tok_1') => - createMockRequest({ - method: 'PUT', - url: `http://localhost/api/files/public/${token}/otp`, - body: { email, otp }, - }) - -const emailShare = { - share: { id: 'sh_1', authType: 'email', password: null, allowedEmails: ['@acme.com'] }, - file: { originalName: 'report.pdf' }, -} - -describe('POST /api/files/public/[token]/otp', () => { - beforeEach(() => { - mockCheckRateLimitDirect.mockResolvedValue({ allowed: true }) - mockResolveActiveShareByToken.mockResolvedValue(emailShare) - mockIsEmailAllowed.mockReturnValue(true) - mockGenerateOTP.mockReturnValue('123456') - mockRenderOTPEmail.mockResolvedValue('') - mockSendEmail.mockResolvedValue({ success: true }) - }) - - it('returns the generic acceptance response for an email not on the allow-list', async () => { - mockIsEmailAllowed.mockReturnValueOnce(false) - const res = await POST(post('user@evil.com'), params()) - expect(res.status).toBe(200) - await expect(res.json()).resolves.toEqual({ message: 'Verification code sent' }) - expect(mockCheckRateLimitDirect).toHaveBeenCalledTimes(1) - expect(mockStoreOTP).not.toHaveBeenCalled() - expect(mockSendEmail).not.toHaveBeenCalled() - }) - - it('does not consume a send bucket for a rejected email without a client IP', async () => { - requestUtilsMockFns.mockGetClientIp.mockReturnValueOnce(null) - mockIsEmailAllowed.mockReturnValueOnce(false) - - const res = await POST(post('user@evil.com'), params()) - - expect(res.status).toBe(200) - await expect(res.json()).resolves.toEqual({ message: 'Verification code sent' }) - expect(mockAfterResponse).toHaveBeenCalledTimes(1) - expect(mockCheckRateLimitDirect).not.toHaveBeenCalled() - expect(mockStoreOTP).not.toHaveBeenCalled() - expect(mockSendEmail).not.toHaveBeenCalled() - }) - - it('returns 429 when the IP rate limit is exceeded', async () => { - mockCheckRateLimitDirect.mockResolvedValueOnce({ allowed: false, retryAfterMs: 1000 }) - const res = await POST(post('user@acme.com'), params()) - expect(res.status).toBe(429) - expect(res.headers.get('Retry-After')).toBe('1') - }) - - it('returns the generic acceptance response when the email rate limit is exceeded', async () => { - mockCheckRateLimitDirect - .mockResolvedValueOnce({ allowed: true }) - .mockResolvedValueOnce({ allowed: true }) - .mockResolvedValueOnce({ allowed: false, retryAfterMs: 1000 }) - - const res = await POST(post('user@acme.com'), params()) - - expect(res.status).toBe(200) - await expect(res.json()).resolves.toEqual({ message: 'Verification code sent' }) - expect(mockStoreOTP).not.toHaveBeenCalled() - expect(mockSendEmail).not.toHaveBeenCalled() - }) - - it('retains resource and email backstops when the client IP cannot be resolved', async () => { - requestUtilsMockFns.mockGetClientIp.mockReturnValueOnce(null) - - const res = await POST(post('user@acme.com'), params()) - - expect(res.status).toBe(200) - expect(mockCheckRateLimitDirect).toHaveBeenCalledTimes(2) - expect(mockCheckRateLimitDirect).toHaveBeenNthCalledWith( - 1, - 'file-otp:resource:sh_1', - expect.any(Object), - { failClosed: true } - ) - expect(mockCheckRateLimitDirect).toHaveBeenNthCalledWith( - 2, - 'file-otp:email:sh_1:user@acme.com', - expect.any(Object), - { failClosed: true } - ) - }) -}) - -describe('PUT /api/files/public/[token]/otp', () => { - beforeEach(() => { - mockResolveActiveShareByToken.mockResolvedValue(emailShare) - mockIsEmailAllowed.mockReturnValue(true) - mockGetOTP.mockResolvedValue('123456:0') - mockDecodeOTPValue.mockReturnValue({ otp: '123456', attempts: 0 }) - }) - - it('rejects a valid code when the email is no longer allowed', async () => { - mockIsEmailAllowed.mockReturnValueOnce(false) - - const res = await PUT(put('user@acme.com', '123456'), params()) - - expect(res.status).toBe(403) - expect(mockGetOTP).not.toHaveBeenCalled() - expect(mockDeleteOTP).not.toHaveBeenCalled() - expect(mockSetDeploymentAuthCookie).not.toHaveBeenCalled() - }) - - it('rejects a wrong code with 400 and increments attempts', async () => { - mockIncrementOTPAttempts.mockResolvedValueOnce('incremented') - const res = await PUT(put('user@acme.com', '000000'), params()) - expect(res.status).toBe(400) - expect(mockIncrementOTPAttempts).toHaveBeenCalled() - expect(mockSetDeploymentAuthCookie).not.toHaveBeenCalled() - }) - - it('returns 429 when attempts are exhausted on a wrong code', async () => { - mockIncrementOTPAttempts.mockResolvedValueOnce('locked') - const res = await PUT(put('user@acme.com', '000000'), params()) - expect(res.status).toBe(429) - }) -}) diff --git a/apps/sim/app/api/files/public/[token]/otp/route.ts b/apps/sim/app/api/files/public/[token]/otp/route.ts index 57a707c189b..11b9fcadbcb 100644 --- a/apps/sim/app/api/files/public/[token]/otp/route.ts +++ b/apps/sim/app/api/files/public/[token]/otp/route.ts @@ -1,216 +1,76 @@ import { createLogger } from '@sim/logger' -import { normalizeEmail } from '@sim/utils/string' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' -import { getOtpSubject, renderOTPEmail } from '@/components/emails' import { requestPublicFileOtpContract, verifyPublicFileOtpContract, } from '@/lib/api/contracts/public-shares' import { parseRequest } from '@/lib/api/server' import { RateLimiter } from '@/lib/core/rate-limiter' -import { isEmailAllowed, setDeploymentAuthCookie } from '@/lib/core/security/deployment' -import { - decodeOTPValue, - deleteOTP, - generateOTP, - getOTP, - incrementOTPAttempts, - MAX_OTP_ATTEMPTS, - OTP_EMAIL_RATE_LIMIT, - OTP_IP_RATE_LIMIT, - OTP_RESOURCE_RATE_LIMIT, - storeOTP, -} from '@/lib/core/security/otp' +import { OTP_IP_RATE_LIMIT } from '@/lib/core/security/otp' import { afterResponse } from '@/lib/core/utils/after-response' -import { generateRequestId, getClientIp } from '@/lib/core/utils/request' +import { getClientIp } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { sendEmail } from '@/lib/messaging/email/mailer' -import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager' +import { publicFileAuthDenied, publicFileErrorResponse } from '@/lib/public-shares/api' +import { + requestPublicFileShareOtp, + verifyPublicFileShareOtp, +} from '@/lib/public-shares/application' export const dynamic = 'force-dynamic' - const logger = createLogger('PublicFileOtpAPI') - const rateLimiter = new RateLimiter() -const SHARE_EMAIL_LABEL = 'a shared file' - -function rateLimited(retryAfterMs: number | undefined, fallbackMs: number): NextResponse { - const response = NextResponse.json( - { error: 'Too many requests. Please try again later.' }, - { status: 429 } - ) - response.headers.set('Retry-After', String(Math.ceil((retryAfterMs ?? fallbackMs) / 1000))) - return response -} - -function otpRequestAccepted(): NextResponse { - return NextResponse.json({ message: 'Verification code sent' }) -} - -async function deliverOtp(requestId: string, shareId: string, email: string): Promise { - const resourceRateLimit = await rateLimiter.checkRateLimitDirect( - `file-otp:resource:${shareId}`, - OTP_RESOURCE_RATE_LIMIT, - { failClosed: true } - ) - if (!resourceRateLimit.allowed) { - logger.warn(`[${requestId}] OTP resource rate limit exceeded for share ${shareId}`) - return - } - - const emailRateLimit = await rateLimiter.checkRateLimitDirect( - `file-otp:email:${shareId}:${email}`, - OTP_EMAIL_RATE_LIMIT, - { failClosed: true } - ) - if (!emailRateLimit.allowed) { - logger.warn(`[${requestId}] OTP email rate limit exceeded for ${email}`) - return - } - - const otp = generateOTP() - await storeOTP('file', shareId, email, otp) - - const emailHtml = await renderOTPEmail(otp, 'email-verification', SHARE_EMAIL_LABEL) - const emailResult = await sendEmail({ - to: email, - subject: getOtpSubject(SHARE_EMAIL_LABEL), - html: emailHtml, - }) - if (!emailResult.success) { - logger.error(`[${requestId}] Failed to send OTP email:`, emailResult.message) - return - } - - logger.info(`[${requestId}] OTP sent for share ${shareId}`) -} - -/** - * POST /api/files/public/[token]/otp - * Sends a 6-digit verification code to an allow-listed email for an email-gated share. - */ +/** Accepted email requests retain one response; the application delivers only to authorized recipients. */ export const POST = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ token: string }> }) => { - const requestId = generateRequestId() - try { const ip = getClientIp(request) if (ip) { - const ipRateLimit = await rateLimiter.checkRateLimitDirect( + const limited = await rateLimiter.checkRateLimitDirect( `file-otp:ip:${ip}`, OTP_IP_RATE_LIMIT, { failClosed: true } ) - if (!ipRateLimit.allowed) { - logger.warn(`[${requestId}] OTP IP rate limit exceeded from ${ip}`) - return rateLimited(ipRateLimit.retryAfterMs, OTP_IP_RATE_LIMIT.refillIntervalMs) - } + if (!limited.allowed) + return publicFileAuthDenied({ + error: 'Too many requests. Please try again later.', + status: 429, + retryAfterMs: limited.retryAfterMs ?? OTP_IP_RATE_LIMIT.refillIntervalMs, + }) } - const parsed = await parseRequest(requestPublicFileOtpContract, request, context) if (!parsed.success) return parsed.response - const { token } = parsed.data.params - // Normalize once so allow-list matching, OTP storage, and the verify lookup - // all key off the same value (allow-list entries are stored lowercase). - const email = normalizeEmail(parsed.data.body.email) - - const resolved = await resolveActiveShareByToken(token) - if (!resolved) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) - } - if (resolved.share.authType !== 'email') { - return NextResponse.json( - { error: 'This file does not use email authentication' }, - { status: 400 } - ) - } - const emailAllowed = isEmailAllowed(email, resolved.share.allowedEmails) - - afterResponse(async () => { - if (!emailAllowed) return - await deliverOtp(requestId, resolved.share.id, email) + const accepted = await requestPublicFileShareOtp({ + token: parsed.data.params.token, + email: parsed.data.body.email, }) - return otpRequestAccepted() + afterResponse(accepted.deliver) + return NextResponse.json({ message: 'Verification code sent' }) } catch (error) { - logger.error(`[${requestId}] Error processing OTP request:`, error) - return NextResponse.json({ error: 'Failed to process request' }, { status: 500 }) + logger.error('Error processing OTP request:', error) + return publicFileErrorResponse(error, 'Failed to process request') } } ) -/** - * PUT /api/files/public/[token]/otp - * Verifies the code and, on success, sets the `file_auth_{shareId}` cookie. - */ +/** The application consumes a correct OTP and returns the current resource-policy cookie. */ export const PUT = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ token: string }> }) => { - const requestId = generateRequestId() - try { const parsed = await parseRequest(verifyPublicFileOtpContract, request, context) if (!parsed.success) return parsed.response - const { token } = parsed.data.params - const { otp } = parsed.data.body - const email = normalizeEmail(parsed.data.body.email) - - const resolved = await resolveActiveShareByToken(token) - if (!resolved) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) - } - if (resolved.share.authType !== 'email') { - return NextResponse.json( - { error: 'This file does not use email authentication' }, - { status: 400 } - ) - } - if (!isEmailAllowed(email, resolved.share.allowedEmails)) { - return NextResponse.json({ error: 'Email not authorized' }, { status: 403 }) - } - - const storedValue = await getOTP('file', resolved.share.id, email) - if (!storedValue) { - return NextResponse.json( - { error: 'No verification code found, request a new one' }, - { status: 400 } - ) - } - - const { otp: storedOTP, attempts } = decodeOTPValue(storedValue) - if (attempts >= MAX_OTP_ATTEMPTS) { - await deleteOTP('file', resolved.share.id, email) - return NextResponse.json( - { error: 'Too many failed attempts. Please request a new code.' }, - { status: 429 } - ) - } - - if (storedOTP !== otp) { - const result = await incrementOTPAttempts('file', resolved.share.id, email, storedValue) - if (result === 'locked') { - return NextResponse.json( - { error: 'Too many failed attempts. Please request a new code.' }, - { status: 429 } - ) - } - return NextResponse.json({ error: 'Invalid verification code' }, { status: 400 }) - } - - await deleteOTP('file', resolved.share.id, email) - - const response = NextResponse.json({ authType: resolved.share.authType }) - await setDeploymentAuthCookie({ - response, - cookiePrefix: 'file', - resource: resolved.share, - verifiedEmail: email, + const auth = await verifyPublicFileShareOtp({ + token: parsed.data.params.token, + ...parsed.data.body, }) - logger.info(`[${requestId}] OTP verified for share ${resolved.share.id}`) + if (!auth.authorized) return publicFileAuthDenied(auth) + const response = NextResponse.json({ authType: auth.authType }) + response.cookies.set(auth.cookie) return response } catch (error) { - logger.error(`[${requestId}] Error verifying OTP:`, error) - return NextResponse.json({ error: 'Failed to process request' }, { status: 500 }) + logger.error('Error verifying OTP:', error) + return publicFileErrorResponse(error, 'Failed to process request') } } ) diff --git a/apps/sim/app/api/files/public/[token]/route.test.ts b/apps/sim/app/api/files/public/[token]/route.test.ts deleted file mode 100644 index 2a996f664d4..00000000000 --- a/apps/sim/app/api/files/public/[token]/route.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { publicSharesMock, publicSharesMockFns } from '@sim/testing/mocks/public-shares.mock' -import { NextRequest } from 'next/server' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockEnforceRateLimit, mockValidateDeploymentAuth, mockSetDeploymentAuthCookie } = - vi.hoisted(() => ({ - mockEnforceRateLimit: vi.fn(), - mockValidateDeploymentAuth: vi.fn(), - mockSetDeploymentAuthCookie: vi.fn(), - })) - -vi.mock('@/lib/public-shares/share-manager', () => publicSharesMock) - -vi.mock('@/lib/public-shares/rate-limit', () => ({ - enforcePublicFileRateLimit: mockEnforceRateLimit, -})) - -vi.mock('@/lib/core/security/deployment-auth', () => ({ - validateDeploymentAuth: mockValidateDeploymentAuth, -})) - -vi.mock('@/lib/core/security/deployment', () => ({ - setDeploymentAuthCookie: mockSetDeploymentAuthCookie, -})) - -import { NextResponse } from 'next/server' -import { GET, POST } from '@/app/api/files/public/[token]/route' - -const mockResolveActiveShareByToken = publicSharesMockFns.mockResolveActiveShareByToken - -const params = (token = 'tok_1') => ({ params: Promise.resolve({ token }) }) -const request = (token = 'tok_1') => new NextRequest(`http://localhost/api/files/public/${token}`) -const postRequest = (password: string, token = 'tok_1') => - new NextRequest(`http://localhost/api/files/public/${token}`, { - method: 'POST', - headers: { 'content-type': 'application/json' }, - body: JSON.stringify({ password }), - }) - -const publicShare = { - share: { id: 'sh_1', token: 'tok_1', authType: 'public', password: null }, - file: { - id: 'wf_1', - key: 'workspace/ws/secret-key.pdf', - workspaceId: 'ws-secret', - originalName: 'report.pdf', - contentType: 'application/pdf', - sizeBytes: 2048, - }, - workspaceName: 'Acme Workspace', - ownerName: 'Jane Doe', -} - -const passwordShare = { - ...publicShare, - share: { id: 'sh_1', token: 'tok_1', authType: 'password', password: 'enc:secret' }, -} - -describe('GET /api/files/public/[token]', () => { - beforeEach(() => { - mockEnforceRateLimit.mockResolvedValue(null) // allow by default - mockValidateDeploymentAuth.mockResolvedValue({ authorized: true }) // public by default - }) - - it('returns 429 when the per-IP rate limit is exceeded', async () => { - mockEnforceRateLimit.mockResolvedValueOnce( - NextResponse.json({ error: 'Too many requests. Please try again later.' }, { status: 429 }) - ) - const res = await GET(request(), params()) - expect(res.status).toBe(429) - expect(mockResolveActiveShareByToken).not.toHaveBeenCalled() - }) - - it('returns public-safe metadata without leaking the key or workspace id', async () => { - mockResolveActiveShareByToken.mockResolvedValueOnce(publicShare) - const res = await GET(request(), params()) - expect(res.status).toBe(200) - const body = await res.json() - expect(body).toEqual({ - token: 'tok_1', - name: 'report.pdf', - type: 'application/pdf', - size: 2048, - workspaceName: 'Acme Workspace', - ownerName: 'Jane Doe', - }) - expect(JSON.stringify(body)).not.toContain('secret-key') - expect(JSON.stringify(body)).not.toContain('ws-secret') - }) - - it('returns 401 auth_required_password for a password share without a valid cookie', async () => { - mockResolveActiveShareByToken.mockResolvedValueOnce(passwordShare) - mockValidateDeploymentAuth.mockResolvedValueOnce({ - authorized: false, - error: 'auth_required_password', - }) - const res = await GET(request(), params()) - expect(res.status).toBe(401) - expect((await res.json()).error).toBe('auth_required_password') - expect(mockValidateDeploymentAuth).toHaveBeenCalledWith( - expect.any(String), - passwordShare.share, - expect.anything(), - undefined, - 'file' - ) - }) -}) - -describe('POST /api/files/public/[token]', () => { - beforeEach(() => { - mockResolveActiveShareByToken.mockResolvedValue(passwordShare) - }) - - it('sets the file_auth cookie and returns the authType on a correct password', async () => { - mockValidateDeploymentAuth.mockResolvedValueOnce({ authorized: true }) - const res = await POST(postRequest('hunter2'), params()) - expect(res.status).toBe(200) - expect(await res.json()).toEqual({ authType: 'password' }) - expect(mockSetDeploymentAuthCookie).toHaveBeenCalledWith({ - response: expect.anything(), - cookiePrefix: 'file', - resource: passwordShare.share, - }) - }) - - it('refuses to mint a cookie for a non-password (e.g. public) share', async () => { - mockResolveActiveShareByToken.mockResolvedValueOnce({ - ...passwordShare, - share: { id: 'sh_1', token: 'tok_1', authType: 'public', password: null }, - }) - const res = await POST(postRequest('whatever'), params()) - expect(res.status).toBe(400) - expect(mockValidateDeploymentAuth).not.toHaveBeenCalled() - expect(mockSetDeploymentAuthCookie).not.toHaveBeenCalled() - }) - - it('returns 401 Invalid password on mismatch without setting a cookie', async () => { - mockValidateDeploymentAuth.mockResolvedValueOnce({ - authorized: false, - error: 'Invalid password', - }) - const res = await POST(postRequest('wrong'), params()) - expect(res.status).toBe(401) - expect((await res.json()).error).toBe('Invalid password') - expect(mockSetDeploymentAuthCookie).not.toHaveBeenCalled() - }) - - it('returns 429 with Retry-After when password attempts are rate-limited', async () => { - mockValidateDeploymentAuth.mockResolvedValueOnce({ - authorized: false, - error: 'Too many attempts. Please try again later.', - status: 429, - retryAfterMs: 60_000, - }) - const res = await POST(postRequest('wrong'), params()) - expect(res.status).toBe(429) - expect(res.headers.get('Retry-After')).toBe('60') - expect(mockSetDeploymentAuthCookie).not.toHaveBeenCalled() - }) -}) diff --git a/apps/sim/app/api/files/public/[token]/route.ts b/apps/sim/app/api/files/public/[token]/route.ts index 5874d977b9b..2c2aed33059 100644 --- a/apps/sim/app/api/files/public/[token]/route.ts +++ b/apps/sim/app/api/files/public/[token]/route.ts @@ -1,5 +1,4 @@ import { createLogger } from '@sim/logger' -import { getErrorMessage } from '@sim/utils/errors' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { @@ -7,54 +6,39 @@ import { getPublicFileContract, } from '@/lib/api/contracts/public-shares' import { parseRequest } from '@/lib/api/server' -import { setDeploymentAuthCookie } from '@/lib/core/security/deployment' -import { validateDeploymentAuth } from '@/lib/core/security/deployment-auth' -import { generateRequestId } from '@/lib/core/utils/request' +import { getClientIp } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { + publicFileAuthDenied, + publicFileErrorResponse, + publicFileShareCredential, +} from '@/lib/public-shares/api' +import { + authenticatePublicFileSharePassword, + authorizePublicFileShare, + readPublicFileShare, +} from '@/lib/public-shares/application' import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit' -import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager' import { getWorkspaceFileSize } from '@/lib/uploads/shared/types' export const dynamic = 'force-dynamic' - const logger = createLogger('PublicFileMetadataAPI') -/** - * GET /api/files/public/[token] - * Public, unauthenticated metadata for a shared file. Returns 404 for unknown, - * inactive, or deleted shares — the existence of a file is never leaked. A - * password-protected share returns 401 `auth_required_password` until a valid - * `file_auth_{shareId}` cookie is present. - */ +/** Public bearer authentication and cookie exchange retain their protocol-specific response shape. */ export const GET = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ token: string }> }) => { - const requestId = generateRequestId() - try { const limited = await enforcePublicFileRateLimit(request, 'metadata') if (limited) return limited - const parsed = await parseRequest(getPublicFileContract, request, context) if (!parsed.success) return parsed.response const { token } = parsed.data.params - - const resolved = await resolveActiveShareByToken(token) - if (!resolved) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) - } - - const auth = await validateDeploymentAuth( - requestId, - resolved.share, - request, - undefined, - 'file' - ) - if (!auth.authorized) { - return NextResponse.json({ error: auth.error ?? 'auth_required_password' }, { status: 401 }) - } - - const { file, workspaceName, ownerName } = resolved + const auth = await authorizePublicFileShare({ + token, + credential: await publicFileShareCredential(request.cookies.getAll(), getClientIp(request)), + }) + if (!auth.authorized) return publicFileAuthDenied(auth) + const { file, workspaceName, ownerName } = await readPublicFileShare({ grant: auth.grant }) return NextResponse.json({ token, name: file.originalName, @@ -65,78 +49,29 @@ export const GET = withRouteHandler( }) } catch (error) { logger.error('Error fetching public file metadata:', error) - return NextResponse.json( - { error: getErrorMessage(error, 'Failed to fetch file') }, - { status: 500 } - ) + return publicFileErrorResponse(error, 'Failed to fetch file') } } ) -/** - * POST /api/files/public/[token] - * Exchanges a share password for a `file_auth_{shareId}` cookie. IP rate-limited - * via the shared deployment-auth gate; returns 401 (`Invalid password`) on - * mismatch and 429 (with `Retry-After`) when throttled. - */ +/** Exchanges a current password policy for its resource-bound HttpOnly cookie. */ export const POST = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ token: string }> }) => { - const requestId = generateRequestId() - try { const parsed = await parseRequest(authenticatePublicFileContract, request, context) if (!parsed.success) return parsed.response - const { token } = parsed.data.params - const { password } = parsed.data.body - - const resolved = await resolveActiveShareByToken(token) - if (!resolved) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) - } - - // This endpoint authenticates password shares only. Refusing other modes - // here prevents minting a `file_auth` cookie for a `public` share (which - // `validateDeploymentAuth` would otherwise authorize), which could later - // satisfy the gate if the share is switched to `email`/`sso`. - if (resolved.share.authType !== 'password') { - return NextResponse.json( - { error: 'This file does not use password authentication' }, - { status: 400 } - ) - } - - const auth = await validateDeploymentAuth( - requestId, - resolved.share, - request, - { password }, - 'file' - ) - if (!auth.authorized) { - const response = NextResponse.json( - { error: auth.error ?? 'Invalid password' }, - { status: auth.status ?? 401 } - ) - if (auth.status === 429 && auth.retryAfterMs !== undefined) { - response.headers.set('Retry-After', String(Math.ceil(auth.retryAfterMs / 1000))) - } - return response - } - - const response = NextResponse.json({ authType: resolved.share.authType }) - await setDeploymentAuthCookie({ - response, - cookiePrefix: 'file', - resource: resolved.share, + const auth = await authenticatePublicFileSharePassword({ + token: parsed.data.params.token, + password: parsed.data.body.password, + clientIp: getClientIp(request), }) - logger.info('Public file share password accepted', { token, shareId: resolved.share.id }) + if (!auth.authorized) return publicFileAuthDenied(auth) + const response = NextResponse.json({ authType: auth.authType }) + response.cookies.set(auth.cookie) return response } catch (error) { logger.error('Error authenticating public file share:', error) - return NextResponse.json( - { error: getErrorMessage(error, 'Failed to authenticate') }, - { status: 500 } - ) + return publicFileErrorResponse(error, 'Failed to authenticate') } } ) diff --git a/apps/sim/app/api/files/public/[token]/sso/route.test.ts b/apps/sim/app/api/files/public/[token]/sso/route.test.ts deleted file mode 100644 index 20957fa6465..00000000000 --- a/apps/sim/app/api/files/public/[token]/sso/route.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { requestUtilsMockFns } from '@sim/testing' -import { createRouteContext } from '@sim/testing/helpers/http' -import { publicSharesMock, publicSharesMockFns } from '@sim/testing/mocks/public-shares.mock' -import { rateLimiterMock, rateLimiterMockFns } from '@sim/testing/mocks/rate-limiter.mock' -import { createMockRequest } from '@sim/testing/mocks/request.mock' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { mockIsEmailAllowed } = vi.hoisted(() => ({ - mockIsEmailAllowed: vi.fn(), -})) - -vi.mock('@/lib/public-shares/share-manager', () => publicSharesMock) -vi.mock('@/lib/core/security/deployment', () => ({ isEmailAllowed: mockIsEmailAllowed })) -vi.mock('@/lib/core/rate-limiter', () => rateLimiterMock) - -import { POST } from '@/app/api/files/public/[token]/sso/route' - -const { mockResolveActiveShareByToken } = publicSharesMockFns - -const mockCheckRateLimitDirect = rateLimiterMockFns.mockCheckRateLimitDirect - -const params = (token = 'tok_1') => createRouteContext({ token }) -const post = (email: string, token = 'tok_1') => - createMockRequest({ - method: 'POST', - url: `http://localhost/api/files/public/${token}/sso`, - body: { email }, - }) - -const ssoShare = { - share: { id: 'sh_1', authType: 'sso', password: null, allowedEmails: ['@acme.com'] }, - file: { originalName: 'report.pdf' }, -} - -describe('POST /api/files/public/[token]/sso', () => { - beforeEach(() => { - mockCheckRateLimitDirect.mockResolvedValue({ allowed: true }) - mockResolveActiveShareByToken.mockResolvedValue(ssoShare) - }) - - it('returns eligible:false for a non-listed email', async () => { - mockIsEmailAllowed.mockReturnValueOnce(false) - const res = await POST(post('user@evil.com'), params()) - expect(res.status).toBe(200) - expect(await res.json()).toEqual({ eligible: false }) - }) - - it('returns 429 when rate-limited', async () => { - mockCheckRateLimitDirect.mockResolvedValueOnce({ allowed: false, retryAfterMs: 2000 }) - const res = await POST(post('user@acme.com'), params()) - expect(res.status).toBe(429) - expect(res.headers.get('Retry-After')).toBe('2') - }) - - it('uses the share resource limit when the client IP cannot be resolved', async () => { - requestUtilsMockFns.mockGetClientIp.mockReturnValueOnce(null) - - const res = await POST(post('user@acme.com'), params()) - - expect(res.status).toBe(200) - expect(mockCheckRateLimitDirect).toHaveBeenCalledTimes(1) - expect(mockCheckRateLimitDirect).toHaveBeenCalledWith( - 'file-sso:resource:sh_1', - expect.objectContaining({ maxTokens: 100 }), - { failClosed: true } - ) - }) -}) diff --git a/apps/sim/app/api/files/public/[token]/sso/route.ts b/apps/sim/app/api/files/public/[token]/sso/route.ts index bc94fdcd0b6..9718d8a1e86 100644 --- a/apps/sim/app/api/files/public/[token]/sso/route.ts +++ b/apps/sim/app/api/files/public/[token]/sso/route.ts @@ -1,94 +1,54 @@ import { createLogger } from '@sim/logger' -import { normalizeEmail } from '@sim/utils/string' import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { publicFileSSOContract } from '@/lib/api/contracts/public-shares' import { parseRequest } from '@/lib/api/server' -import type { TokenBucketConfig } from '@/lib/core/rate-limiter' -import { RateLimiter } from '@/lib/core/rate-limiter' -import { isEmailAllowed } from '@/lib/core/security/deployment' -import { generateRequestId, getClientIp } from '@/lib/core/utils/request' +import { RateLimiter, type TokenBucketConfig } from '@/lib/core/rate-limiter' +import { getClientIp } from '@/lib/core/utils/request' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' -import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager' +import { publicFileAuthDenied, publicFileErrorResponse } from '@/lib/public-shares/api' +import { getPublicFileShareSsoEligibility } from '@/lib/public-shares/application' export const dynamic = 'force-dynamic' export const runtime = 'nodejs' - const logger = createLogger('PublicFileSSOAPI') - const rateLimiter = new RateLimiter() - const SSO_IP_RATE_LIMIT: TokenBucketConfig = { maxTokens: 20, refillRate: 20, refillIntervalMs: 15 * 60_000, } -const SSO_RESOURCE_RATE_LIMIT: TokenBucketConfig = { - maxTokens: 100, - refillRate: 100, - refillIntervalMs: 15 * 60_000, -} - -function rateLimited(retryAfterMs: number | undefined, fallbackMs: number): NextResponse { - const response = NextResponse.json( - { error: 'Too many requests. Please try again later.' }, - { status: 429 } - ) - response.headers.set('Retry-After', String(Math.ceil((retryAfterMs ?? fallbackMs) / 1000))) - return response -} - -/** - * POST /api/files/public/[token]/sso - * Reports whether an email is on the allow-list for an SSO-gated share. The actual - * authentication is the global Sim session (checked at the page/route gate). - */ +/** Eligibility preserves the existing SSO redirect flow and never grants file access by itself. */ export const POST = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ token: string }> }) => { - const requestId = generateRequestId() - - const ip = getClientIp(request) - if (ip) { - const ipRateLimit = await rateLimiter.checkRateLimitDirect( - `file-sso:ip:${ip}`, - SSO_IP_RATE_LIMIT, - { failClosed: true } - ) - if (!ipRateLimit.allowed) { - logger.warn(`[${requestId}] SSO eligibility rate limit exceeded from ${ip}`) - return rateLimited(ipRateLimit.retryAfterMs, SSO_IP_RATE_LIMIT.refillIntervalMs) + try { + const ip = getClientIp(request) + if (ip) { + const limited = await rateLimiter.checkRateLimitDirect( + `file-sso:ip:${ip}`, + SSO_IP_RATE_LIMIT, + { failClosed: true } + ) + if (!limited.allowed) + return publicFileAuthDenied({ + error: 'Too many requests. Please try again later.', + status: 429, + retryAfterMs: limited.retryAfterMs ?? SSO_IP_RATE_LIMIT.refillIntervalMs, + }) } - } - - const parsed = await parseRequest(publicFileSSOContract, request, context) - if (!parsed.success) return parsed.response - const { token } = parsed.data.params - const email = normalizeEmail(parsed.data.body.email) - - const resolved = await resolveActiveShareByToken(token) - if (!resolved) { - return NextResponse.json({ error: 'Not found' }, { status: 404 }) - } - if (resolved.share.authType !== 'sso') { - return NextResponse.json({ error: 'This file is not configured for SSO' }, { status: 400 }) - } - - const resourceRateLimit = await rateLimiter.checkRateLimitDirect( - `file-sso:resource:${resolved.share.id}`, - SSO_RESOURCE_RATE_LIMIT, - { failClosed: true } - ) - if (!resourceRateLimit.allowed) { - logger.warn(`[${requestId}] SSO eligibility resource rate limit exceeded`, { - shareId: resolved.share.id, + const parsed = await parseRequest(publicFileSSOContract, request, context) + if (!parsed.success) return parsed.response + const result = await getPublicFileShareSsoEligibility({ + token: parsed.data.params.token, + email: parsed.data.body.email, }) - return rateLimited(resourceRateLimit.retryAfterMs, SSO_RESOURCE_RATE_LIMIT.refillIntervalMs) + return result.allowed + ? NextResponse.json({ eligible: result.eligible }) + : publicFileAuthDenied(result) + } catch (error) { + logger.error('Error checking public file SSO eligibility:', error) + return publicFileErrorResponse(error, 'Failed to process request') } - - const allowedEmails = Array.isArray(resolved.share.allowedEmails) - ? (resolved.share.allowedEmails as string[]) - : [] - return NextResponse.json({ eligible: isEmailAllowed(email, allowedEmails) }) } ) diff --git a/apps/sim/app/api/files/serve-inline-image.ts b/apps/sim/app/api/files/serve-inline-image.ts deleted file mode 100644 index f984413fd52..00000000000 --- a/apps/sim/app/api/files/serve-inline-image.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { createLogger } from '@sim/logger' -import type { NextResponse } from 'next/server' -import { downloadFile } from '@/lib/uploads/core/storage-service' -import type { ResolvedInlineImage } from '@/lib/uploads/server/inline-image' -import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' -import { sniffImageContentType } from '@/lib/uploads/utils/validation' -import { createFileResponse, FileNotFoundError } from '@/app/api/files/utils' - -const logger = createLogger('InlineImageServe') - -/** - * An embedded image is authenticated content served from a fixed inline URL, and the file behind it can - * be DELETED or its access REVOKED at any time — so it always revalidates, letting each request re-run the - * server-side deletion/authorization check rather than serving a stale (possibly no-longer-authorized) - * image from cache. Private so no shared cache/CDN ever stores it. - */ -const INLINE_CACHE_CONTROL = 'private, no-cache, must-revalidate' - -/** - * Download and respond with an already-workspace-scoped inline image — the single serving tail for both - * the in-app and public inline routes. When `sniff` is set (public shares, a less-trusted audience), the - * served content type is derived from the bytes and non-raster content is refused with 404; otherwise the - * stored content type is served, matching the in-app serve route. - */ -export async function serveInlineImage( - image: ResolvedInlineImage, - { sniff }: { sniff: boolean } -): Promise { - const buffer = await downloadFile({ - key: image.key, - context: 'workspace', - maxBytes: MAX_BUFFERED_TRANSFER_BYTES, - }) - - let contentType = image.contentType - if (sniff) { - const sniffed = sniffImageContentType(buffer) - if (!sniffed) { - logger.warn('Embedded reference is not a renderable image', { key: image.key }) - throw new FileNotFoundError('Not found') - } - contentType = sniffed - } - - return createFileResponse({ - buffer, - contentType, - filename: image.filename, - cacheControl: INLINE_CACHE_CONTROL, - }) -} diff --git a/apps/sim/app/api/files/serve/[...path]/route.test.ts b/apps/sim/app/api/files/serve/[...path]/route.test.ts index b5c7b7234c8..c476d65cdb3 100644 --- a/apps/sim/app/api/files/serve/[...path]/route.test.ts +++ b/apps/sim/app/api/files/serve/[...path]/route.test.ts @@ -13,7 +13,12 @@ import { createSessionPrincipal, } from '@sim/testing/factories/principal.factory' import { createRouteContext } from '@sim/testing/helpers/http' +import { fileReadReceiptMock } from '@sim/testing/mocks/file-read-receipt.mock' import { fileUtilsMock, fileUtilsMockFns } from '@sim/testing/mocks/file-utils.mock' +import { + fileUtilsServerMock, + fileUtilsServerMockFns, +} from '@sim/testing/mocks/file-utils-server.mock' import { filesAuthorizationMock, filesAuthorizationMockFns, @@ -36,6 +41,8 @@ const { mockReadFile, mockAuthenticateWorkspaceFile, mockReadWorkspaceFileContentByKey, + mockReadWorkspaceFileRecordByKey, + mockAuthorizeWorkspaceFileRecordByKey, mockResolveServableDocBytes, mockGetContentType, mockFindLocalFile, @@ -57,6 +64,8 @@ const { mockReadFile: vi.fn(), mockAuthenticateWorkspaceFile: vi.fn(), mockReadWorkspaceFileContentByKey: vi.fn(), + mockReadWorkspaceFileRecordByKey: vi.fn(), + mockAuthorizeWorkspaceFileRecordByKey: vi.fn(async () => undefined), mockResolveServableDocBytes: vi.fn(), mockGetContentType: vi.fn(), mockFindLocalFile: vi.fn(), @@ -86,6 +95,9 @@ vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock) vi.mock('@/lib/uploads/utils/file-utils', () => fileUtilsMock) +vi.mock('@/lib/uploads/utils/file-utils.server', () => fileUtilsServerMock) +vi.mock('@/lib/workspace-files/read-receipt', () => fileReadReceiptMock) + vi.mock('@/lib/uploads/server/metadata', () => uploadsMetadataMock) vi.mock('@/lib/uploads/setup.server', () => ({})) @@ -106,6 +118,10 @@ vi.mock('@/lib/workspace-files/api', () => ({ vi.mock('@/lib/workspace-files/application/read-workspace-file-content-by-key', () => ({ readWorkspaceFileContentByKey: { execute: mockReadWorkspaceFileContentByKey }, + readWorkspaceFileRecordByKey: { + execute: mockReadWorkspaceFileRecordByKey, + authorize: mockAuthorizeWorkspaceFileRecordByKey, + }, })) vi.mock('@/lib/uploads/documents/compile', () => ({ @@ -161,6 +177,17 @@ describe('File Serve API Route', () => { }, content: Buffer.from('generated source'), }) + mockReadWorkspaceFileRecordByKey.mockResolvedValue({ + file: { + id: 'file-1', + workspaceId: 'test-workspace-id', + name: 'report.pdf', + key: 'workspace/test-workspace-id/report.pdf', + type: 'text/x-pdflibjs', + size: 16, + contentUpdatedAt: new Date('2026-09-06'), + }, + }) mockResolveServableDocBytes.mockImplementation( async ({ rawBuffer, fileName }: { rawBuffer: Buffer; fileName: string }) => ({ buffer: rawBuffer, @@ -173,24 +200,23 @@ describe('File Serve API Route', () => { mockReadFile(filePath) ) mockCreateFileResponse.mockImplementation( - (file: { buffer: Buffer; contentType: string; filename: string }) => { + (file: { buffer: Buffer; contentType: string; filename: string; cacheControl?: string }) => { return new Response(file.buffer, { status: 200, headers: { 'Content-Type': file.contentType, + ...(file.cacheControl ? { 'Cache-Control': file.cacheControl } : {}), 'Content-Disposition': `inline; filename="${file.filename}"`, }, }) } ) - // Delegates so the existing assertions on the response payload — including its - // Cache-Control — read the same call list whichever helper the route reached for. mockCreateConditionalFileResponse.mockImplementation((file: unknown) => mockCreateFileResponse(file) ) - mockCreateErrorResponse.mockImplementation((error: Error) => { + mockCreateErrorResponse.mockImplementation((error: Error, status = 500) => { return new Response(JSON.stringify({ error: error.name, message: error.message }), { - status: error.name === 'FileNotFoundError' ? 404 : 500, + status: error.name === 'FileNotFoundError' ? 404 : status, headers: { 'Content-Type': 'application/json' }, }) }) @@ -281,14 +307,6 @@ describe('File Serve API Route', () => { observedBytes: MAX_BUFFERED_TRANSFER_BYTES + 1, }) ) - // The real createErrorResponse owns the status mapping; mirror it here so the - // route's own error path is what decides, not the mock's default 500. - mockCreateErrorResponse.mockImplementation( - (error: Error) => - new Response(JSON.stringify({ error: error.name }), { - status: error.name === 'PayloadSizeLimitError' ? 413 : 500, - }) - ) const response = await GET( createMockRequest({ url: 'http://localhost:3000/api/files/serve/workspace/ws/huge.bin' }), @@ -316,7 +334,7 @@ describe('File Serve API Route', () => { mockResolveStoredFileContext.mockResolvedValue('workspace') mockParseWorkspaceFileKey.mockReturnValue('test-workspace-id') mockAuthenticateWorkspaceFile.mockResolvedValue(principal) - mockResolveServableDocBytes.mockResolvedValue({ + fileUtilsServerMockFns.mockDownloadServableFileFromStorage.mockResolvedValue({ buffer: Buffer.from('compiled'), contentType: 'application/pdf', ...(dependsOnReferencedFiles ? { dependsOnReferencedFiles: true } : {}), @@ -325,19 +343,22 @@ describe('File Serve API Route', () => { const req = createMockRequest({ url: 'http://localhost:3000/api/files/serve/workspace/test-workspace-id/report.pdf?v=1756684800000', }) - await GET(req, createRouteContext({ path: ['workspace', 'test-workspace-id', 'report.pdf'] })) - return mockCreateFileResponse.mock.calls.at(-1)?.[0] + const response = await GET( + req, + createRouteContext({ path: ['workspace', 'test-workspace-id', 'report.pdf'] }) + ) + expect(response.status).toBe(200) + return response } it('caches a versioned document immutably when its bytes derive from the stored source alone', async () => { - expect(await serveVersionedDoc(false)).toEqual( - expect.objectContaining({ cacheControl: 'private, max-age=31536000, immutable' }) - ) + const response = await serveVersionedDoc(false) + expect(response.headers.get('Cache-Control')).toBe('private, max-age=31536000, immutable') }) - it('attaches a validator to a revalidated response so the next check can be answered 304', async () => { - await serveVersionedDoc(true) - expect(mockCreateConditionalFileResponse).toHaveBeenCalled() + it('requires revalidation for a versioned document with referenced inputs', async () => { + const response = await serveVersionedDoc(true) + expect(response.headers.get('Cache-Control')).toBe('private, no-cache, must-revalidate') }) }) }) diff --git a/apps/sim/app/api/files/serve/[...path]/route.ts b/apps/sim/app/api/files/serve/[...path]/route.ts index bf7c925167e..fdfb8cd7399 100644 --- a/apps/sim/app/api/files/serve/[...path]/route.ts +++ b/apps/sim/app/api/files/serve/[...path]/route.ts @@ -1,4 +1,4 @@ -import { type Principal, requirePrincipalSubjectUserId } from '@sim/auth/principal' +import type { Principal } from '@sim/auth/principal' import { createLogger } from '@sim/logger' import { getErrorMessage } from '@sim/utils/errors' import type { NextRequest } from 'next/server' @@ -10,7 +10,7 @@ import { internalSessionAuth, } from '@/lib/api/server/routes' import { AuthType, checkSessionOrInternalAuth } from '@/lib/auth/hybrid' -import { asOrchestrationError } from '@/lib/core/orchestration/types' +import { asOrchestrationError, statusForOrchestrationError } from '@/lib/core/orchestration/types' import { assertKnownSizeWithinLimit, isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { CopilotFiles, isUsingCloudStorage } from '@/lib/uploads' @@ -20,12 +20,18 @@ import { parseWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspac import { downloadFile } from '@/lib/uploads/core/storage-service' import { resolveServableDocBytes } from '@/lib/uploads/documents/compile' import { DocCompileUserError } from '@/lib/uploads/documents/compile-error' +import { FILE_CACHE_CONTROL, workspaceFileCacheControl } from '@/lib/uploads/server/delivery' import { resolveServableImageBytes } from '@/lib/uploads/server/image-derivative' import { resolveStoredFileContext } from '@/lib/uploads/server/metadata' import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' import { inferContextFromKey } from '@/lib/uploads/utils/file-utils' import { internalWorkspaceFileServeAuth } from '@/lib/workspace-files/api' -import { readWorkspaceFileContentByKey } from '@/lib/workspace-files/application/read-workspace-file-content-by-key' +import { fetchAuthorizedServableWorkspaceFileBuffer } from '@/lib/workspace-files/application/fetch-servable-workspace-file-buffer' +import { finishFileDelivery } from '@/lib/workspace-files/application/finish-file-delivery' +import { + readWorkspaceFileContentByKey, + readWorkspaceFileRecordByKey, +} from '@/lib/workspace-files/application/read-workspace-file-content-by-key' import { isSimPageSource, SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile' import { renderSimPageDocumentWithAssets } from '@/lib/workspace-files/page-document.server' import { type KnowledgeFileAccess, verifyFileAccess } from '@/app/api/files/authorization' @@ -212,11 +218,11 @@ function getWorkspaceIdForCompile(key: string): string | undefined { return parseWorkspaceFileKey(key) ?? undefined } -const IMMUTABLE_CACHE_CONTROL = 'private, max-age=31536000, immutable' -const WORKSPACE_REVALIDATE_CACHE_CONTROL = 'private, no-cache, must-revalidate' +const IMMUTABLE_CACHE_CONTROL = FILE_CACHE_CONTROL.immutable +const WORKSPACE_REVALIDATE_CACHE_CONTROL = FILE_CACHE_CONTROL.revalidate /** For the genuinely-public, pre-auth asset routes (avatars, OG images, workspace logos) — these are * intentionally shared-cacheable. Passed EXPLICITLY so the default response cache stays `private`. */ -const PUBLIC_ASSET_CACHE_CONTROL = 'public, max-age=31536000' +const PUBLIC_ASSET_CACHE_CONTROL = FILE_CACHE_CONTROL.publicAsset /** * Cache-Control for a served file. @@ -238,6 +244,7 @@ function resolveServeCacheControl( context: string | undefined, dependsOnReferencedFiles: boolean ): string | undefined { + if (context === 'workspace') return workspaceFileCacheControl(versioned, dependsOnReferencedFiles) if (versioned && !dependsOnReferencedFiles) return IMMUTABLE_CACHE_CONTROL return context === 'workspace' || dependsOnReferencedFiles ? WORKSPACE_REVALIDATE_CACHE_CONTROL @@ -269,6 +276,8 @@ export const GET = withRouteHandler( throw new FileNotFoundError('No file path provided') } + if (path[0] === 'project') throw new FileNotFoundError('File not found') + logger.info('File serve request:', { path }) const fullPath = path.join('/') @@ -331,7 +340,16 @@ export const GET = withRouteHandler( path, error: legacyAuthResult.error || 'Missing userId', }) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + return NextResponse.json( + { error: 'Unauthorized' }, + { + status: 401, + headers: { + 'Cache-Control': FILE_CACHE_CONTROL.noStore, + 'X-Content-Type-Options': 'nosniff', + }, + } + ) } const query = fileServeQuerySchema.parse({ @@ -363,7 +381,8 @@ export const GET = withRouteHandler( options, request.signal, storageContext, - knowledgeAccess + knowledgeAccess, + request.method === 'HEAD' ) } @@ -373,12 +392,22 @@ export const GET = withRouteHandler( options, request.signal, storageContext, - knowledgeAccess + knowledgeAccess, + request.method === 'HEAD' ) } catch (error) { if (error instanceof InternalUnauthenticatedError) { logger.warn('Unauthorized file access attempt', { error: error.message }) - return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) + return NextResponse.json( + { error: 'Unauthorized' }, + { + status: 401, + headers: { + 'Cache-Control': FILE_CACHE_CONTROL.noStore, + 'X-Content-Type-Options': 'nosniff', + }, + } + ) } // An in-progress/incomplete doc source fails to compile — this is expected @@ -401,6 +430,12 @@ export const GET = withRouteHandler( return createErrorResponse(notFound) } + if (orchestrationError) + return createErrorResponse( + orchestrationError, + statusForOrchestrationError(orchestrationError.code) + ) + logServeFailure('Error serving file:', error) if (error instanceof FileNotFoundError) { @@ -421,23 +456,42 @@ async function handleWorkspaceFile( const workspaceId = getWorkspaceIdForCompile(key) if (!workspaceId) throw new FileNotFoundError(`File not found: ${key}`) - const { file, content } = await readWorkspaceFileContentByKey.execute({ - principal, - input: { key, assertedWorkspaceId: workspaceId }, - request, - }) - const ownerKey = `user:${requirePrincipalSubjectUserId(principal)}` - const resolved = await resolveServableBytes({ - buffer: content, - filename: file.name, - storageKey: key, - workspaceId, - options, - ownerKey, - filePrincipal: principal, - fileType: file.type, - signal: request.signal, - }) + const input = { key, assertedWorkspaceId: workspaceId } + const { file } = await readWorkspaceFileRecordByKey.execute({ principal, input, request }) + if (request.method === 'HEAD') { + return new NextResponse(null, { + headers: { 'Cache-Control': FILE_CACHE_CONTROL.noStore, 'X-Content-Type-Options': 'nosniff' }, + }) + } + let resolved: ServableBytes + if (options.raw) { + const { content } = await readWorkspaceFileContentByKey.execute({ principal, input, request }) + resolved = { + buffer: content, + contentType: getContentType(file.name), + dependsOnReferencedFiles: false, + } + } else { + const rendered = await fetchAuthorizedServableWorkspaceFileBuffer(file, principal, { + maxBytes: MAX_BUFFERED_TRANSFER_BYTES, + signal: request.signal, + }) + const preview = options.preview ? await resolveServableImageBytes(rendered.buffer, key) : null + resolved = { + buffer: preview?.buffer ?? rendered.buffer, + contentType: preview?.contentType ?? rendered.contentType, + dependsOnReferencedFiles: rendered.dependsOnReferencedFiles, + } + assertKnownSizeWithinLimit( + resolved.buffer.length, + MAX_BUFFERED_TRANSFER_BYTES, + 'served file response' + ) + await finishFileDelivery({ + authorize: () => readWorkspaceFileRecordByKey.authorize({ principal, input, request }), + receipt: rendered.receipt, + }) + } logger.info('Workspace file served', { fileId: file.id, @@ -465,7 +519,8 @@ async function handleLocalFile( options: ServeOptions, signal: AbortSignal | undefined, context: StorageContext, - knowledgeAccess: KnowledgeFileAccess | undefined + knowledgeAccess: KnowledgeFileAccess | undefined, + head: boolean ): Promise { const ownerKey = `user:${userId}` try { @@ -482,6 +537,13 @@ async function handleLocalFile( throw new FileNotFoundError(`File not found: ${filename}`) } + if (head) + return new NextResponse(null, { + headers: { + 'Cache-Control': FILE_CACHE_CONTROL.noStore, + 'X-Content-Type-Options': 'nosniff', + }, + }) const filePath = await findLocalFile(filename) if (!filePath) { @@ -537,7 +599,8 @@ async function handleCloudProxy( options: ServeOptions, signal: AbortSignal | undefined, context: StorageContext, - knowledgeAccess: KnowledgeFileAccess | undefined + knowledgeAccess: KnowledgeFileAccess | undefined, + head: boolean ): Promise { const ownerKey = `user:${userId}` try { @@ -556,6 +619,13 @@ async function handleCloudProxy( throw new FileNotFoundError(`File not found: ${cloudKey}`) } + if (head) + return new NextResponse(null, { + headers: { + 'Cache-Control': FILE_CACHE_CONTROL.noStore, + 'X-Content-Type-Options': 'nosniff', + }, + }) let rawBuffer: Buffer if (context === 'copilot') { diff --git a/apps/sim/app/api/files/uploads/purposes.ts b/apps/sim/app/api/files/uploads/purposes.ts index a438007ef4c..8d92165cef7 100644 --- a/apps/sim/app/api/files/uploads/purposes.ts +++ b/apps/sim/app/api/files/uploads/purposes.ts @@ -250,6 +250,11 @@ function requireSessionScope(value: string | null, label = 'scope'): string { async function principalUserId(principal: Principal, workspaceId?: string): Promise { switch (principal.kind) { + case 'resource_delegated': + throw new UploadSessionError( + 'forbidden', + 'Resource delegation cannot create workspace uploads' + ) case 'slack_app': case 'slack_installation': throw new UploadSessionError('forbidden', 'Slack installations cannot create uploads') diff --git a/apps/sim/app/api/files/utils.test.ts b/apps/sim/app/api/files/utils.test.ts index 02536725612..f324c33f148 100644 --- a/apps/sim/app/api/files/utils.test.ts +++ b/apps/sim/app/api/files/utils.test.ts @@ -1,8 +1,8 @@ import { describe, expect, it } from 'vitest' +import { encodeFilenameForHeader } from '@/lib/uploads/server/delivery' import { createConditionalFileResponse, createFileResponse, - encodeFilenameForHeader, extractFilename, findLocalFile, } from '@/app/api/files/utils' diff --git a/apps/sim/app/api/files/utils.ts b/apps/sim/app/api/files/utils.ts index bbd20fe6b52..f92efec44f6 100644 --- a/apps/sim/app/api/files/utils.ts +++ b/apps/sim/app/api/files/utils.ts @@ -1,11 +1,15 @@ -import { createHash } from 'node:crypto' import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' import { isPayloadSizeLimitError, readNodeStreamToBufferWithLimit, } from '@/lib/core/utils/stream-limits' -import { ensureFileNameExtension, sanitizeFileKey } from '@/lib/uploads/utils/file-utils' +import { + bufferedRepresentationEtag, + FILE_CACHE_CONTROL, + fileDeliveryHeaders, +} from '@/lib/uploads/server/delivery' +import { sanitizeFileKey } from '@/lib/uploads/utils/file-utils' const logger = createLogger('FilesUtils') @@ -151,116 +155,20 @@ export async function findLocalFile(filename: string): Promise { } } -const SAFE_INLINE_TYPES = new Set([ - 'image/png', - 'image/jpeg', - 'image/jpg', - 'image/gif', - 'image/svg+xml', - 'image/webp', - 'image/avif', - 'image/bmp', - 'image/x-icon', - 'application/pdf', - 'text/plain', - 'text/csv', - 'application/json', -]) - -const FORCE_ATTACHMENT_EXTENSIONS = new Set(['html', 'htm', 'js', 'css', 'xml']) - -export function getSecureFileHeaders(filename: string, originalContentType: string) { - const extension = filename.split('.').pop()?.toLowerCase() || '' - - if (FORCE_ATTACHMENT_EXTENSIONS.has(extension)) { - return { - contentType: 'application/octet-stream', - disposition: 'attachment', - } - } - - let safeContentType = originalContentType - - if (originalContentType === 'text/html') { - safeContentType = 'text/plain' - } - - const disposition = SAFE_INLINE_TYPES.has(safeContentType) ? 'inline' : 'attachment' - - return { - contentType: safeContentType, - disposition, - } -} - -/** - * Percent-encode a filename as an RFC 8187 `ext-value`. - * - * `encodeURIComponent` alone is not enough: it leaves `'`, `(`, `)` and `*` raw, and - * none of those are `attr-char`. The apostrophe is the specific hazard — it is the - * delimiter in `UTF-8''name`, so a filename like `it's.pdf` would emit a third `'` - * and desync the parser. - */ -function encodeExtValue(filename: string): string { - return encodeURIComponent(filename).replace( - /['()*]/g, - (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}` - ) -} - -/** - * Build the `filename` parameters for a Content-Disposition header. - * - * The name is attacker-controlled (it is the user's `originalName`), so it can never - * be interpolated raw: a `"` closes the quoted-string early and everything after it - * is parsed as further parameters. An injected `filename*` is the payload that - * matters, because RFC 6266 tells clients to prefer `filename*` over `filename` — - * so the attacker's value wins and the download lands under a name the product UI - * never showed. Both parameters are therefore always emitted from sanitized input: - * the quoted form keeps only printable ASCII minus `"` and `\`, and the `filename*` - * form is fully percent-encoded. - * - * `;` is neutralized too, even though a quoted string may legally contain one: the - * quoted parameter exists as the fallback for clients that do not implement - * `filename*`, and those are the same clients liable to split parameters on a bare - * `;` without honouring the quoting. The exact name still survives in `filename*`. - */ -export function encodeFilenameForHeader(storageKey: string): string { - const filename = storageKey.split('/').pop() || storageKey - const asciiSafe = filename.replace(/[^\x20-\x7E]/g, '_').replace(/["\\;]/g, '_') - // Unchanged input proves the name is printable ASCII with no `"` or `\`, so the - // quoted form alone is both safe and sufficient — `filename*` buys nothing here. - if (asciiSafe === filename) { - return `filename="${filename}"` - } - return `filename="${asciiSafe}"; filename*=UTF-8''${encodeExtValue(filename)}` -} - /** * Derives the served filename from the CALLER's content type (`getSecureFileHeaders` * downgrades `text/html`) before the header decision, so a derived `.html` name gets the * same forced-attachment treatment a stored `.html` file gets. */ export function createFileResponse(file: FileResponse): NextResponse { - const servedFilename = ensureFileNameExtension(file.filename, file.contentType) - - const { contentType, disposition } = getSecureFileHeaders(servedFilename, file.contentType) - - const headers: Record = { - 'Content-Type': contentType, - 'Content-Disposition': `${disposition}; ${encodeFilenameForHeader(servedFilename)}`, - // Default to PRIVATE: this response is served only after access verification, so it must never be - // stored by a shared cache/CDN and re-served cross-user. Genuinely public assets (avatars, OG images, - // workspace logos) pass an explicit `cacheControl` (see PUBLIC_ASSET_CACHE_CONTROL in the serve route). - 'Cache-Control': file.cacheControl || 'private, no-cache', - 'X-Content-Type-Options': 'nosniff', - } - - if (contentType === 'image/svg+xml') { - headers['Content-Security-Policy'] = "default-src 'none'; style-src 'unsafe-inline'; sandbox;" - } - - return new NextResponse(file.buffer as BodyInit, { status: 200, headers }) + return new NextResponse(file.buffer as BodyInit, { + status: 200, + headers: fileDeliveryHeaders({ + ...file, + cacheControl: file.cacheControl || FILE_CACHE_CONTROL.private, + contentLength: file.buffer.length, + }), + }) } /** @@ -294,18 +202,15 @@ export function createConditionalFileResponse( file: FileResponse, ifNoneMatch: string | null ): NextResponse { - const etag = `"${createHash('sha256').update(file.buffer).digest('base64url')}"` + const etag = bufferedRepresentationEtag(file.buffer) if (ifNoneMatchHolds(ifNoneMatch, etag)) { - // A 304 repeats the headers that govern caching, so the stored response is refreshed with the - // lifetime this request would have granted it rather than keeping the one it was stored with. - return new NextResponse(null, { - status: 304, - headers: { - ETag: etag, - 'Cache-Control': file.cacheControl || 'private, no-cache', - }, + const headers = fileDeliveryHeaders({ + ...file, + cacheControl: file.cacheControl || FILE_CACHE_CONTROL.private, }) + headers.set('ETag', etag) + return new NextResponse(null, { status: 304, headers }) } const response = createFileResponse(file) @@ -331,7 +236,10 @@ export function createErrorResponse(error: Error, status = 500): NextResponse { error: error.name, message: error.message, }, - { status: statusCode } + { + status: statusCode, + headers: { 'Cache-Control': FILE_CACHE_CONTROL.noStore, 'X-Content-Type-Options': 'nosniff' }, + } ) } diff --git a/apps/sim/app/api/internal/project-file-doc/[projectId]/[fileId]/access/route.ts b/apps/sim/app/api/internal/project-file-doc/[projectId]/[fileId]/access/route.ts new file mode 100644 index 00000000000..11e44aefe8b --- /dev/null +++ b/apps/sim/app/api/internal/project-file-doc/[projectId]/[fileId]/access/route.ts @@ -0,0 +1,20 @@ +import { projectFileDocAccessContract } from '@/lib/api/contracts/project-file-doc' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, +} from '@/lib/api/server/routes' +import { realtimeProjectFileAuth } from '@/lib/auth/realtime-file-delegation' +import { getProjectFileDocAccess } from '@/lib/projects/files/application/documents' + +export const POST = defineInternalJsonRoute({ + contract: projectFileDocAccessContract, + operation: getProjectFileDocAccess.operation, + auth: realtimeProjectFileAuth, + rateLimit: internalRateLimits.none({ + reason: 'Authenticated realtime relay; socket frame admission bounds collaborative traffic', + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => params, + useCase: getProjectFileDocAccess, +}) diff --git a/apps/sim/app/api/internal/project-file-doc/[projectId]/[fileId]/persist/route.ts b/apps/sim/app/api/internal/project-file-doc/[projectId]/[fileId]/persist/route.ts new file mode 100644 index 00000000000..05223b4958f --- /dev/null +++ b/apps/sim/app/api/internal/project-file-doc/[projectId]/[fileId]/persist/route.ts @@ -0,0 +1,25 @@ +import { projectFileDocPersistContract } from '@/lib/api/contracts/project-file-doc' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, +} from '@/lib/api/server/routes' +import { realtimeProjectFileAuth } from '@/lib/auth/realtime-file-delegation' +import { persistProjectFileDoc } from '@/lib/projects/files/application/documents' + +export const POST = defineInternalJsonRoute({ + contract: projectFileDocPersistContract, + operation: persistProjectFileDoc.operation, + auth: realtimeProjectFileAuth, + rateLimit: internalRateLimits.none({ + reason: 'Authenticated realtime relay; socket frame admission bounds collaborative traffic', + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ + ...params, + docState: new Uint8Array(Buffer.from(body.docState, 'base64')), + expectedVersion: body.expectedVersion, + }), + useCase: persistProjectFileDoc, + parseOptions: { maxBodyBytes: 17 * 1024 * 1024 }, +}) diff --git a/apps/sim/app/api/internal/project-file-doc/[projectId]/[fileId]/seed/route.ts b/apps/sim/app/api/internal/project-file-doc/[projectId]/[fileId]/seed/route.ts new file mode 100644 index 00000000000..69c3f353d37 --- /dev/null +++ b/apps/sim/app/api/internal/project-file-doc/[projectId]/[fileId]/seed/route.ts @@ -0,0 +1,24 @@ +import { projectFileDocSeedContract } from '@/lib/api/contracts/project-file-doc' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, +} from '@/lib/api/server/routes' +import { realtimeProjectFileAuth } from '@/lib/auth/realtime-file-delegation' +import { buildProjectFileDocSeed } from '@/lib/projects/files/application/documents' + +export const POST = defineInternalJsonRoute({ + contract: projectFileDocSeedContract, + operation: buildProjectFileDocSeed.operation, + auth: realtimeProjectFileAuth, + rateLimit: internalRateLimits.none({ + reason: 'Authenticated realtime relay; socket frame admission bounds collaborative traffic', + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => params, + useCase: buildProjectFileDocSeed, + present: (result) => ({ + update: Buffer.from(result.update).toString('base64'), + version: result.version, + }), +}) diff --git a/apps/sim/app/api/internal/project-file-list/[projectId]/access/route.ts b/apps/sim/app/api/internal/project-file-list/[projectId]/access/route.ts new file mode 100644 index 00000000000..0e735c4afe2 --- /dev/null +++ b/apps/sim/app/api/internal/project-file-list/[projectId]/access/route.ts @@ -0,0 +1,21 @@ +import { projectFileListAccessContract } from '@/lib/api/contracts/realtime-file-lists' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, +} from '@/lib/api/server/routes' +import { realtimeProjectFileListAuth } from '@/lib/auth/realtime-file-list-delegation' +import { getProjectFileListAccess } from '@/lib/projects/files/application' + +export const POST = defineInternalJsonRoute({ + contract: projectFileListAccessContract, + operation: getProjectFileListAccess.operation, + auth: realtimeProjectFileListAuth, + rateLimit: internalRateLimits.none({ + reason: + 'Service-authenticated realtime collection admission and bounded membership revalidation', + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => params, + useCase: getProjectFileListAccess, +}) diff --git a/apps/sim/app/api/knowledge/[id]/export/route.ts b/apps/sim/app/api/knowledge/[id]/export/route.ts index b7c9c25e560..2c4ad52080e 100644 --- a/apps/sim/app/api/knowledge/[id]/export/route.ts +++ b/apps/sim/app/api/knowledge/[id]/export/route.ts @@ -12,7 +12,7 @@ import { buildKnowledgeBundleArchive, knowledgeBundleFileName, } from '@/lib/knowledge/transfer/export-archive' -import { encodeFilenameForHeader } from '@/app/api/files/utils' +import { encodeFilenameForHeader } from '@/lib/uploads/server/delivery' /** GET /api/knowledge/[id]/export — stream a knowledge base as a bundle archive. */ export const GET = defineInternalBinaryRoute({ diff --git a/apps/sim/app/api/projects/[id]/files/[fileId]/artifact/route.ts b/apps/sim/app/api/projects/[id]/files/[fileId]/artifact/route.ts new file mode 100644 index 00000000000..c18a1989e84 --- /dev/null +++ b/apps/sim/app/api/projects/[id]/files/[fileId]/artifact/route.ts @@ -0,0 +1,30 @@ +import { readProjectFileArtifactContract } from '@/lib/api/contracts/project-files' +import { + defineInternalBinaryRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { presentProjectFileContent } from '@/lib/projects/files/api' +import { readProjectFileArtifact } from '@/lib/projects/files/application' +import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' + +export const GET = defineInternalBinaryRoute({ + contract: readProjectFileArtifactContract, + auth: internalSessionAuth, + operation: readProjectFileArtifact.operation, + rateLimit: internalRateLimits.none({ + reason: 'Authenticated bounded file rendering follows the existing private preview policy', + }), + errorPolicy: internalOrchestrationErrorPolicy, + headSafe: false, + mapInput: ({ params, query }) => ({ + preview: query.preview === '1', + projectId: params.id, + fileId: params.fileId, + maxBytes: MAX_BUFFERED_TRANSFER_BYTES, + }), + useCase: readProjectFileArtifact, + present: ({ file, buffer, contentType }) => + presentProjectFileContent({ file: { ...file, type: contentType }, content: buffer }), +}) diff --git a/apps/sim/app/api/projects/[id]/files/[fileId]/content/route.ts b/apps/sim/app/api/projects/[id]/files/[fileId]/content/route.ts new file mode 100644 index 00000000000..1fa726fa6ab --- /dev/null +++ b/apps/sim/app/api/projects/[id]/files/[fileId]/content/route.ts @@ -0,0 +1,55 @@ +import { + readProjectFileContentContract, + updateProjectFileContentContract, +} from '@/lib/api/contracts/project-files' +import { + defineInternalBinaryRoute, + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { presentProjectFileContent } from '@/lib/projects/files/api' +import { readProjectFileContent, updateProjectFileContent } from '@/lib/projects/files/application' +import { MAX_WORKSPACE_FILE_INLINE_BODY_BYTES } from '@/lib/workspace-files/orchestration' + +export const GET = defineInternalBinaryRoute({ + contract: readProjectFileContentContract, + auth: internalSessionAuth, + headSafe: false, + operation: readProjectFileContent.operation, + rateLimit: internalRateLimits.none({ + reason: 'Authenticated internal file byte delivery follows the workspace download policy', + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ projectId: params.id, fileId: params.fileId }), + useCase: readProjectFileContent, + present: presentProjectFileContent, +}) + +export const PUT = defineInternalJsonRoute({ + contract: updateProjectFileContentContract, + auth: internalSessionAuth, + operation: updateProjectFileContent.operation, + rateLimit: internalRateLimits.user({ bucketName: 'project-files.write' }), + errorPolicy: internalOrchestrationErrorPolicy, + parseOptions: { maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES }, + beforeParse: async ({ principal, params }) => { + if (typeof params.id === 'string' && typeof params.fileId === 'string') + await updateProjectFileContent.authorize({ + principal, + input: { projectId: params.id, fileId: params.fileId, content: '', encoding: 'utf-8' }, + }) + }, + mapInput: ({ params, body }) => ({ + projectId: params.id, + fileId: params.fileId, + content: body.content, + encoding: body.encoding ?? 'utf-8', + contentType: body.contentType, + expectedRevision: body.expectedRevision, + ...(body.expectedUpdatedAt ? { expectedUpdatedAt: new Date(body.expectedUpdatedAt) } : {}), + }), + useCase: updateProjectFileContent, + present: (result) => result, +}) diff --git a/apps/sim/app/api/projects/[id]/files/[fileId]/csv-preview/route.ts b/apps/sim/app/api/projects/[id]/files/[fileId]/csv-preview/route.ts new file mode 100644 index 00000000000..1b3e9f4cd2b --- /dev/null +++ b/apps/sim/app/api/projects/[id]/files/[fileId]/csv-preview/route.ts @@ -0,0 +1,24 @@ +import { getProjectCsvPreviewContract } from '@/lib/api/contracts/project-files' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { readProjectFileCsvPreview } from '@/lib/projects/files/application' + +export const GET = defineInternalJsonRoute({ + contract: getProjectCsvPreviewContract, + auth: internalSessionAuth, + operation: readProjectFileCsvPreview.operation, + rateLimit: internalRateLimits.user({ bucketName: 'project-files.csv-preview' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, query }, { request }) => ({ + projectId: params.id, + fileId: params.fileId, + key: query.key, + signal: request.signal, + }), + useCase: readProjectFileCsvPreview, + present: ({ success, headers, rows, truncated }) => ({ success, headers, rows, truncated }), +}) diff --git a/apps/sim/app/api/projects/[id]/files/[fileId]/export/route.ts b/apps/sim/app/api/projects/[id]/files/[fileId]/export/route.ts new file mode 100644 index 00000000000..e6f575dfc15 --- /dev/null +++ b/apps/sim/app/api/projects/[id]/files/[fileId]/export/route.ts @@ -0,0 +1,24 @@ +import { + exportProjectFileSnapshotContract, + MAX_PROJECT_FILE_SNAPSHOT_BODY_BYTES, +} from '@/lib/api/contracts/project-file-downloads' +import { + defineInternalBinaryRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { presentProjectFileDownload } from '@/lib/projects/files/api/download-presenter' +import { exportProjectFileSnapshot } from '@/lib/projects/files/application' + +export const POST = defineInternalBinaryRoute({ + contract: exportProjectFileSnapshotContract, + parseOptions: { maxBodyBytes: MAX_PROJECT_FILE_SNAPSHOT_BODY_BYTES }, + auth: internalSessionAuth, + operation: exportProjectFileSnapshot.operation, + rateLimit: internalRateLimits.none({ reason: 'Authenticated bounded Markdown snapshot export' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ projectId: params.id, fileId: params.fileId, ...body }), + useCase: exportProjectFileSnapshot, + present: presentProjectFileDownload, +}) diff --git a/apps/sim/app/api/projects/[id]/files/[fileId]/extract/route.ts b/apps/sim/app/api/projects/[id]/files/[fileId]/extract/route.ts new file mode 100644 index 00000000000..41b5d123f4a --- /dev/null +++ b/apps/sim/app/api/projects/[id]/files/[fileId]/extract/route.ts @@ -0,0 +1,21 @@ +import { extractProjectFileContract } from '@/lib/api/contracts/project-file-extraction' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { extractProjectFile } from '@/lib/projects/files/application' +import { internalFileErrorPolicies } from '@/lib/workspace-files/api' + +export const maxDuration = 300 + +export const POST = defineInternalJsonRoute({ + contract: extractProjectFileContract, + auth: internalSessionAuth, + operation: extractProjectFile.operation, + rateLimit: internalRateLimits.user({ bucketName: 'project-files.write' }), + errorPolicy: internalFileErrorPolicies.extractArchive, + mapInput: ({ params }) => ({ projectId: params.id, fileId: params.fileId }), + useCase: extractProjectFile, + present: (result) => ({ success: true, ...result }), +}) diff --git a/apps/sim/app/api/projects/[id]/files/[fileId]/restore/route.ts b/apps/sim/app/api/projects/[id]/files/[fileId]/restore/route.ts new file mode 100644 index 00000000000..9f6c0f30773 --- /dev/null +++ b/apps/sim/app/api/projects/[id]/files/[fileId]/restore/route.ts @@ -0,0 +1,18 @@ +import { restoreProjectFileContract } from '@/lib/api/contracts/project-file-lifecycle' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { projectFileOperations, restoreProjectFile } from '@/lib/projects/files/application' + +export const POST = defineInternalJsonRoute({ + contract: restoreProjectFileContract, + auth: internalSessionAuth, + operation: projectFileOperations.restore, + rateLimit: internalRateLimits.user({ bucketName: 'project-files.write' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ projectId: params.id, fileId: params.fileId }), + useCase: restoreProjectFile, +}) diff --git a/apps/sim/app/api/projects/[id]/files/[fileId]/route.ts b/apps/sim/app/api/projects/[id]/files/[fileId]/route.ts new file mode 100644 index 00000000000..d41fbbefccd --- /dev/null +++ b/apps/sim/app/api/projects/[id]/files/[fileId]/route.ts @@ -0,0 +1,33 @@ +import { renameProjectFileContract } from '@/lib/api/contracts/project-file-lifecycle' +import { getProjectFileContract } from '@/lib/api/contracts/project-files' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + getProjectFileMetadata, + projectFileOperations, + renameProjectFile, +} from '@/lib/projects/files/application' + +export const GET = defineInternalJsonRoute({ + contract: getProjectFileContract, + auth: internalSessionAuth, + operation: projectFileOperations.readMetadata, + rateLimit: internalRateLimits.user({ bucketName: 'project-files.read' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ projectId: params.id, fileId: params.fileId }), + useCase: getProjectFileMetadata, +}) + +export const PATCH = defineInternalJsonRoute({ + contract: renameProjectFileContract, + auth: internalSessionAuth, + operation: projectFileOperations.rename, + rateLimit: internalRateLimits.user({ bucketName: 'project-files.write' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ projectId: params.id, fileId: params.fileId, ...body }), + useCase: renameProjectFile, +}) diff --git a/apps/sim/app/api/projects/[id]/files/[fileId]/share/route.ts b/apps/sim/app/api/projects/[id]/files/[fileId]/share/route.ts new file mode 100644 index 00000000000..717e120acaa --- /dev/null +++ b/apps/sim/app/api/projects/[id]/files/[fileId]/share/route.ts @@ -0,0 +1,34 @@ +import { + getProjectFileShareContract, + updateProjectFileShareContract, +} from '@/lib/api/contracts/project-file-shares' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + getProjectFileShare, + projectFileOperations, + updateProjectFileShare, +} from '@/lib/projects/files/application' + +export const GET = defineInternalJsonRoute({ + contract: getProjectFileShareContract, + auth: internalSessionAuth, + operation: projectFileOperations.readShare, + rateLimit: internalRateLimits.user({ bucketName: 'project-files.read' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ projectId: params.id, fileId: params.fileId }), + useCase: getProjectFileShare, +}) +export const PUT = defineInternalJsonRoute({ + contract: updateProjectFileShareContract, + auth: internalSessionAuth, + operation: projectFileOperations.updateShare, + rateLimit: internalRateLimits.user({ bucketName: 'project-files.write' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ projectId: params.id, fileId: params.fileId, ...body }), + useCase: updateProjectFileShare, +}) diff --git a/apps/sim/app/api/projects/[id]/files/[fileId]/versions/[version]/content/route.ts b/apps/sim/app/api/projects/[id]/files/[fileId]/versions/[version]/content/route.ts new file mode 100644 index 00000000000..419b68d8c19 --- /dev/null +++ b/apps/sim/app/api/projects/[id]/files/[fileId]/versions/[version]/content/route.ts @@ -0,0 +1,26 @@ +import { readProjectFileVersionContentContract } from '@/lib/api/contracts/project-file-versions' +import { + defineInternalBinaryRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { presentProjectFileVersionContent } from '@/lib/projects/files/api' +import { readProjectFileVersionContent } from '@/lib/projects/files/application' +export const GET = defineInternalBinaryRoute({ + contract: readProjectFileVersionContentContract, + auth: internalSessionAuth, + headSafe: false, + operation: readProjectFileVersionContent.operation, + rateLimit: internalRateLimits.none({ + reason: 'Authenticated historical byte delivery follows the workspace download policy', + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ + projectId: params.id, + fileId: params.fileId, + version: params.version, + }), + useCase: readProjectFileVersionContent, + present: presentProjectFileVersionContent, +}) diff --git a/apps/sim/app/api/projects/[id]/files/[fileId]/versions/[version]/revert/route.ts b/apps/sim/app/api/projects/[id]/files/[fileId]/versions/[version]/revert/route.ts new file mode 100644 index 00000000000..311d998f099 --- /dev/null +++ b/apps/sim/app/api/projects/[id]/files/[fileId]/versions/[version]/revert/route.ts @@ -0,0 +1,31 @@ +import { revertProjectFileVersionContract } from '@/lib/api/contracts/project-file-versions' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { toProjectFileVersion } from '@/lib/projects/files/api' +import { revertProjectFileVersion } from '@/lib/projects/files/application' +import { workspaceFileRevisionField } from '@/lib/workspace-files/application/file-revision' +export const POST = defineInternalJsonRoute({ + contract: revertProjectFileVersionContract, + auth: internalSessionAuth, + operation: revertProjectFileVersion.operation, + rateLimit: internalRateLimits.user({ bucketName: 'project-files.history' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ + projectId: params.id, + fileId: params.fileId, + version: params.version, + expectedCurrentVersion: body.expectedCurrentVersion, + expectedRevision: body.expectedRevision, + }), + useCase: revertProjectFileVersion, + present: ({ file, version, reverted }) => ({ + file: file, + version: toProjectFileVersion(version), + reverted, + ...workspaceFileRevisionField(file), + }), +}) diff --git a/apps/sim/app/api/projects/[id]/files/[fileId]/versions/[version]/route.ts b/apps/sim/app/api/projects/[id]/files/[fileId]/versions/[version]/route.ts new file mode 100644 index 00000000000..38b20a207cd --- /dev/null +++ b/apps/sim/app/api/projects/[id]/files/[fileId]/versions/[version]/route.ts @@ -0,0 +1,40 @@ +import { + deleteProjectFileVersionContract, + getProjectFileVersionContract, +} from '@/lib/api/contracts/project-file-versions' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { toProjectFileVersion } from '@/lib/projects/files/api' +import { deleteProjectFileVersion, readProjectFileVersion } from '@/lib/projects/files/application' +export const GET = defineInternalJsonRoute({ + contract: getProjectFileVersionContract, + auth: internalSessionAuth, + operation: readProjectFileVersion.operation, + rateLimit: internalRateLimits.user({ bucketName: 'project-files.history' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ + projectId: params.id, + fileId: params.fileId, + version: params.version, + }), + useCase: readProjectFileVersion, + present: ({ version }) => ({ version: toProjectFileVersion(version) }), +}) +export const DELETE = defineInternalJsonRoute({ + contract: deleteProjectFileVersionContract, + auth: internalSessionAuth, + operation: deleteProjectFileVersion.operation, + rateLimit: internalRateLimits.user({ bucketName: 'project-files.history' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ + projectId: params.id, + fileId: params.fileId, + version: params.version, + }), + useCase: deleteProjectFileVersion, + present: ({ file, version }) => ({ fileId: file.id, version, deleted: true as const }), +}) diff --git a/apps/sim/app/api/projects/[id]/files/[fileId]/versions/route.ts b/apps/sim/app/api/projects/[id]/files/[fileId]/versions/route.ts new file mode 100644 index 00000000000..297ee8dcef2 --- /dev/null +++ b/apps/sim/app/api/projects/[id]/files/[fileId]/versions/route.ts @@ -0,0 +1,48 @@ +import { listProjectFileVersionsContract } from '@/lib/api/contracts/project-file-versions' +import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { toProjectFileVersion } from '@/lib/projects/files/api' +import { listProjectFileVersions } from '@/lib/projects/files/application' +import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' +export const GET = defineInternalJsonRoute({ + contract: listProjectFileVersionsContract, + auth: internalSessionAuth, + operation: listProjectFileVersions.operation, + rateLimit: internalRateLimits.user({ bucketName: 'project-files.history' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, query }) => ({ + projectId: params.id, + fileId: params.fileId, + sortOrder: query.sortOrder, + limit: query.limit, + after: readSortedCursor( + query.cursor, + query.sortBy, + query.sortOrder, + cursorScopeKey( + cursorRoute(listProjectFileVersionsContract, { id: params.id, fileId: params.fileId }) + ) + ), + }), + useCase: listProjectFileVersions, + present: ({ versions, nextKeys, revision }, { input }) => ({ + revision, + versions: versions.map(toProjectFileVersion), + nextCursor: writeSortedCursor( + nextKeys, + 'version', + input.sortOrder, + cursorScopeKey( + cursorRoute(listProjectFileVersionsContract, { + id: input.projectId, + fileId: input.fileId, + }) + ) + ), + }), +}) diff --git a/apps/sim/app/api/projects/[id]/files/archive/route.ts b/apps/sim/app/api/projects/[id]/files/archive/route.ts new file mode 100644 index 00000000000..5dda324ffaa --- /dev/null +++ b/apps/sim/app/api/projects/[id]/files/archive/route.ts @@ -0,0 +1,18 @@ +import { archiveProjectFileItemsContract } from '@/lib/api/contracts/project-file-lifecycle' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { archiveProjectFileItems, projectFileOperations } from '@/lib/projects/files/application' + +export const POST = defineInternalJsonRoute({ + contract: archiveProjectFileItemsContract, + auth: internalSessionAuth, + operation: projectFileOperations.archiveItems, + rateLimit: internalRateLimits.user({ bucketName: 'project-files.write' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ projectId: params.id, ...body }), + useCase: archiveProjectFileItems, +}) diff --git a/apps/sim/app/api/projects/[id]/files/download/route.ts b/apps/sim/app/api/projects/[id]/files/download/route.ts new file mode 100644 index 00000000000..293b9a237ab --- /dev/null +++ b/apps/sim/app/api/projects/[id]/files/download/route.ts @@ -0,0 +1,20 @@ +import { downloadProjectFileItemsContract } from '@/lib/api/contracts/project-file-downloads' +import { + defineInternalBinaryRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { presentProjectFileDownload } from '@/lib/projects/files/api/download-presenter' +import { downloadProjectFileItems } from '@/lib/projects/files/application' + +export const GET = defineInternalBinaryRoute({ + contract: downloadProjectFileItemsContract, + auth: internalSessionAuth, + operation: downloadProjectFileItems.operation, + rateLimit: internalRateLimits.none({ reason: 'Authenticated bounded Project archive download' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, query }) => ({ projectId: params.id, ...query }), + useCase: downloadProjectFileItems, + present: presentProjectFileDownload, +}) diff --git a/apps/sim/app/api/projects/[id]/files/folders/[folderId]/restore/route.ts b/apps/sim/app/api/projects/[id]/files/folders/[folderId]/restore/route.ts new file mode 100644 index 00000000000..4afc4583018 --- /dev/null +++ b/apps/sim/app/api/projects/[id]/files/folders/[folderId]/restore/route.ts @@ -0,0 +1,18 @@ +import { restoreProjectFileFolderContract } from '@/lib/api/contracts/project-file-lifecycle' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { projectFileOperations, restoreProjectFileFolder } from '@/lib/projects/files/application' + +export const POST = defineInternalJsonRoute({ + contract: restoreProjectFileFolderContract, + auth: internalSessionAuth, + operation: projectFileOperations.restoreFolder, + rateLimit: internalRateLimits.user({ bucketName: 'project-files.write' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params }) => ({ projectId: params.id, folderId: params.folderId }), + useCase: restoreProjectFileFolder, +}) diff --git a/apps/sim/app/api/projects/[id]/files/folders/[folderId]/route.ts b/apps/sim/app/api/projects/[id]/files/folders/[folderId]/route.ts new file mode 100644 index 00000000000..66ae653f777 --- /dev/null +++ b/apps/sim/app/api/projects/[id]/files/folders/[folderId]/route.ts @@ -0,0 +1,19 @@ +import { updateProjectFileFolderContract } from '@/lib/api/contracts/project-file-folders' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { projectFileOperations, updateProjectFileFolder } from '@/lib/projects/files/application' + +export const PATCH = defineInternalJsonRoute({ + contract: updateProjectFileFolderContract, + auth: internalSessionAuth, + operation: projectFileOperations.updateFolder, + rateLimit: internalRateLimits.user({ bucketName: 'project-file-folders.write' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ projectId: params.id, folderId: params.folderId, ...body }), + useCase: updateProjectFileFolder, + present: (result) => result, +}) diff --git a/apps/sim/app/api/projects/[id]/files/folders/route.ts b/apps/sim/app/api/projects/[id]/files/folders/route.ts new file mode 100644 index 00000000000..47cb2c6e6d4 --- /dev/null +++ b/apps/sim/app/api/projects/[id]/files/folders/route.ts @@ -0,0 +1,37 @@ +import { + createProjectFileFolderContract, + listProjectFileFoldersContract, +} from '@/lib/api/contracts/project-file-folders' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + createProjectFileFolder, + listProjectFileFolders, + projectFileOperations, +} from '@/lib/projects/files/application' + +export const GET = defineInternalJsonRoute({ + contract: listProjectFileFoldersContract, + auth: internalSessionAuth, + operation: projectFileOperations.listFolders, + rateLimit: internalRateLimits.user({ bucketName: 'project-file-folders.read' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, query }) => ({ projectId: params.id, scope: query.scope }), + useCase: listProjectFileFolders, + present: (result) => result, +}) + +export const POST = defineInternalJsonRoute({ + contract: createProjectFileFolderContract, + auth: internalSessionAuth, + operation: projectFileOperations.createFolder, + rateLimit: internalRateLimits.user({ bucketName: 'project-file-folders.write' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ projectId: params.id, ...body }), + useCase: createProjectFileFolder, + present: (result) => result, +}) diff --git a/apps/sim/app/api/projects/[id]/files/inline/route.ts b/apps/sim/app/api/projects/[id]/files/inline/route.ts new file mode 100644 index 00000000000..8caab953b32 --- /dev/null +++ b/apps/sim/app/api/projects/[id]/files/inline/route.ts @@ -0,0 +1,25 @@ +import { getInlineProjectFileContract } from '@/lib/api/contracts/project-files' +import { + defineInternalBinaryRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { presentProjectFileContent } from '@/lib/projects/files/api' +import { readProjectInlineFile } from '@/lib/projects/files/application' + +export const GET = defineInternalBinaryRoute({ + contract: getInlineProjectFileContract, + auth: internalSessionAuth, + headSafe: false, + operation: readProjectInlineFile.operation, + rateLimit: internalRateLimits.none({ reason: 'Authenticated embedded file delivery' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, query }) => ({ + projectId: params.id, + key: query.key, + referenceFileId: query.fileId, + }), + useCase: readProjectInlineFile, + present: presentProjectFileContent, +}) diff --git a/apps/sim/app/api/projects/[id]/files/move/route.ts b/apps/sim/app/api/projects/[id]/files/move/route.ts new file mode 100644 index 00000000000..1c9daa7ba64 --- /dev/null +++ b/apps/sim/app/api/projects/[id]/files/move/route.ts @@ -0,0 +1,18 @@ +import { moveProjectFileItemsContract } from '@/lib/api/contracts/project-file-lifecycle' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { moveProjectFileItems, projectFileOperations } from '@/lib/projects/files/application' + +export const POST = defineInternalJsonRoute({ + contract: moveProjectFileItemsContract, + auth: internalSessionAuth, + operation: projectFileOperations.moveItems, + rateLimit: internalRateLimits.user({ bucketName: 'project-files.write' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ projectId: params.id, ...body }), + useCase: moveProjectFileItems, +}) diff --git a/apps/sim/app/api/projects/[id]/files/route.ts b/apps/sim/app/api/projects/[id]/files/route.ts new file mode 100644 index 00000000000..58814a29bae --- /dev/null +++ b/apps/sim/app/api/projects/[id]/files/route.ts @@ -0,0 +1,119 @@ +import { + createProjectFileContract, + listProjectFilesContract, +} from '@/lib/api/contracts/project-files' +import { canonicalUnorderedArray, cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { + createProjectFile, + listProjectFileItems, + projectFileOperations, +} from '@/lib/projects/files/application' +import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' +import { MAX_WORKSPACE_FILE_INLINE_BODY_BYTES } from '@/lib/workspace-files/orchestration' +import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' + +function cursorFilters( + id: string, + query: { + scope?: 'active' | 'archived' + folderId?: string | null + types?: readonly string[] + sizes?: readonly string[] + creatorIds?: readonly string[] + search?: string + } +) { + return cursorScopeKey(cursorRoute(listProjectFilesContract, { id }), { + scope: query.scope, + folderId: query.folderId, + types: canonicalUnorderedArray(query.types ?? []), + sizes: canonicalUnorderedArray(query.sizes ?? []), + creatorIds: canonicalUnorderedArray(query.creatorIds ?? []), + search: query.search, + }) +} + +export const GET = defineInternalJsonRoute({ + contract: listProjectFilesContract, + auth: internalSessionAuth, + operation: projectFileOperations.list, + rateLimit: internalRateLimits.user({ bucketName: 'project-files.read' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, query }) => { + const folderId = + query.recursive || query.search || query.scope === 'archived' + ? undefined + : (query.folderId ?? null) + return { + projectId: params.id, + scope: query.scope, + folderId, + types: query.types, + sizes: query.sizes, + creatorIds: query.creatorIds, + search: query.search, + sortBy: query.sortBy, + sortOrder: query.sortOrder, + limit: query.limit, + after: readSortedCursor( + query.cursor, + query.sortBy, + query.sortOrder, + cursorFilters(params.id, { ...query, folderId }) + ), + } + }, + useCase: listProjectFileItems, + present: ({ files, items, creators, nextKeys, capabilities }, { input }) => ({ + files, + items, + creators, + capabilities, + nextCursor: writeSortedCursor( + nextKeys, + input.sortBy, + input.sortOrder, + cursorFilters(input.projectId, input) + ), + }), +}) + +export const POST = defineInternalJsonRoute({ + contract: createProjectFileContract, + auth: internalSessionAuth, + operation: projectFileOperations.create, + rateLimit: internalRateLimits.user({ bucketName: 'project-files.write' }), + errorPolicy: internalOrchestrationErrorPolicy, + parseOptions: { maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES }, + beforeParse: async ({ principal, params }) => { + if (typeof params.id === 'string') + await createProjectFile.authorize({ + principal, + input: { + projectId: params.id, + name: 'admission', + contentType: 'application/octet-stream', + content: '', + encoding: 'utf-8', + }, + }) + }, + mapInput: ({ params, body }) => ({ + projectId: params.id, + name: body.name, + contentType: body.contentType ?? getMimeTypeFromExtension(getFileExtension(body.name)), + content: body.content, + encoding: body.encoding, + exactName: body.exactName, + folderId: body.folderId, + folderPath: body.folderPath, + }), + useCase: createProjectFile, + present: (result) => result, +}) diff --git a/apps/sim/app/api/projects/[id]/files/uploads/[uploadId]/complete/route.ts b/apps/sim/app/api/projects/[id]/files/uploads/[uploadId]/complete/route.ts new file mode 100644 index 00000000000..0cd1e6a422d --- /dev/null +++ b/apps/sim/app/api/projects/[id]/files/uploads/[uploadId]/complete/route.ts @@ -0,0 +1,25 @@ +import { completeProjectFileUploadContract } from '@/lib/api/contracts/project-file-uploads' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { presentProjectFileUpload } from '@/lib/projects/files/api' +import { completeProjectFileUploadSession } from '@/lib/projects/files/application' + +export const POST = defineInternalJsonRoute({ + contract: completeProjectFileUploadContract, + parseOptions: { optionalJsonBody: true }, + auth: internalSessionAuth, + operation: completeProjectFileUploadSession.operation, + rateLimit: internalRateLimits.user({ bucketName: 'project-files.upload' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, headers }) => ({ + projectId: params.id, + uploadId: params.uploadId, + uploadToken: headers['upload-token'], + }), + useCase: completeProjectFileUploadSession, + present: ({ session, value }) => presentProjectFileUpload(session, value.file), +}) diff --git a/apps/sim/app/api/projects/[id]/files/uploads/[uploadId]/parts/route.ts b/apps/sim/app/api/projects/[id]/files/uploads/[uploadId]/parts/route.ts new file mode 100644 index 00000000000..79e26b2983a --- /dev/null +++ b/apps/sim/app/api/projects/[id]/files/uploads/[uploadId]/parts/route.ts @@ -0,0 +1,26 @@ +import { getProjectFileUploadPartUrlsContract } from '@/lib/api/contracts/project-file-uploads' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { getProjectFileUploadPartUrls } from '@/lib/projects/files/application' +import { requestOrigin } from '@/lib/uploads/upload-session/application' + +export const POST = defineInternalJsonRoute({ + contract: getProjectFileUploadPartUrlsContract, + auth: internalSessionAuth, + operation: getProjectFileUploadPartUrls.operation, + rateLimit: internalRateLimits.user({ bucketName: 'project-files.upload' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, headers, body }, { request }) => ({ + projectId: params.id, + localOrigin: requestOrigin(request), + uploadId: params.uploadId, + uploadToken: headers['upload-token'], + partNumbers: body.partNumbers, + }), + useCase: getProjectFileUploadPartUrls, + present: (result) => result, +}) diff --git a/apps/sim/app/api/projects/[id]/files/uploads/[uploadId]/route.ts b/apps/sim/app/api/projects/[id]/files/uploads/[uploadId]/route.ts new file mode 100644 index 00000000000..d4d751cdad1 --- /dev/null +++ b/apps/sim/app/api/projects/[id]/files/uploads/[uploadId]/route.ts @@ -0,0 +1,45 @@ +import { + abortProjectFileUploadContract, + getProjectFileUploadContract, +} from '@/lib/api/contracts/project-file-uploads' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { presentProjectFileUpload } from '@/lib/projects/files/api' +import { + abortProjectFileUploadSession, + getProjectFileUploadSession, +} from '@/lib/projects/files/application' + +export const GET = defineInternalJsonRoute({ + contract: getProjectFileUploadContract, + auth: internalSessionAuth, + operation: getProjectFileUploadSession.operation, + rateLimit: internalRateLimits.user({ bucketName: 'project-files.upload' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, headers }) => ({ + projectId: params.id, + uploadId: params.uploadId, + uploadToken: headers['upload-token'], + }), + useCase: getProjectFileUploadSession, + present: ({ session, file }) => presentProjectFileUpload(session, file), +}) + +export const DELETE = defineInternalJsonRoute({ + contract: abortProjectFileUploadContract, + auth: internalSessionAuth, + operation: abortProjectFileUploadSession.operation, + rateLimit: internalRateLimits.user({ bucketName: 'project-files.upload' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, headers }) => ({ + projectId: params.id, + uploadId: params.uploadId, + uploadToken: headers['upload-token'], + }), + useCase: abortProjectFileUploadSession, + present: (session) => presentProjectFileUpload(session, null), +}) diff --git a/apps/sim/app/api/projects/[id]/files/uploads/route.ts b/apps/sim/app/api/projects/[id]/files/uploads/route.ts new file mode 100644 index 00000000000..4f64cc3d0a0 --- /dev/null +++ b/apps/sim/app/api/projects/[id]/files/uploads/route.ts @@ -0,0 +1,33 @@ +import { createProjectFileUploadContract } from '@/lib/api/contracts/project-file-uploads' +import { + defineInternalJsonRoute, + internalOrchestrationErrorPolicy, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { presentProjectFileUpload } from '@/lib/projects/files/api' +import { createProjectFileUploadSession } from '@/lib/projects/files/application' +import { requestOrigin } from '@/lib/uploads/upload-session/application' + +export const POST = defineInternalJsonRoute({ + contract: createProjectFileUploadContract, + auth: internalSessionAuth, + operation: createProjectFileUploadSession.operation, + rateLimit: internalRateLimits.user({ bucketName: 'project-files.upload' }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: ({ params, body }, { request }) => ({ + projectId: params.id, + localOrigin: requestOrigin(request), + fileName: body.name, + contentType: body.contentType, + fileSize: body.size, + folderId: body.folderId, + folderPath: body.folderPath, + }), + useCase: createProjectFileUploadSession, + present: (session) => ({ + session: presentProjectFileUpload(session, null), + uploadToken: session.uploadToken, + transfer: session.transfer, + }), +}) diff --git a/apps/sim/app/api/users/me/settings/route.test.ts b/apps/sim/app/api/users/me/settings/route.test.ts index 050225f6090..0485a5b31cc 100644 --- a/apps/sim/app/api/users/me/settings/route.test.ts +++ b/apps/sim/app/api/users/me/settings/route.test.ts @@ -44,7 +44,10 @@ describe('GET /api/users/me/settings', () => { }) it('preserves anonymous defaults without entering the protected current-user read', async () => { - const response = await GET() + const response = await GET( + createMockRequest({ method: 'GET', url: 'http://localhost:3000/api/users/me/settings' }), + undefined + ) expect(response.status).toBe(200) await expect(response.json()).resolves.toMatchObject({ @@ -59,7 +62,10 @@ describe('GET /api/users/me/settings', () => { throw new Error('Database unavailable') }) - const response = await GET() + const response = await GET( + createMockRequest({ method: 'GET', url: 'http://localhost:3000/api/users/me/settings' }), + undefined + ) expect(response.status).toBe(500) await expect(response.json()).resolves.toEqual({ error: 'Failed to load settings' }) diff --git a/apps/sim/app/api/v1/admin/folders/[id]/export/route.ts b/apps/sim/app/api/v1/admin/folders/[id]/export/route.ts index 529adaabf1d..37e1c412e37 100644 --- a/apps/sim/app/api/v1/admin/folders/[id]/export/route.ts +++ b/apps/sim/app/api/v1/admin/folders/[id]/export/route.ts @@ -25,10 +25,10 @@ import { adminV1ExportFolderContract } from '@/lib/api/contracts/v1/admin' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { isWorkspaceFolder } from '@/lib/folders/scope' +import { encodeFilenameForHeader } from '@/lib/uploads/server/delivery' import { exportFolderToZip, sanitizePathSegment } from '@/lib/workflows/operations/import-export' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' import { parseWorkflowVariables } from '@/lib/workflows/variables/parse' -import { encodeFilenameForHeader } from '@/app/api/files/utils' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { internalErrorResponse, diff --git a/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts b/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts index c3c80b9d790..bf9f77e76a1 100644 --- a/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts +++ b/apps/sim/app/api/v1/admin/workspaces/[id]/export/route.ts @@ -20,10 +20,10 @@ import { NextResponse } from 'next/server' import { adminV1ExportWorkspaceContract } from '@/lib/api/contracts/v1/admin' import { parseRequest } from '@/lib/api/server' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' +import { encodeFilenameForHeader } from '@/lib/uploads/server/delivery' import { exportWorkspaceToZip, sanitizePathSegment } from '@/lib/workflows/operations/import-export' import { loadWorkflowFromNormalizedTables } from '@/lib/workflows/persistence/utils' import { parseWorkflowVariables } from '@/lib/workflows/variables/parse' -import { encodeFilenameForHeader } from '@/app/api/files/utils' import { withAdminAuthParams } from '@/app/api/v1/admin/middleware' import { internalErrorResponse, diff --git a/apps/sim/app/api/v2/files/[fileId]/route.ts b/apps/sim/app/api/v2/files/[fileId]/route.ts index eb0df4f5f66..a81e5b9ea15 100644 --- a/apps/sim/app/api/v2/files/[fileId]/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/route.ts @@ -9,12 +9,12 @@ import { v2ApiKeyAuth, v2RateLimits, } from '@/lib/api/server/routes' +import { FILE_CACHE_CONTROL, presentFileDelivery } from '@/lib/uploads/server/delivery' import { v2FileErrorPolicies } from '@/lib/workspace-files/api' import { deleteWorkspaceFileOperation } from '@/lib/workspace-files/application/delete-workspace-file' import { downloadWorkspaceFileStream } from '@/lib/workspace-files/application/download-workspace-file' import { fileOperations } from '@/lib/workspace-files/application/operations' import { renameWorkspaceFile } from '@/lib/workspace-files/application/rename-workspace-file' -import { encodeFilenameForHeader } from '@/app/api/files/utils' import { toV2File } from '@/app/api/v2/files/utils' export const dynamic = 'force-dynamic' @@ -44,12 +44,15 @@ export const GET = defineV2BinaryRoute({ assertedWorkspaceId: query.workspaceId, }), useCase: downloadWorkspaceFileStream, - present: ({ file, stream, contentType, contentLength }) => ({ - body: stream, - contentType, - contentDisposition: `attachment; ${encodeFilenameForHeader(file.name)}`, - contentLength, - }), + present: ({ file, stream, contentType, contentLength }) => + presentFileDelivery({ + body: stream, + filename: file.name, + contentType, + contentLength, + attachment: true, + cacheControl: FILE_CACHE_CONTROL.noStore, + }), }) /** diff --git a/apps/sim/app/api/v2/files/[fileId]/versions/[version]/content/route.ts b/apps/sim/app/api/v2/files/[fileId]/versions/[version]/content/route.ts index d2f978f1572..5c5c16fe85f 100644 --- a/apps/sim/app/api/v2/files/[fileId]/versions/[version]/content/route.ts +++ b/apps/sim/app/api/v2/files/[fileId]/versions/[version]/content/route.ts @@ -1,9 +1,9 @@ import { v2DownloadFileVersionContract } from '@/lib/api/contracts/v2/file-versions' import { defineV2BinaryRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { FILE_CACHE_CONTROL, presentFileDelivery } from '@/lib/uploads/server/delivery' import { v2FileErrorPolicies } from '@/lib/workspace-files/api' import { downloadWorkspaceFileVersion } from '@/lib/workspace-files/application/file-versions' import { fileOperations } from '@/lib/workspace-files/application/operations' -import { encodeFilenameForHeader } from '@/app/api/files/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 @@ -29,10 +29,13 @@ export const GET = defineV2BinaryRoute({ version: params.version, }), useCase: downloadWorkspaceFileVersion, - present: ({ file, stream, contentType, contentLength }) => ({ - body: stream, - contentType, - contentDisposition: `attachment; ${encodeFilenameForHeader(file.name)}`, - contentLength, - }), + present: ({ file, stream, contentType, contentLength }) => + presentFileDelivery({ + body: stream, + filename: file.name, + contentType, + contentLength, + attachment: true, + cacheControl: FILE_CACHE_CONTROL.noStore, + }), }) diff --git a/apps/sim/app/api/v2/files/bulk-download/route.ts b/apps/sim/app/api/v2/files/bulk-download/route.ts index f2322d778ce..00d255c5d31 100644 --- a/apps/sim/app/api/v2/files/bulk-download/route.ts +++ b/apps/sim/app/api/v2/files/bulk-download/route.ts @@ -1,33 +1,13 @@ -import { Readable } from 'node:stream' -import { createLogger } from '@sim/logger' -import { ZipArchive } from 'archiver' import { v2BulkDownloadFilesContract } from '@/lib/api/contracts/v2/files' import { defineV2BinaryRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' -import { nodeReadableToWebStream } from '@/lib/core/utils/node-stream' -import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' -import { downloadFileStream } from '@/lib/uploads/core/storage-service' -import { buildZipEntryPaths } from '@/lib/uploads/zip-entry-path' -import { v2FileErrorPolicies } from '@/lib/workspace-files/api' +import { presentWorkspaceFileArchive, v2FileErrorPolicies } from '@/lib/workspace-files/api' import { downloadWorkspaceFileItems } from '@/lib/workspace-files/application/download-workspace-file-items' import { fileOperations } from '@/lib/workspace-files/application/operations' -const logger = createLogger('V2FilesBulkDownloadAPI') - export const dynamic = 'force-dynamic' export const revalidate = 0 /** Opens each object only as the archiver reaches it, so peak memory stays flat. */ -function lazyWorkspaceFileStream(file: WorkspaceFileRecord): Readable { - return Readable.from( - (async function* () { - yield* await downloadFileStream({ - key: file.key, - context: file.storageContext ?? 'workspace', - }) - })(), - { objectMode: false } - ) -} /** * GET /api/v2/files/bulk-download — stream a selection of files as one zip. @@ -54,31 +34,5 @@ export const GET = defineV2BinaryRoute({ folderPaths: query.folderPaths, }), useCase: downloadWorkspaceFileItems, - present: ({ filesToZip, folderPaths, renderedDocuments }) => { - const entryPaths = buildZipEntryPaths( - filesToZip.map((file) => ({ - name: file.name, - folderPath: file.folderId ? folderPaths.get(file.folderId) : null, - contentType: file.type, - })) - ) - const archive = new ZipArchive({ store: true }) - archive.on('warning', (error: Error) => { - logger.warn('Archive warning while streaming workspace files', { error }) - }) - filesToZip.forEach((file, index) => { - archive.append(renderedDocuments.get(file.id) ?? lazyWorkspaceFileStream(file), { - name: entryPaths[index], - }) - }) - archive.finalize().catch((error) => { - logger.error('Failed to finalize workspace file archive', { error }) - }) - - return { - body: nodeReadableToWebStream(archive), - contentType: 'application/zip', - contentDisposition: 'attachment; filename="workspace-files.zip"', - } - }, + present: presentWorkspaceFileArchive, }) diff --git a/apps/sim/app/api/v2/files/copy/route.ts b/apps/sim/app/api/v2/files/copy/route.ts new file mode 100644 index 00000000000..3de70475a44 --- /dev/null +++ b/apps/sim/app/api/v2/files/copy/route.ts @@ -0,0 +1,21 @@ +import { v2CopyFileItemsContract } from '@/lib/api/contracts/v2/file-copy' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { presentCopiedFileItems } from '@/lib/workspace-files/api/copy-presenter' +import { copyFileItems } from '@/lib/workspace-files/application/copy-file-items' +import { fileCopyOperations } from '@/lib/workspace-files/application/operations' + +export const POST = defineV2JsonRoute({ + contract: v2CopyFileItemsContract, + auth: v2ApiKeyAuth, + operation: fileCopyOperations.copy, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ body }) => body, + useCase: copyFileItems, + present: (result) => ({ data: presentCopiedFileItems(result) }), +}) diff --git a/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/export/route.ts b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/export/route.ts index 4d8b8a2cc8f..5c1a842bcae 100644 --- a/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/export/route.ts +++ b/apps/sim/app/api/v2/knowledge/[knowledgeBaseId]/export/route.ts @@ -8,7 +8,7 @@ import { buildKnowledgeBundleArchive, knowledgeBundleFileName, } from '@/lib/knowledge/transfer/export-archive' -import { encodeFilenameForHeader } from '@/app/api/files/utils' +import { encodeFilenameForHeader } from '@/lib/uploads/server/delivery' export const dynamic = 'force-dynamic' export const revalidate = 0 diff --git a/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/content/route.ts b/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/content/route.ts new file mode 100644 index 00000000000..54057fb7d9d --- /dev/null +++ b/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/content/route.ts @@ -0,0 +1,46 @@ +import { + v2ReadProjectFileContentContract, + v2UpdateProjectFileContentContract, +} from '@/lib/api/contracts/v2/project-files' +import { + defineV2BinaryRoute, + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { presentProjectFileContent } from '@/lib/projects/files/api' +import { toV2ProjectFile } from '@/lib/projects/files/api/presenters' +import { readProjectFileContent, updateProjectFileContent } from '@/lib/projects/files/application' +import { projectFileOperations } from '@/lib/projects/files/application/operations' +import { MAX_WORKSPACE_FILE_INLINE_BODY_BYTES } from '@/lib/workspace-files/orchestration' + +export const GET = defineV2BinaryRoute({ + contract: v2ReadProjectFileContentContract, + auth: v2ApiKeyAuth, + operation: projectFileOperations.readContent, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params }) => ({ projectId: params.projectId, fileId: params.fileId }), + useCase: readProjectFileContent, + present: presentProjectFileContent, + headSafe: false, +}) + +export const PUT = defineV2JsonRoute({ + contract: v2UpdateProjectFileContentContract, + auth: v2ApiKeyAuth, + operation: projectFileOperations.updateContent, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + parseOptions: { maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES }, + mapInput: ({ params, body }) => ({ + projectId: params.projectId, + fileId: params.fileId, + content: body.content, + encoding: body.encoding, + expectedRevision: body.expectedRevision, + }), + useCase: updateProjectFileContent, + present: ({ file }) => ({ data: toV2ProjectFile(file) }), +}) diff --git a/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/export/route.ts b/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/export/route.ts new file mode 100644 index 00000000000..282d07f3947 --- /dev/null +++ b/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/export/route.ts @@ -0,0 +1,23 @@ +import { MAX_PROJECT_FILE_SNAPSHOT_BODY_BYTES } from '@/lib/api/contracts/project-file-downloads' +import { v2ExportProjectFileSnapshotContract } from '@/lib/api/contracts/v2/project-file-downloads' +import { + defineV2BinaryRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { presentProjectFileDownload } from '@/lib/projects/files/api/download-presenter' +import { exportProjectFileSnapshot } from '@/lib/projects/files/application' +import { projectFileOperations } from '@/lib/projects/files/application/operations' + +export const POST = defineV2BinaryRoute({ + contract: v2ExportProjectFileSnapshotContract, + parseOptions: { maxBodyBytes: MAX_PROJECT_FILE_SNAPSHOT_BODY_BYTES }, + auth: v2ApiKeyAuth, + operation: projectFileOperations.exportSnapshot, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ ...params, content: body.content }), + useCase: exportProjectFileSnapshot, + present: presentProjectFileDownload, +}) diff --git a/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/metadata/route.ts b/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/metadata/route.ts new file mode 100644 index 00000000000..bd9bf654b3f --- /dev/null +++ b/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/metadata/route.ts @@ -0,0 +1,21 @@ +import { v2GetProjectFileMetadataContract } from '@/lib/api/contracts/v2/project-files' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { toV2ProjectFile } from '@/lib/projects/files/api/presenters' +import { getProjectFileMetadata } from '@/lib/projects/files/application' +import { projectFileOperations } from '@/lib/projects/files/application/operations' + +export const GET = defineV2JsonRoute({ + contract: v2GetProjectFileMetadataContract, + auth: v2ApiKeyAuth, + operation: projectFileOperations.readMetadata, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params }) => params, + useCase: getProjectFileMetadata, + present: ({ file }) => ({ data: toV2ProjectFile(file) }), +}) diff --git a/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/restore/route.ts b/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/restore/route.ts new file mode 100644 index 00000000000..cf0b5a51d42 --- /dev/null +++ b/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/restore/route.ts @@ -0,0 +1,21 @@ +import { v2RestoreProjectFileContract } from '@/lib/api/contracts/v2/project-file-lifecycle' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { toV2ProjectFile } from '@/lib/projects/files/api/presenters' +import { restoreProjectFile } from '@/lib/projects/files/application' +import { projectFileOperations } from '@/lib/projects/files/application/operations' + +export const POST = defineV2JsonRoute({ + contract: v2RestoreProjectFileContract, + auth: v2ApiKeyAuth, + operation: projectFileOperations.restore, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params }) => ({ ...params }), + useCase: restoreProjectFile, + present: ({ file }) => ({ data: toV2ProjectFile(file) }), +}) diff --git a/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/route.ts b/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/route.ts new file mode 100644 index 00000000000..f60e2064cf9 --- /dev/null +++ b/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/route.ts @@ -0,0 +1,21 @@ +import { v2RenameProjectFileContract } from '@/lib/api/contracts/v2/project-file-lifecycle' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { toV2ProjectFile } from '@/lib/projects/files/api/presenters' +import { renameProjectFile } from '@/lib/projects/files/application' +import { projectFileOperations } from '@/lib/projects/files/application/operations' + +export const PATCH = defineV2JsonRoute({ + contract: v2RenameProjectFileContract, + auth: v2ApiKeyAuth, + operation: projectFileOperations.rename, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ ...params, ...body }), + useCase: renameProjectFile, + present: ({ file }) => ({ data: toV2ProjectFile(file) }), +}) diff --git a/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/share/route.ts b/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/share/route.ts new file mode 100644 index 00000000000..8b27b2d7791 --- /dev/null +++ b/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/share/route.ts @@ -0,0 +1,41 @@ +import { + v2GetProjectFileShareContract, + v2UpdateProjectFileShareContract, +} from '@/lib/api/contracts/v2/project-file-shares' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { projectFileOperations } from '@/lib/projects/files/application/operations' +import { + getProjectFileShare, + updateProjectFileShare, +} from '@/lib/projects/files/application/shares' + +export const GET = defineV2JsonRoute({ + contract: v2GetProjectFileShareContract, + auth: v2ApiKeyAuth, + operation: projectFileOperations.readShare, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params }) => ({ projectId: params.projectId, fileId: params.fileId }), + useCase: getProjectFileShare, + present: ({ share }) => ({ data: share }), +}) + +export const PATCH = defineV2JsonRoute({ + contract: v2UpdateProjectFileShareContract, + auth: v2ApiKeyAuth, + operation: projectFileOperations.updateShare, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ + projectId: params.projectId, + fileId: params.fileId, + ...body, + }), + useCase: updateProjectFileShare, + present: ({ share }) => ({ data: share }), +}) diff --git a/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/unzip/route.ts b/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/unzip/route.ts new file mode 100644 index 00000000000..899587397a0 --- /dev/null +++ b/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/unzip/route.ts @@ -0,0 +1,26 @@ +import { v2UnzipProjectFileContract } from '@/lib/api/contracts/v2/project-file-extraction' +import { defineV2JsonRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { buildFolderPath } from '@/lib/folders/paths' +import { extractProjectFile } from '@/lib/projects/files/application' +import { projectFileOperations } from '@/lib/projects/files/application/operations' +import { v2FileErrorPolicies } from '@/lib/workspace-files/api' +import { parseWorkspaceFileFolderDisplayPath } from '@/lib/workspace-files/folder-display-path' + +export const maxDuration = 300 + +export const POST = defineV2JsonRoute({ + contract: v2UnzipProjectFileContract, + auth: v2ApiKeyAuth, + operation: projectFileOperations.extractArchive, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2FileErrorPolicies.concealExtractionAuthorization, + mapInput: ({ params }) => ({ projectId: params.projectId, fileId: params.fileId }), + useCase: extractProjectFile, + present: (result) => ({ + data: { + folderPath: buildFolderPath(parseWorkspaceFileFolderDisplayPath(result.folderDisplayPath)), + extractedFileCount: result.extractedCount, + skippedFileCount: result.skippedCount, + }, + }), +}) diff --git a/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/versions/[version]/content/route.ts b/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/versions/[version]/content/route.ts new file mode 100644 index 00000000000..292f50d7582 --- /dev/null +++ b/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/versions/[version]/content/route.ts @@ -0,0 +1,25 @@ +import { v2ReadProjectFileVersionContentContract } from '@/lib/api/contracts/v2/project-file-versions' +import { + defineV2BinaryRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { presentProjectFileVersionContent } from '@/lib/projects/files/api' +import { readProjectFileVersionContent } from '@/lib/projects/files/application' +import { projectFileOperations } from '@/lib/projects/files/application/operations' +export const GET = defineV2BinaryRoute({ + contract: v2ReadProjectFileVersionContentContract, + auth: v2ApiKeyAuth, + operation: projectFileOperations.readVersionContent, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + headSafe: false, + mapInput: ({ params }) => ({ + projectId: params.projectId, + fileId: params.fileId, + version: params.version, + }), + useCase: readProjectFileVersionContent, + present: presentProjectFileVersionContent, +}) diff --git a/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/versions/[version]/revert/route.ts b/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/versions/[version]/revert/route.ts new file mode 100644 index 00000000000..88e6b4343b6 --- /dev/null +++ b/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/versions/[version]/revert/route.ts @@ -0,0 +1,34 @@ +import { v2RevertProjectFileVersionContract } from '@/lib/api/contracts/v2/project-file-versions' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { toProjectFileVersion, toV2ProjectFile } from '@/lib/projects/files/api' +import { revertProjectFileVersion } from '@/lib/projects/files/application' +import { projectFileOperations } from '@/lib/projects/files/application/operations' +import { workspaceFileRevisionField } from '@/lib/workspace-files/application/file-revision' +export const POST = defineV2JsonRoute({ + contract: v2RevertProjectFileVersionContract, + auth: v2ApiKeyAuth, + operation: projectFileOperations.revertVersion, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ + projectId: params.projectId, + fileId: params.fileId, + version: params.version, + expectedCurrentVersion: body.expectedCurrentVersion, + expectedRevision: body.expectedRevision, + }), + useCase: revertProjectFileVersion, + present: ({ file, version, reverted }) => ({ + data: { + file: toV2ProjectFile(file), + version: toProjectFileVersion(version), + reverted, + ...workspaceFileRevisionField(file), + }, + }), +}) diff --git a/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/versions/[version]/route.ts b/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/versions/[version]/route.ts new file mode 100644 index 00000000000..4f10d24cc3c --- /dev/null +++ b/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/versions/[version]/route.ts @@ -0,0 +1,41 @@ +import { + v2DeleteProjectFileVersionContract, + v2GetProjectFileVersionContract, +} from '@/lib/api/contracts/v2/project-file-versions' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { toProjectFileVersion } from '@/lib/projects/files/api' +import { deleteProjectFileVersion, readProjectFileVersion } from '@/lib/projects/files/application' +import { projectFileOperations } from '@/lib/projects/files/application/operations' +export const GET = defineV2JsonRoute({ + contract: v2GetProjectFileVersionContract, + auth: v2ApiKeyAuth, + operation: projectFileOperations.readVersion, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params }) => ({ + projectId: params.projectId, + fileId: params.fileId, + version: params.version, + }), + useCase: readProjectFileVersion, + present: ({ version }) => ({ data: toProjectFileVersion(version) }), +}) +export const DELETE = defineV2JsonRoute({ + contract: v2DeleteProjectFileVersionContract, + auth: v2ApiKeyAuth, + operation: projectFileOperations.deleteVersion, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params }) => ({ + projectId: params.projectId, + fileId: params.fileId, + version: params.version, + }), + useCase: deleteProjectFileVersion, + present: ({ file, version }) => ({ data: { fileId: file.id, version, deleted: true as const } }), +}) diff --git a/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/versions/route.ts b/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/versions/route.ts new file mode 100644 index 00000000000..648b77ab7d1 --- /dev/null +++ b/apps/sim/app/api/v2/projects/[projectId]/files/[fileId]/versions/route.ts @@ -0,0 +1,51 @@ +import { v2ListProjectFileVersionsContract } from '@/lib/api/contracts/v2/project-file-versions' +import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { toProjectFileVersion } from '@/lib/projects/files/api' +import { listProjectFileVersions } from '@/lib/projects/files/application' +import { projectFileOperations } from '@/lib/projects/files/application/operations' +import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' +export const GET = defineV2JsonRoute({ + contract: v2ListProjectFileVersionsContract, + auth: v2ApiKeyAuth, + operation: projectFileOperations.listVersions, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params, query }) => ({ + projectId: params.projectId, + fileId: params.fileId, + sortOrder: query.sortOrder, + limit: query.limit, + after: readSortedCursor( + query.cursor, + query.sortBy, + query.sortOrder, + cursorScopeKey( + cursorRoute(v2ListProjectFileVersionsContract, { + projectId: params.projectId, + fileId: params.fileId, + }) + ) + ), + }), + useCase: listProjectFileVersions, + present: ({ versions, nextKeys }, { params, query }) => ({ + data: versions.map(toProjectFileVersion), + nextCursor: writeSortedCursor( + nextKeys, + query.sortBy, + query.sortOrder, + cursorScopeKey( + cursorRoute(v2ListProjectFileVersionsContract, { + projectId: params.projectId, + fileId: params.fileId, + }) + ) + ), + }), +}) diff --git a/apps/sim/app/api/v2/projects/[projectId]/files/archive/route.ts b/apps/sim/app/api/v2/projects/[projectId]/files/archive/route.ts new file mode 100644 index 00000000000..3595cddde3b --- /dev/null +++ b/apps/sim/app/api/v2/projects/[projectId]/files/archive/route.ts @@ -0,0 +1,20 @@ +import { v2ArchiveProjectFileItemsContract } from '@/lib/api/contracts/v2/project-file-lifecycle' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { archiveProjectFileItems } from '@/lib/projects/files/application' +import { projectFileOperations } from '@/lib/projects/files/application/operations' + +export const POST = defineV2JsonRoute({ + contract: v2ArchiveProjectFileItemsContract, + auth: v2ApiKeyAuth, + operation: projectFileOperations.archiveItems, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ ...params, ...body }), + useCase: archiveProjectFileItems, + present: (result) => ({ data: result }), +}) diff --git a/apps/sim/app/api/v2/projects/[projectId]/files/bulk-download/route.ts b/apps/sim/app/api/v2/projects/[projectId]/files/bulk-download/route.ts new file mode 100644 index 00000000000..f50f95dfa3c --- /dev/null +++ b/apps/sim/app/api/v2/projects/[projectId]/files/bulk-download/route.ts @@ -0,0 +1,22 @@ +import { v2DownloadProjectFileItemsContract } from '@/lib/api/contracts/v2/project-file-downloads' +import { + defineV2BinaryRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { presentProjectFileDownload } from '@/lib/projects/files/api/download-presenter' +import { downloadProjectFileItems } from '@/lib/projects/files/application' +import { projectFileOperations } from '@/lib/projects/files/application/operations' + +export const GET = defineV2BinaryRoute({ + contract: v2DownloadProjectFileItemsContract, + auth: v2ApiKeyAuth, + operation: projectFileOperations.downloadItems, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + headSafe: false, + mapInput: ({ params, query }) => ({ projectId: params.projectId, ...query }), + useCase: downloadProjectFileItems, + present: presentProjectFileDownload, +}) diff --git a/apps/sim/app/api/v2/projects/[projectId]/files/folders/[folderId]/restore/route.ts b/apps/sim/app/api/v2/projects/[projectId]/files/folders/[folderId]/restore/route.ts new file mode 100644 index 00000000000..2acf28c40e9 --- /dev/null +++ b/apps/sim/app/api/v2/projects/[projectId]/files/folders/[folderId]/restore/route.ts @@ -0,0 +1,23 @@ +import { v2RestoreProjectFileFolderContract } from '@/lib/api/contracts/v2/project-file-folders' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { toV2ProjectFileFolder } from '@/lib/projects/files/api/presenters' +import { restoreProjectFileFolder } from '@/lib/projects/files/application' +import { projectFileOperations } from '@/lib/projects/files/application/operations' + +export const POST = defineV2JsonRoute({ + contract: v2RestoreProjectFileFolderContract, + auth: v2ApiKeyAuth, + operation: projectFileOperations.restoreFolder, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params }) => ({ ...params }), + useCase: restoreProjectFileFolder, + present: ({ folder, restoredItems }) => ({ + data: { folder: toV2ProjectFileFolder(folder), restoredItems }, + }), +}) diff --git a/apps/sim/app/api/v2/projects/[projectId]/files/folders/[folderId]/route.ts b/apps/sim/app/api/v2/projects/[projectId]/files/folders/[folderId]/route.ts new file mode 100644 index 00000000000..5a8f80ac5ed --- /dev/null +++ b/apps/sim/app/api/v2/projects/[projectId]/files/folders/[folderId]/route.ts @@ -0,0 +1,21 @@ +import { v2UpdateProjectFileFolderContract } from '@/lib/api/contracts/v2/project-file-folders' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { toV2ProjectFileFolder } from '@/lib/projects/files/api/presenters' +import { updateProjectFileFolder } from '@/lib/projects/files/application' +import { projectFileOperations } from '@/lib/projects/files/application/operations' + +export const PATCH = defineV2JsonRoute({ + contract: v2UpdateProjectFileFolderContract, + auth: v2ApiKeyAuth, + operation: projectFileOperations.updateFolder, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ ...params, ...body }), + useCase: updateProjectFileFolder, + present: ({ folder }) => ({ data: toV2ProjectFileFolder(folder) }), +}) diff --git a/apps/sim/app/api/v2/projects/[projectId]/files/folders/route.ts b/apps/sim/app/api/v2/projects/[projectId]/files/folders/route.ts new file mode 100644 index 00000000000..e56888d13ce --- /dev/null +++ b/apps/sim/app/api/v2/projects/[projectId]/files/folders/route.ts @@ -0,0 +1,35 @@ +import { + v2CreateProjectFileFolderContract, + v2ListProjectFileFoldersContract, +} from '@/lib/api/contracts/v2/project-file-folders' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { toV2ProjectFileFolder } from '@/lib/projects/files/api/presenters' +import { createProjectFileFolder, listProjectFileFolders } from '@/lib/projects/files/application' +import { projectFileOperations } from '@/lib/projects/files/application/operations' + +export const GET = defineV2JsonRoute({ + contract: v2ListProjectFileFoldersContract, + auth: v2ApiKeyAuth, + operation: projectFileOperations.listFolders, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params, query }) => ({ ...params, ...query }), + useCase: listProjectFileFolders, + present: ({ folders }) => ({ data: folders.map(toV2ProjectFileFolder), nextCursor: null }), +}) + +export const POST = defineV2JsonRoute({ + contract: v2CreateProjectFileFolderContract, + auth: v2ApiKeyAuth, + operation: projectFileOperations.createFolder, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ ...params, ...body }), + useCase: createProjectFileFolder, + present: ({ folder }) => ({ data: toV2ProjectFileFolder(folder) }), +}) diff --git a/apps/sim/app/api/v2/projects/[projectId]/files/move/route.ts b/apps/sim/app/api/v2/projects/[projectId]/files/move/route.ts new file mode 100644 index 00000000000..2da7873dd48 --- /dev/null +++ b/apps/sim/app/api/v2/projects/[projectId]/files/move/route.ts @@ -0,0 +1,24 @@ +import { v2MoveProjectFileItemsContract } from '@/lib/api/contracts/v2/project-file-lifecycle' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { moveProjectFileItems } from '@/lib/projects/files/application' +import { projectFileOperations } from '@/lib/projects/files/application/operations' + +export const POST = defineV2JsonRoute({ + contract: v2MoveProjectFileItemsContract, + auth: v2ApiKeyAuth, + operation: projectFileOperations.moveItems, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ + ...params, + ...body, + targetFolderPath: body.targetFolderPath ?? '/', + }), + useCase: moveProjectFileItems, + present: (result) => ({ data: result }), +}) diff --git a/apps/sim/app/api/v2/projects/[projectId]/files/route.ts b/apps/sim/app/api/v2/projects/[projectId]/files/route.ts new file mode 100644 index 00000000000..2e29ba81b6d --- /dev/null +++ b/apps/sim/app/api/v2/projects/[projectId]/files/route.ts @@ -0,0 +1,82 @@ +import { listsSubfolders } from '@/lib/api/contracts/v2/files' +import { + type V2ListProjectFilesQuery, + v2CreateProjectFileContract, + v2ListProjectFilesContract, +} from '@/lib/api/contracts/v2/project-files' +import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { toV2ProjectFile } from '@/lib/projects/files/api/presenters' +import { createProjectFile, listProjectFiles } from '@/lib/projects/files/application' +import { projectFileOperations } from '@/lib/projects/files/application/operations' +import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' +import { MAX_WORKSPACE_FILE_INLINE_BODY_BYTES } from '@/lib/workspace-files/orchestration' +import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' + +function cursorFilters(projectId: string, query: V2ListProjectFilesQuery) { + return cursorScopeKey(cursorRoute(v2ListProjectFilesContract, { projectId }), { + scope: query.scope, + folderPath: query.folderPath, + recursive: String(listsSubfolders(query)), + search: query.search, + }) +} + +export const GET = defineV2JsonRoute({ + contract: v2ListProjectFilesContract, + auth: v2ApiKeyAuth, + operation: projectFileOperations.list, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params, query }) => ({ + projectId: params.projectId, + scope: query.scope, + folderPath: query.folderPath, + recursive: listsSubfolders(query), + search: query.search, + sortBy: query.sortBy, + sortOrder: query.sortOrder, + limit: query.limit, + after: readSortedCursor( + query.cursor, + query.sortBy, + query.sortOrder, + cursorFilters(params.projectId, query) + ), + }), + useCase: listProjectFiles, + present: ({ files, nextKeys }, { params, query }) => ({ + data: files.map(toV2ProjectFile), + nextCursor: writeSortedCursor( + nextKeys, + query.sortBy, + query.sortOrder, + cursorFilters(params.projectId, query) + ), + }), +}) + +export const POST = defineV2JsonRoute({ + contract: v2CreateProjectFileContract, + auth: v2ApiKeyAuth, + operation: projectFileOperations.create, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + parseOptions: { maxBodyBytes: MAX_WORKSPACE_FILE_INLINE_BODY_BYTES }, + mapInput: ({ params, body }) => ({ + projectId: params.projectId, + name: body.name, + contentType: body.contentType ?? getMimeTypeFromExtension(getFileExtension(body.name)), + content: body.content, + encoding: body.encoding, + folderPath: body.folderPath ?? '/', + exactName: true, + }), + useCase: createProjectFile, + present: ({ file }) => ({ data: toV2ProjectFile(file) }), +}) diff --git a/apps/sim/app/api/v2/projects/[projectId]/files/search/route.ts b/apps/sim/app/api/v2/projects/[projectId]/files/search/route.ts new file mode 100644 index 00000000000..acb8e777195 --- /dev/null +++ b/apps/sim/app/api/v2/projects/[projectId]/files/search/route.ts @@ -0,0 +1,28 @@ +import { parseFolderPathList } from '@/lib/api/contracts/v2/files' +import { v2SearchProjectFileContentContract } from '@/lib/api/contracts/v2/project-file-search' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { searchProjectFileContent } from '@/lib/projects/files/application' +import { projectFileOperations } from '@/lib/projects/files/application/operations' + +export const GET = defineV2JsonRoute({ + contract: v2SearchProjectFileContentContract, + auth: v2ApiKeyAuth, + operation: projectFileOperations.searchContent, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params, query }) => ({ + ...params, + ...query, + folderPaths: + query.folderPaths === undefined ? undefined : parseFolderPathList(query.folderPaths), + }), + useCase: searchProjectFileContent, + present: ({ results, count, truncated, complete, indexStatus }) => ({ + data: { results, count, truncated, complete, indexStatus }, + }), +}) diff --git a/apps/sim/app/api/v2/projects/[projectId]/files/uploads/[uploadId]/complete/route.ts b/apps/sim/app/api/v2/projects/[projectId]/files/uploads/[uploadId]/complete/route.ts new file mode 100644 index 00000000000..1c5f1c1a75a --- /dev/null +++ b/apps/sim/app/api/v2/projects/[projectId]/files/uploads/[uploadId]/complete/route.ts @@ -0,0 +1,25 @@ +import { v2CompleteProjectFileUploadContract } from '@/lib/api/contracts/v2/project-file-uploads' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { toV2ProjectFileUpload } from '@/lib/projects/files/api/upload-presenter' +import { completeProjectFileUploadSession } from '@/lib/projects/files/application' +import { projectFileOperations } from '@/lib/projects/files/application/operations' + +export const POST = defineV2JsonRoute({ + contract: v2CompleteProjectFileUploadContract, + auth: v2ApiKeyAuth, + operation: projectFileOperations.uploadComplete, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params, headers }) => ({ + projectId: params.projectId, + uploadId: params.uploadId, + uploadToken: headers['upload-token'], + }), + useCase: completeProjectFileUploadSession, + present: ({ session, value }) => ({ data: toV2ProjectFileUpload(session, value.file) }), +}) diff --git a/apps/sim/app/api/v2/projects/[projectId]/files/uploads/[uploadId]/parts/route.ts b/apps/sim/app/api/v2/projects/[projectId]/files/uploads/[uploadId]/parts/route.ts new file mode 100644 index 00000000000..f86960c363e --- /dev/null +++ b/apps/sim/app/api/v2/projects/[projectId]/files/uploads/[uploadId]/parts/route.ts @@ -0,0 +1,25 @@ +import { v2GetProjectFileUploadPartUrlsContract } from '@/lib/api/contracts/v2/project-file-uploads' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { getProjectFileUploadPartUrls } from '@/lib/projects/files/application' +import { projectFileOperations } from '@/lib/projects/files/application/operations' + +export const POST = defineV2JsonRoute({ + contract: v2GetProjectFileUploadPartUrlsContract, + auth: v2ApiKeyAuth, + operation: projectFileOperations.uploadParts, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params, headers, body }) => ({ + projectId: params.projectId, + uploadId: params.uploadId, + uploadToken: headers['upload-token'], + partNumbers: body.partNumbers, + }), + useCase: getProjectFileUploadPartUrls, + present: (result) => ({ data: result }), +}) diff --git a/apps/sim/app/api/v2/projects/[projectId]/files/uploads/[uploadId]/route.ts b/apps/sim/app/api/v2/projects/[projectId]/files/uploads/[uploadId]/route.ts new file mode 100644 index 00000000000..342b400ec6f --- /dev/null +++ b/apps/sim/app/api/v2/projects/[projectId]/files/uploads/[uploadId]/route.ts @@ -0,0 +1,46 @@ +import { + v2AbortProjectFileUploadContract, + v2GetProjectFileUploadContract, +} from '@/lib/api/contracts/v2/project-file-uploads' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { toV2ProjectFileUpload } from '@/lib/projects/files/api/upload-presenter' +import { + abortProjectFileUploadSession, + getProjectFileUploadSession, +} from '@/lib/projects/files/application' +import { projectFileOperations } from '@/lib/projects/files/application/operations' + +export const GET = defineV2JsonRoute({ + contract: v2GetProjectFileUploadContract, + auth: v2ApiKeyAuth, + operation: projectFileOperations.uploadRead, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params, headers }) => ({ + projectId: params.projectId, + uploadId: params.uploadId, + uploadToken: headers['upload-token'], + }), + useCase: getProjectFileUploadSession, + present: ({ session, file }) => ({ data: toV2ProjectFileUpload(session, file) }), +}) + +export const DELETE = defineV2JsonRoute({ + contract: v2AbortProjectFileUploadContract, + auth: v2ApiKeyAuth, + operation: projectFileOperations.uploadCancel, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params, headers }) => ({ + projectId: params.projectId, + uploadId: params.uploadId, + uploadToken: headers['upload-token'], + }), + useCase: abortProjectFileUploadSession, + present: (session) => ({ data: toV2ProjectFileUpload(session, null) }), +}) diff --git a/apps/sim/app/api/v2/projects/[projectId]/files/uploads/route.ts b/apps/sim/app/api/v2/projects/[projectId]/files/uploads/route.ts new file mode 100644 index 00000000000..39fff7c9ea9 --- /dev/null +++ b/apps/sim/app/api/v2/projects/[projectId]/files/uploads/route.ts @@ -0,0 +1,34 @@ +import { v2CreateProjectFileUploadContract } from '@/lib/api/contracts/v2/project-file-uploads' +import { + defineV2JsonRoute, + v2ApiKeyAuth, + v2OrchestrationErrorPolicy, + v2RateLimits, +} from '@/lib/api/server/routes' +import { toV2ProjectFileUpload } from '@/lib/projects/files/api/upload-presenter' +import { createProjectFileUploadSession } from '@/lib/projects/files/application' +import { projectFileOperations } from '@/lib/projects/files/application/operations' + +export const POST = defineV2JsonRoute({ + contract: v2CreateProjectFileUploadContract, + auth: v2ApiKeyAuth, + operation: projectFileOperations.uploadCreate, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + mapInput: ({ params, body }) => ({ + projectId: params.projectId, + fileName: body.name, + contentType: body.contentType, + fileSize: body.size, + folderId: body.folderId, + folderPath: body.folderPath, + }), + useCase: createProjectFileUploadSession, + present: (session) => ({ + data: { + session: toV2ProjectFileUpload(session, null), + uploadToken: session.uploadToken, + transfer: session.transfer, + }, + }), +}) diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/files/[fileId]/route.ts b/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/files/[fileId]/route.ts index 5679f08562e..779c4070d69 100644 --- a/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/files/[fileId]/route.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/runs/[runId]/files/[fileId]/route.ts @@ -1,9 +1,9 @@ import { v2DownloadRunFileContract } from '@/lib/api/contracts/v2/workflows' import { defineV2BinaryRoute, v2ApiKeyAuth, v2RateLimits } from '@/lib/api/server/routes' +import { encodeFilenameForHeader } from '@/lib/uploads/server/delivery' import { v2WorkflowErrorPolicies } from '@/lib/workflows/api' import { downloadWorkflowRunFileStream } from '@/lib/workflows/application/download-workflow-run-file' import { workflowOperations } from '@/lib/workflows/application/operations' -import { encodeFilenameForHeader } from '@/app/api/files/utils' export const dynamic = 'force-dynamic' export const revalidate = 0 diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.test.ts index 7cbe7d5d6da..a2c3f14599f 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/content/route.test.ts @@ -103,13 +103,8 @@ describe('PUT /api/workspaces/[id]/files/[fileId]/content', () => { expect(mocks.updateContent).not.toHaveBeenCalled() }) - /** - * The route declares a 70 MB inline cap, but Next's proxy truncates a client - * body past 10 MiB, so the parser clamps to that ceiling and answers 413 - * rather than letting a truncated prefix surface as malformed JSON. - */ it('rejects a JSON body above the proxy ceiling after admission', async () => { - const response = await PUT(createRequest({ content: '' }, 10 * 1024 * 1024 + 1), routeContext) + const response = await PUT(createRequest({ content: '' }, 17 * 1024 * 1024 + 1), routeContext) expect(response.status).toBe(413) expect(mocks.admit).toHaveBeenCalled() diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/export/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/export/route.ts index c6d573e4d5c..1aad1b2521b 100644 --- a/apps/sim/app/api/workspaces/[id]/files/[fileId]/export/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/export/route.ts @@ -5,9 +5,9 @@ import { internalSessionAuth, } from '@/lib/api/server/routes' import { captureServerEvent } from '@/lib/posthog/server' +import { encodeFilenameForHeader } from '@/lib/uploads/server/delivery' import { internalFileErrorPolicies } from '@/lib/workspace-files/api' import { exportWorkspaceFileSnapshot } from '@/lib/workspace-files/application/export-workspace-file-snapshot' -import { encodeFilenameForHeader } from '@/app/api/files/utils' export const POST = defineInternalBinaryRoute({ contract: exportWorkspaceFileSnapshotContract, diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/versions/[version]/content/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/versions/[version]/content/route.ts new file mode 100644 index 00000000000..51830d2cce9 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/versions/[version]/content/route.ts @@ -0,0 +1,36 @@ +import { downloadWorkspaceFileVersionContract } from '@/lib/api/contracts/workspace-file-versions' +import { + defineInternalBinaryRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { FILE_CACHE_CONTROL, presentFileDelivery } from '@/lib/uploads/server/delivery' +import { internalFileErrorPolicies } from '@/lib/workspace-files/api' +import { downloadWorkspaceFileVersion } from '@/lib/workspace-files/application/file-versions' +import { fileOperations } from '@/lib/workspace-files/application/operations' + +export const GET = defineInternalBinaryRoute({ + contract: downloadWorkspaceFileVersionContract, + auth: internalSessionAuth, + headSafe: false, + operation: fileOperations.downloadVersion, + rateLimit: internalRateLimits.none({ + reason: 'Authenticated historical downloads follow the workspace binary delivery policy', + }), + errorPolicy: internalFileErrorPolicies.concealResourceAuthorization, + mapInput: ({ params }) => ({ + assertedWorkspaceId: params.id, + fileId: params.fileId, + version: params.version, + }), + useCase: downloadWorkspaceFileVersion, + present: ({ file, stream, contentType, contentLength }) => + presentFileDelivery({ + body: stream, + filename: file.name, + contentType, + contentLength, + attachment: true, + cacheControl: FILE_CACHE_CONTROL.noStore, + }), +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/versions/[version]/revert/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/versions/[version]/revert/route.ts new file mode 100644 index 00000000000..5cf11381e7f --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/versions/[version]/revert/route.ts @@ -0,0 +1,26 @@ +import { revertWorkspaceFileVersionContract } from '@/lib/api/contracts/workspace-file-versions' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalFileErrorPolicies } from '@/lib/workspace-files/api' +import { workspaceFileRevisionField } from '@/lib/workspace-files/application/file-revision' +import { revertWorkspaceFileVersion } from '@/lib/workspace-files/application/file-versions' +import { fileOperations } from '@/lib/workspace-files/application/operations' + +export const POST = defineInternalJsonRoute({ + contract: revertWorkspaceFileVersionContract, + auth: internalSessionAuth, + operation: fileOperations.revertVersion, + rateLimit: internalRateLimits.user({ bucketName: 'workspace-files.history' }), + errorPolicy: internalFileErrorPolicies.concealResourceAuthorization, + mapInput: ({ params, body }) => ({ + assertedWorkspaceId: params.id, + fileId: params.fileId, + version: params.version, + ...body, + }), + useCase: revertWorkspaceFileVersion, + present: ({ file, reverted }) => ({ reverted, ...workspaceFileRevisionField(file) }), +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/[fileId]/versions/route.ts b/apps/sim/app/api/workspaces/[id]/files/[fileId]/versions/route.ts new file mode 100644 index 00000000000..0930003b7c2 --- /dev/null +++ b/apps/sim/app/api/workspaces/[id]/files/[fileId]/versions/route.ts @@ -0,0 +1,47 @@ +import { listWorkspaceFileVersionsContract } from '@/lib/api/contracts/workspace-file-versions' +import { cursorRoute, cursorScopeKey } from '@/lib/api/cursor-binding' +import { + defineInternalJsonRoute, + internalRateLimits, + internalSessionAuth, +} from '@/lib/api/server/routes' +import { internalFileErrorPolicies, toFileVersion } from '@/lib/workspace-files/api' +import { listWorkspaceFileVersions } from '@/lib/workspace-files/application/file-versions' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { readSortedCursor, writeSortedCursor } from '@/app/api/v2/lib/response' + +export const GET = defineInternalJsonRoute({ + contract: listWorkspaceFileVersionsContract, + auth: internalSessionAuth, + operation: fileOperations.listVersions, + rateLimit: internalRateLimits.user({ bucketName: 'workspace-files.history' }), + errorPolicy: internalFileErrorPolicies.concealResourceAuthorization, + mapInput: ({ params, query }) => ({ + assertedWorkspaceId: params.id, + fileId: params.fileId, + limit: query.limit, + sortOrder: query.sortOrder, + after: readSortedCursor( + query.cursor, + query.sortBy, + query.sortOrder, + cursorScopeKey(cursorRoute(listWorkspaceFileVersionsContract, params)) + ), + }), + useCase: listWorkspaceFileVersions, + present: ({ versions, nextKeys, revision }, { input }) => ({ + versions: versions.map(toFileVersion), + revision, + nextCursor: writeSortedCursor( + nextKeys, + 'version', + input.sortOrder, + cursorScopeKey( + cursorRoute(listWorkspaceFileVersionsContract, { + id: input.assertedWorkspaceId, + fileId: input.fileId, + }) + ) + ), + }), +}) diff --git a/apps/sim/app/api/workspaces/[id]/files/download/route-archive.test.ts b/apps/sim/app/api/workspaces/[id]/files/download/route-archive.test.ts index 77c794ffc6e..a7acf73517d 100644 --- a/apps/sim/app/api/workspaces/[id]/files/download/route-archive.test.ts +++ b/apps/sim/app/api/workspaces/[id]/files/download/route-archive.test.ts @@ -15,6 +15,7 @@ import { createMockRequest } from '@sim/testing' import { createRouteContext } from '@sim/testing/helpers/http' import { auditMock } from '@sim/testing/mocks/audit.mock' import { authMockFns } from '@sim/testing/mocks/auth.mock' +import { fileReadReceiptMock } from '@sim/testing/mocks/file-read-receipt.mock' import { posthogServerMock } from '@sim/testing/mocks/posthog-server.mock' import { storageServiceMock, storageServiceMockFns } from '@sim/testing/mocks/storage-service.mock' import { workspaceAuthzMock, workspaceAuthzMockFns } from '@sim/testing/mocks/workspace-authz.mock' @@ -29,6 +30,7 @@ const run = promisify(execFile) vi.mock('@sim/platform-authz/workspace', () => workspaceAuthzMock) vi.mock('@/lib/uploads/contexts/workspace', () => workspaceUploadsMock) vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock) +vi.mock('@/lib/workspace-files/read-receipt', () => fileReadReceiptMock) vi.mock('@sim/audit', () => auditMock) vi.mock('@/lib/posthog/server', () => posthogServerMock) diff --git a/apps/sim/app/api/workspaces/[id]/files/download/route.ts b/apps/sim/app/api/workspaces/[id]/files/download/route.ts index f1ccd1ce2bd..74892fca96e 100644 --- a/apps/sim/app/api/workspaces/[id]/files/download/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/download/route.ts @@ -1,36 +1,20 @@ -import { Readable } from 'node:stream' -import { createLogger } from '@sim/logger' -import { ZipArchive } from 'archiver' import { downloadWorkspaceFileItemsContract } from '@/lib/api/contracts/workspace-file-folders' import { defineInternalBinaryRoute, internalRateLimits, internalSessionAuth, } from '@/lib/api/server/routes' -import { nodeReadableToWebStream } from '@/lib/core/utils/node-stream' -import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' -import { downloadFileStream } from '@/lib/uploads/core/storage-service' -import { buildZipEntryPaths } from '@/lib/uploads/zip-entry-path' -import { internalFileAnalytics, internalFileErrorPolicies } from '@/lib/workspace-files/api' +import { + internalFileAnalytics, + internalFileErrorPolicies, + presentWorkspaceFileArchive, +} from '@/lib/workspace-files/api' import { downloadWorkspaceFileItems } from '@/lib/workspace-files/application/download-workspace-file-items' -const logger = createLogger('WorkspaceFilesDownloadAPI') - -function lazyWorkspaceFileStream(file: WorkspaceFileRecord): Readable { - return Readable.from( - (async function* () { - yield* await downloadFileStream({ - key: file.key, - context: file.storageContext ?? 'workspace', - }) - })(), - { objectMode: false } - ) -} - export const GET = defineInternalBinaryRoute({ contract: downloadWorkspaceFileItemsContract, auth: internalSessionAuth, + headSafe: false, operation: downloadWorkspaceFileItems.operation, rateLimit: internalRateLimits.none({ reason: 'Internal workspace zip download' }), errorPolicy: internalFileErrorPolicies.downloadArchive, @@ -41,32 +25,5 @@ export const GET = defineInternalBinaryRoute({ }), useCase: downloadWorkspaceFileItems, onSuccess: internalFileAnalytics.bulkDownloaded, - present: ({ filesToZip, folderPaths, renderedDocuments }) => { - const entryPaths = buildZipEntryPaths( - filesToZip.map((file) => ({ - name: file.name, - folderPath: file.folderId ? folderPaths.get(file.folderId) : null, - contentType: file.type, - })) - ) - const archive = new ZipArchive({ store: true }) - archive.on('warning', (error: Error) => { - logger.warn('Archive warning while streaming workspace files', { error }) - }) - filesToZip.forEach((file, index) => { - archive.append(renderedDocuments.get(file.id) ?? lazyWorkspaceFileStream(file), { - name: entryPaths[index], - }) - }) - archive.finalize().catch((error) => { - logger.error('Failed to finalize workspace file archive', { error }) - }) - - return { - body: nodeReadableToWebStream(archive), - contentType: 'application/zip', - contentDisposition: 'attachment; filename="workspace-files.zip"', - headers: { 'Cache-Control': 'no-store' }, - } - }, + present: presentWorkspaceFileArchive, }) diff --git a/apps/sim/app/api/workspaces/[id]/files/inline/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/inline/route.test.ts index 85f09365f27..4cfc126a79c 100644 --- a/apps/sim/app/api/workspaces/[id]/files/inline/route.test.ts +++ b/apps/sim/app/api/workspaces/[id]/files/inline/route.test.ts @@ -9,6 +9,7 @@ vi.mock('@/lib/workspace-files/application/read-workspace-inline-file', () => ({ readWorkspaceInlineFile: { operation: { id: 'files.read_content', minimumRole: 'read', workspaceApiKey: 'allow' }, execute: mockReadInline, + authorize: vi.fn(async () => undefined), }, })) diff --git a/apps/sim/app/api/workspaces/[id]/files/inline/route.ts b/apps/sim/app/api/workspaces/[id]/files/inline/route.ts index 0a3f20bf4ec..bcb9ca37b24 100644 --- a/apps/sim/app/api/workspaces/[id]/files/inline/route.ts +++ b/apps/sim/app/api/workspaces/[id]/files/inline/route.ts @@ -4,40 +4,16 @@ import { internalRateLimits, internalSessionAuth, } from '@/lib/api/server/routes' +import { presentFileDelivery, workspaceFileCacheControl } from '@/lib/uploads/server/delivery' import { internalFileErrorPolicies } from '@/lib/workspace-files/api' import { readWorkspaceInlineFile } from '@/lib/workspace-files/application/read-workspace-inline-file' -import { encodeFilenameForHeader, getSecureFileHeaders } from '@/app/api/files/utils' export const dynamic = 'force-dynamic' -/** - * How long the browser may reuse an embedded image, decided by whether the URL names the exact object - * that was streamed (see {@link ReadWorkspaceInlineFileResult.contentAddressed}). - * - * A content write never rewrites a storage object, so a URL that names one addresses bytes that can - * never change and the browser needs no round trip — which is the difference between an embedded image - * reappearing instantly and being downloaded again. Every document render asks for the same image at - * least twice (ProseMirror's own DOM, then the React node view) and every editor mounts twice (the - * read-only placeholder, then the live editor), so revalidating each time meant re-fetching the whole - * image on every open and reload — measured at ~1 MB per open on a real document, with the image area - * blank until it landed. `private` keeps it out of shared caches: the bytes are authorized per user. - * - * Anything else — a request that names the FILE, whose bytes move under it, or one whose object was - * rotated away mid-request — keeps revalidating. - */ -const IMMUTABLE_CACHE_CONTROL = 'private, max-age=31536000, immutable' -const REVALIDATE_CACHE_CONTROL = 'private, no-cache, must-revalidate' - -/** - * GET /api/workspaces/[id]/files/inline?key=|fileId= - * - * Serves an authenticated workspace-scoped image. Authentication and the - * `files.read_content` authorization check happen before resolving or reading - * the referenced object, preserving cross-workspace concealment. - */ export const GET = defineInternalBinaryRoute({ contract: getInlineWorkspaceFileContract, auth: internalSessionAuth, + headSafe: false, operation: readWorkspaceInlineFile.operation, rateLimit: internalRateLimits.none({ reason: 'Internal workspace inline image delivery' }), errorPolicy: internalFileErrorPolicies.inline, @@ -47,25 +23,12 @@ export const GET = defineInternalBinaryRoute({ fileId: query.fileId, }), useCase: readWorkspaceInlineFile, - present: ({ file, stream, contentAddressed }) => { - const secure = getSecureFileHeaders(file.name, file.type) - const headers = new Headers({ - 'Content-Type': secure.contentType, - 'Content-Disposition': `${secure.disposition}; ${encodeFilenameForHeader(file.name)}`, - 'Cache-Control': contentAddressed ? IMMUTABLE_CACHE_CONTROL : REVALIDATE_CACHE_CONTROL, - 'X-Content-Type-Options': 'nosniff', - }) - if (secure.contentType === 'image/svg+xml') { - headers.set( - 'Content-Security-Policy', - "default-src 'none'; style-src 'unsafe-inline'; sandbox;" - ) - } - return { + present: ({ file, stream, contentAddressed }) => + presentFileDelivery({ body: stream, - contentType: secure.contentType, + filename: file.name, + contentType: file.type, contentLength: file.size, - headers, - } - }, + cacheControl: workspaceFileCacheControl(contentAddressed), + }), }) diff --git a/apps/sim/app/f/[token]/opengraph-image.tsx b/apps/sim/app/f/[token]/opengraph-image.tsx index 2d56c0e3b2f..72c6b33f6cb 100644 --- a/apps/sim/app/f/[token]/opengraph-image.tsx +++ b/apps/sim/app/f/[token]/opengraph-image.tsx @@ -1,5 +1,5 @@ import { COVER_OG_SIZE, createCoverOgImage } from '@/lib/og/cover-image' -import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager' +import { readPublicFileSocialMetadata } from '@/lib/public-shares/api' import { buildProvenance } from '@/app/f/[token]/utils' export const dynamic = 'force-dynamic' @@ -14,16 +14,16 @@ export const size = COVER_OG_SIZE */ export default async function Image({ params }: { params: Promise<{ token: string }> }) { const { token } = await params - const resolved = await resolveActiveShareByToken(token) + const resolved = await readPublicFileSocialMetadata(token) - if (!resolved || resolved.share.authType !== 'public') { + if (!resolved || resolved.protected) { return createCoverOgImage({ title: 'Protected file', subtitle: 'Authentication is required to view this file', }) } - const { file, workspaceName, ownerName } = resolved + const { file, workspaceName, ownerName } = resolved.metadata return createCoverOgImage({ title: file.originalName, diff --git a/apps/sim/app/f/[token]/page.tsx b/apps/sim/app/f/[token]/page.tsx index 2c59e81d057..e0172687298 100644 --- a/apps/sim/app/f/[token]/page.tsx +++ b/apps/sim/app/f/[token]/page.tsx @@ -1,14 +1,9 @@ -import { cache } from 'react' import type { Metadata } from 'next' import { cookies } from 'next/headers' import { notFound } from 'next/navigation' -import { getSession } from '@/lib/auth' -import { - deploymentAuthCookieName, - isEmailAllowed, - validateAuthToken, -} from '@/lib/core/security/deployment' -import { resolveActiveShareByToken } from '@/lib/public-shares/share-manager' +import { asOrchestrationError } from '@/lib/core/orchestration/types' +import { publicFileShareCredential, readPublicFileSocialMetadata } from '@/lib/public-shares/api' +import { authorizePublicFileShare, readPublicFileShare } from '@/lib/public-shares/application' import { getWorkspaceFileSize } from '@/lib/uploads/shared/types' import { PublicFileAuth } from '@/app/f/[token]/public-file-auth' import { PublicFileEmailAuth } from '@/app/f/[token]/public-file-email-auth' @@ -19,9 +14,6 @@ import { getBrandConfig } from '@/ee/whitelabeling' export const dynamic = 'force-dynamic' -/** Deduped per-request so `generateMetadata` and the page share one DB resolve. */ -const resolveShare = cache(resolveActiveShareByToken) - /** Shared links must never be indexed by search engines. */ const NOINDEX = { index: false, follow: false } as const @@ -36,20 +28,21 @@ interface PublicFilePageProps { */ export async function generateMetadata({ params }: PublicFilePageProps): Promise { const { token } = await params - const resolved = await resolveShare(token) + const resolved = await readPublicFileSocialMetadata(token) if (!resolved) { return { robots: NOINDEX } } let title: string let description: string - if (resolved.share.authType !== 'public') { + if (resolved.protected) { title = 'Shared file' description = 'Authentication is required to view this file.' } else { - title = resolved.file.originalName + title = resolved.metadata.file.originalName description = - buildProvenance(resolved.workspaceName, resolved.ownerName) || `Shared file · ${title}` + buildProvenance(resolved.metadata.workspaceName, resolved.metadata.ownerName) || + `Shared file · ${title}` } const brand = getBrandConfig() @@ -62,66 +55,35 @@ export async function generateMetadata({ params }: PublicFilePageProps): Promise } } -/** The auth-relevant slice of a resolved share row. */ -interface GateShare { - id: string - authType: string - password: string | null - allowedEmails: unknown -} - -/** - * Returns the auth prompt to render when a protected share is not yet authorized, - * or `null` when the visitor may view the file. `password`/`email` use the - * `file_auth_{shareId}` cookie; `sso` uses the global Sim session. - */ -async function renderAuthGate(token: string, share: GateShare) { - if (share.authType === 'public') return null - - if (share.authType === 'sso') { - const session = await getSession() - const allowedEmails = Array.isArray(share.allowedEmails) - ? (share.allowedEmails as string[]) - : [] - const authorized = Boolean( - session?.user?.email && isEmailAllowed(session.user.email, allowedEmails) - ) - return authorized ? null : - } - - const cookieStore = await cookies() - const cookieValue = cookieStore.get(deploymentAuthCookieName('file', share.id))?.value - if (await validateAuthToken({ token: cookieValue ?? '', resource: share })) return null - - return share.authType === 'email' ? ( - - ) : ( - - ) -} - export default async function PublicFilePage({ params }: PublicFilePageProps) { const { token } = await params - - const resolved = await resolveShare(token) - if (!resolved) { - notFound() + try { + const auth = await authorizePublicFileShare({ + token, + credential: await publicFileShareCredential((await cookies()).getAll()), + }) + if (!auth.authorized) { + if (auth.authType === 'sso') return + if (auth.authType === 'email') return + return + } + const { file, owner, workspaceName, ownerName } = await readPublicFileShare({ + grant: auth.grant, + }) + return ( + + ) + } catch (error) { + if (asOrchestrationError(error)?.code === 'not_found') notFound() + throw error } - - const { share, file, workspaceName, ownerName } = resolved - - const gate = await renderAuthGate(token, share) - if (gate) return gate - - return ( - - ) } diff --git a/apps/sim/app/f/[token]/public-file-view.tsx b/apps/sim/app/f/[token]/public-file-view.tsx index 4b57b43f56d..11d67c49e73 100644 --- a/apps/sim/app/f/[token]/public-file-view.tsx +++ b/apps/sim/app/f/[token]/public-file-view.tsx @@ -6,6 +6,7 @@ import { Download } from '@sim/emcn/icons' import Link from 'next/link' import { SITE_URL } from '@/lib/core/utils/urls' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' +import type { EditableFileOwner } from '@/lib/workspace-files/ownership' import { DesktopTitleBarLane } from '@/app/_shell/desktop-title-bar' import { buildProvenance } from '@/app/f/[token]/utils' import { FileViewer } from '@/app/workspace/[workspaceId]/files/components/file-viewer' @@ -14,6 +15,7 @@ import { createPublicFileContentSource } from '@/hooks/use-file-content-source' interface PublicFileViewProps { token: string + owner: EditableFileOwner name: string type: string size: number @@ -25,6 +27,7 @@ interface PublicFileViewProps { export function PublicFileView({ token, + owner, name, type, size, @@ -43,10 +46,10 @@ export function PublicFileView({ // storage key + `updatedAt`) refetch when the shared file changes — even when its // size is unchanged. // Embedded images route through the token-scoped cascade endpoint, which serves them only when the - // shared document actually references them and they live in its workspace. + // shared document actually references them and they have the same canonical owner. const source = useMemo( - () => createPublicFileContentSource(token, contentUrl), - [token, contentUrl] + () => createPublicFileContentSource(token, contentUrl, owner), + [token, contentUrl, owner] ) const file = useMemo( () => ({ diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.test.ts index 91e45a93748..5983a8cd741 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.test.ts @@ -7,11 +7,12 @@ import { FILE_DOC_TIMEOUTS, type FileDocUpdateAck, } from '@sim/realtime-protocol/file-doc' +import { flushMicrotasks } from '@sim/testing/helpers/async' import { update as updateJournalStorage } from 'idb-keyval' import * as decoding from 'lib0/decoding' import * as encoding from 'lib0/encoding' import type { Socket } from 'socket.io-client' -import { describe, expect, it, vi } from 'vitest' +import { beforeEach, describe, expect, it, vi } from 'vitest' import * as awarenessProtocol from 'y-protocols/awareness' import * as syncProtocol from 'y-protocols/sync' import * as Y from 'yjs' @@ -30,6 +31,13 @@ vi.mock('idb-keyval', () => ({ }), })) +const workspaceScope = { + owner: { entityType: 'workspace', entityId: 'workspace-1' }, + userId: 'provider-user', +} as const + +beforeEach(() => journalStorage.clear()) + const UPDATE_BATCH_TEST_WINDOW_MS = 100 /** A minimal fake Socket.IO client whose server→client events can be fired in tests. */ @@ -77,7 +85,7 @@ function createProvider(connected = true) { const { socket, emit, fire, timeout } = createSocket(connected) const doc = new Y.Doc() const awareness = new awarenessProtocol.Awareness(doc) - const provider = new FileDocProvider(socket, 'file-1', doc, awareness) + const provider = new FileDocProvider(socket, 'file-1', doc, awareness, workspaceScope) return { provider, doc, awareness, emit, fire, timeout } } @@ -113,6 +121,74 @@ function syncStep1Frame(doc: Y.Doc): Uint8Array { } describe('FileDocProvider', () => { + it('sends the workspace owner while accepting deployed ownerless join replies', async () => { + vi.useFakeTimers() + const { socket, emit, fire } = createSocket(true) + const doc = new Y.Doc() + const awareness = new awarenessProtocol.Awareness(doc) + const owner = { entityType: 'workspace', entityId: 'workspace-1' } as const + const provider = new FileDocProvider(socket, 'file-1', doc, awareness, { + owner, + userId: 'user-1', + }) + try { + const join = emit.mock.calls.find(([event]) => event === FILE_DOC_EVENTS.JOIN)?.[1] + expect(join).toMatchObject({ fileId: 'file-1', owner }) + acceptJoin(fire, doc.clientID, 'doc-1') + await vi.advanceTimersByTimeAsync(0) + expect(provider.joinError).toBeNull() + expect(emittedMessages(emit).length).toBeGreaterThan(0) + } finally { + provider.destroy() + awareness.destroy() + doc.destroy() + vi.useRealTimers() + } + }) + + it.each(['legacy', 'owner'] as const)( + 'accepts %s Project responses only for its owner', + async (wire) => { + vi.useFakeTimers() + const { socket, fire } = createSocket(true) + const doc = new Y.Doc() + const awareness = new awarenessProtocol.Awareness(doc) + const owner = { entityType: 'project', entityId: 'project-1' } as const + const provider = new FileDocProvider(socket, 'file-1', doc, awareness, { + owner, + userId: 'user-1', + }) + try { + expect(provider.canWrite).toBe(false) + const scope = wire === 'legacy' ? { projectId: 'project-1' } : { owner } + fire(FILE_DOC_EVENTS.JOIN_SUCCESS, { + fileId: 'file-1', + ...scope, + clientId: doc.clientID, + docId: 'doc-1', + canWrite: false, + acknowledgedUpdates: true, + }) + await vi.advanceTimersByTimeAsync(0) + fire(FILE_DOC_EVENTS.PERMISSION, { + fileId: 'file-1', + owner: { ...owner, entityId: 'other' }, + canWrite: true, + }) + expect(provider.canWrite).toBe(false) + fire(FILE_DOC_EVENTS.PERMISSION, { fileId: 'file-1', ...scope, canWrite: true }) + expect(provider.canWrite).toBe(true) + fire(FILE_DOC_EVENTS.PERMISSION, { fileId: 'file-1', ...scope, canWrite: false }) + expect(provider.canWrite).toBe(false) + } finally { + provider.destroy() + awareness.destroy() + doc.destroy() + vi.useRealTimers() + } + } + ) + it.each([undefined, 1, FILE_DOC_SCHEMA_VERSION + 1])( 'rejects incompatible server schema %s before exchanging document state', (schemaVersion) => { @@ -168,13 +244,15 @@ describe('FileDocProvider', () => { socket, 'file-1', firstDoc, - new awarenessProtocol.Awareness(firstDoc) + new awarenessProtocol.Awareness(firstDoc), + workspaceScope ) const second = new FileDocProvider( socket, 'file-1', secondDoc, - new awarenessProtocol.Awareness(secondDoc) + new awarenessProtocol.Awareness(secondDoc), + workspaceScope ) const serverDoc = new Y.Doc() const encoder = encoding.createEncoder() @@ -202,7 +280,7 @@ describe('FileDocProvider', () => { * the sync must not happen at all; the fatal path leaves the editor read-only on what it already * shows, and a reload binds a fresh document. */ - it('refuses to sync into a document it does not recognize', () => { + it('refuses to sync into a document it does not recognize', async () => { const { provider, doc, emit, fire } = createProvider(true) doc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.docIdKey, 'doc-original') const joinError = vi.fn() @@ -210,6 +288,7 @@ describe('FileDocProvider', () => { emit.mockClear() acceptJoin(fire, doc.clientID, 'doc-rebuilt') + await flushMicrotasks(20) expect(emittedMessages(emit)).toHaveLength(0) expect(provider.synced).toBe(false) @@ -217,12 +296,13 @@ describe('FileDocProvider', () => { expect(joinError).toHaveBeenCalledTimes(1) }) - it('fails closed when a seeded legacy tab has no identity but the server does', () => { + it('fails closed when a seeded legacy tab has no identity but the server does', async () => { const { provider, doc, emit, fire } = createProvider(true) doc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.flag, true) emit.mockClear() acceptJoin(fire, doc.clientID, 'doc-current') + await flushMicrotasks(20) expect(emittedMessages(emit)).toHaveLength(0) expect(provider.joinError).toMatchObject({ code: 'DOCUMENT_REPLACED', retryable: false }) @@ -237,7 +317,8 @@ describe('FileDocProvider', () => { socket, 'file-1', doc, - new awarenessProtocol.Awareness(doc) + new awarenessProtocol.Awareness(doc), + workspaceScope ) acceptJoin(fire, doc.clientID, 'doc-1') emit.mockClear() @@ -313,7 +394,10 @@ describe('FileDocProvider', () => { 'recovers an unacknowledged %s edit after restart and clears it only after acceptance', async (mode) => { journalStorage.clear() - const scope = { workspaceId: 'workspace-1', userId: 'user-1' } + const scope = { + owner: { entityType: 'workspace' as const, entityId: 'workspace-1' }, + userId: 'user-1', + } const serverDoc = new Y.Doc() const serverConfig = serverDoc.getMap(FILE_DOC_SEED.configMap) serverConfig.set(FILE_DOC_SEED.docIdKey, 'doc-1') @@ -427,7 +511,7 @@ describe('FileDocProvider', () => { 'file-1', doc, new awarenessProtocol.Awareness(doc), - { workspaceId: 'workspace-1', userId: 'user-1' } + { owner: { entityType: 'workspace' as const, entityId: 'workspace-1' }, userId: 'user-1' } ) acceptJoin(fire, doc.clientID, 'doc-1') await vi.advanceTimersByTimeAsync(0) @@ -518,6 +602,7 @@ describe('FileDocProvider', () => { emit.mockClear() acceptJoin(fire, doc.clientID, 'doc-1') + await flushMicrotasks(20) expect(emit.mock.calls.some(([event]) => event === FILE_DOC_EVENTS.UPDATE)).toBe(true) provider.destroy() } finally { @@ -552,7 +637,10 @@ describe('FileDocProvider', () => { journalStorage.clear() const browserWindow = new EventTarget() vi.stubGlobal('window', browserWindow) - const scope = { workspaceId: 'workspace-1', userId: 'user-1' } + const scope = { + owner: { entityType: 'workspace' as const, entityId: 'workspace-1' }, + userId: 'user-1', + } const journal = new PendingFileDocUpdateJournal({ ...scope, fileId: 'file-1' }) const clear = vi.spyOn(PendingFileDocUpdateJournal.prototype, 'clear') const { socket, emit, fire } = createSocket(true) @@ -628,7 +716,10 @@ describe('FileDocProvider', () => { journalStorage.clear() const browserWindow = new EventTarget() vi.stubGlobal('window', browserWindow) - const scope = { workspaceId: 'workspace-1', userId: 'user-1' } + const scope = { + owner: { entityType: 'workspace' as const, entityId: 'workspace-1' }, + userId: 'user-1', + } const journal = new PendingFileDocUpdateJournal({ ...scope, fileId: 'file-1' }) const recoveredDoc = new Y.Doc() recoveredDoc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.docIdKey, 'doc-1') @@ -659,7 +750,10 @@ describe('FileDocProvider', () => { it('journals an edit made while disconnected before page teardown', async () => { journalStorage.clear() - const scope = { workspaceId: 'workspace-1', userId: 'user-1' } + const scope = { + owner: { entityType: 'workspace' as const, entityId: 'workspace-1' }, + userId: 'user-1', + } const { socket, emit, fire } = createSocket(true) const doc = new Y.Doc() doc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.docIdKey, 'doc-1') @@ -686,7 +780,10 @@ describe('FileDocProvider', () => { it('preserves pending recovery through page teardown and destroy', async () => { journalStorage.clear() - const scope = { workspaceId: 'workspace-1', userId: 'user-1' } + const scope = { + owner: { entityType: 'workspace' as const, entityId: 'workspace-1' }, + userId: 'user-1', + } const { socket, fire } = createSocket(true) const doc = new Y.Doc() doc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.docIdKey, 'doc-1') @@ -723,7 +820,10 @@ describe('FileDocProvider', () => { journalStorage.clear() const browserWindow = new EventTarget() vi.stubGlobal('window', browserWindow) - const scope = { workspaceId: 'workspace-1', userId: 'user-1' } + const scope = { + owner: { entityType: 'workspace' as const, entityId: 'workspace-1' }, + userId: 'user-1', + } const journal = new PendingFileDocUpdateJournal({ ...scope, fileId: 'file-1' }) const { socket, emit } = createSocket(false) const doc = new Y.Doc() @@ -772,7 +872,10 @@ describe('FileDocProvider', () => { it('reopens the current generation without installing an incompatible recovery draft', async () => { journalStorage.clear() - const scope = { workspaceId: 'workspace-1', userId: 'user-1' } + const scope = { + owner: { entityType: 'workspace' as const, entityId: 'workspace-1' }, + userId: 'user-1', + } const oldDoc = new Y.Doc() oldDoc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.docIdKey, 'old-doc') oldDoc.getText('default').insert(0, 'complete draft') @@ -812,7 +915,10 @@ describe('FileDocProvider', () => { 'does not install disk recovery without a negotiated identity (local identity: %s)', async (hasLocalIdentity) => { journalStorage.clear() - const scope = { workspaceId: 'workspace-1', userId: 'user-1' } + const scope = { + owner: { entityType: 'workspace' as const, entityId: 'workspace-1' }, + userId: 'user-1', + } const draft = new Y.Doc() draft.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.docIdKey, 'old-doc') draft.getText('default').insert(0, 'retained draft') @@ -837,15 +943,16 @@ describe('FileDocProvider', () => { } ) - it('admits the final online batch before leaving while its relay publication is still pending', () => { + it('admits the final online batch before leaving while its relay publication is still pending', async () => { const { socket, emit, fire } = createSocket(true) const serverDoc = new Y.Doc() serverDoc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.docIdKey, 'doc-1') const doc = new Y.Doc() Y.applyUpdate(doc, Y.encodeStateAsUpdate(serverDoc)) const awareness = new awarenessProtocol.Awareness(doc) - const provider = new FileDocProvider(socket, 'file-1', doc, awareness) + const provider = new FileDocProvider(socket, 'file-1', doc, awareness, workspaceScope) acceptJoin(fire, doc.clientID, 'doc-1') + await flushMicrotasks(20) let joined = true const publications: Array<() => void> = [] emit.mockImplementation((event, payload, acknowledge) => { @@ -882,7 +989,10 @@ describe('FileDocProvider', () => { journalStorage.set(String(key), updater(journalStorage.get(String(key)))) }) const clear = vi.spyOn(PendingFileDocUpdateJournal.prototype, 'clear') - const scope = { workspaceId: 'workspace-1', userId: 'user-1' } + const scope = { + owner: { entityType: 'workspace' as const, entityId: 'workspace-1' }, + userId: 'user-1', + } const { socket, emit, fire } = createSocket(true) const serverDoc = new Y.Doc() serverDoc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.docIdKey, 'doc-1') @@ -947,7 +1057,10 @@ describe('FileDocProvider', () => { async (outcome) => { vi.useFakeTimers() journalStorage.clear() - const scope = { workspaceId: 'workspace-1', userId: 'user-1' } + const scope = { + owner: { entityType: 'workspace' as const, entityId: 'workspace-1' }, + userId: 'user-1', + } const journal = new PendingFileDocUpdateJournal({ ...scope, fileId: 'file-1' }) const { socket, emit, fire } = createSocket(true) const serverDoc = new Y.Doc() @@ -1006,7 +1119,10 @@ describe('FileDocProvider', () => { async (state) => { vi.useFakeTimers() journalStorage.clear() - const scope = { workspaceId: 'workspace-1', userId: 'user-1' } + const scope = { + owner: { entityType: 'workspace' as const, entityId: 'workspace-1' }, + userId: 'user-1', + } const { socket, emit, fire } = createSocket(true) const doc = new Y.Doc() doc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.docIdKey, 'doc-1') @@ -1042,7 +1158,10 @@ describe('FileDocProvider', () => { it('delivers a rejoined legacy batch before leaving without treating it as acknowledged', async () => { vi.useFakeTimers() journalStorage.clear() - const scope = { workspaceId: 'workspace-1', userId: 'user-1' } + const scope = { + owner: { entityType: 'workspace' as const, entityId: 'workspace-1' }, + userId: 'user-1', + } const { socket, emit, fire } = createSocket(true) const serverDoc = new Y.Doc() serverDoc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.docIdKey, 'doc-1') @@ -1084,7 +1203,10 @@ describe('FileDocProvider', () => { it('never falls back to a different document identity when loading local recovery', async () => { journalStorage.clear() - const scope = { workspaceId: 'workspace-1', userId: 'user-1' } + const scope = { + owner: { entityType: 'workspace' as const, entityId: 'workspace-1' }, + userId: 'user-1', + } const oldDoc = new Y.Doc() oldDoc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.docIdKey, 'doc-old') oldDoc.getText('default').insert(0, 'old draft') @@ -1121,7 +1243,10 @@ describe('FileDocProvider', () => { it('recovers the negotiated generation even when an incompatible draft is newer', async () => { vi.useFakeTimers() journalStorage.clear() - const scope = { workspaceId: 'workspace-1', userId: 'user-1' } + const scope = { + owner: { entityType: 'workspace' as const, entityId: 'workspace-1' }, + userId: 'user-1', + } const journal = new PendingFileDocUpdateJournal({ ...scope, fileId: 'file-1' }) const currentDraft = new Y.Doc() currentDraft.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.docIdKey, 'current-doc') @@ -1161,7 +1286,10 @@ describe('FileDocProvider', () => { it('rejects an in-memory generation mismatch before applying a matching server draft', async () => { journalStorage.clear() - const scope = { workspaceId: 'workspace-1', userId: 'user-1' } + const scope = { + owner: { entityType: 'workspace' as const, entityId: 'workspace-1' }, + userId: 'user-1', + } const journal = new PendingFileDocUpdateJournal({ ...scope, fileId: 'file-1' }) const serverDraft = new Y.Doc() serverDraft.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.docIdKey, 'server-doc') @@ -1186,7 +1314,10 @@ describe('FileDocProvider', () => { it('syncs after malformed recovery without replaying it on subsequent mounts', async () => { journalStorage.clear() - const scope = { workspaceId: 'workspace-1', userId: 'user-1' } + const scope = { + owner: { entityType: 'workspace' as const, entityId: 'workspace-1' }, + userId: 'user-1', + } const oldDoc = new Y.Doc() oldDoc.getText('default').insert(0, 'quarantined snapshot') await new PendingFileDocUpdateJournal({ ...scope, fileId: 'file-1' }).save( @@ -1236,7 +1367,7 @@ describe('FileDocProvider', () => { const doc = new Y.Doc() const awareness = new awarenessProtocol.Awareness(doc) const provider = new FileDocProvider(socket, 'file-1', doc, awareness, { - workspaceId: 'workspace-1', + owner: { entityType: 'workspace' as const, entityId: 'workspace-1' }, userId: 'user-1', }) try { @@ -1266,7 +1397,10 @@ describe('FileDocProvider', () => { const load = vi .spyOn(PendingFileDocUpdateJournal.prototype, 'load') .mockReturnValue(new Promise(() => {})) - const scope = { workspaceId: 'workspace-1', userId: 'user-1' } + const scope = { + owner: { entityType: 'workspace' as const, entityId: 'workspace-1' }, + userId: 'user-1', + } const { socket, fire } = createSocket(true) const doc = new Y.Doc() const provider = new FileDocProvider( @@ -1292,7 +1426,10 @@ describe('FileDocProvider', () => { const load = vi .spyOn(PendingFileDocUpdateJournal.prototype, 'load') .mockReturnValue(new Promise(() => {})) - const scope = { workspaceId: 'workspace-1', userId: 'user-1' } + const scope = { + owner: { entityType: 'workspace' as const, entityId: 'workspace-1' }, + userId: 'user-1', + } const { socket, fire } = createSocket(true) const doc = new Y.Doc() const provider = new FileDocProvider( @@ -1319,7 +1456,8 @@ describe('FileDocProvider', () => { socket, 'file-1', firstDoc, - new awarenessProtocol.Awareness(firstDoc) + new awarenessProtocol.Awareness(firstDoc), + workspaceScope ) acceptJoin(fire, firstDoc.clientID) @@ -1328,7 +1466,8 @@ describe('FileDocProvider', () => { socket, 'file-2', secondDoc, - new awarenessProtocol.Awareness(secondDoc) + new awarenessProtocol.Awareness(secondDoc), + workspaceScope ) expect(firstProvider.joinError).toMatchObject({ code: 'DOCUMENT_REPLACED' }) fire(FILE_DOC_EVENTS.JOIN_SUCCESS, { @@ -1374,7 +1513,7 @@ describe('FileDocProvider', () => { 'file-1', doc, new awarenessProtocol.Awareness(doc), - { workspaceId: 'workspace-1', userId: 'user-1' } + { owner: { entityType: 'workspace' as const, entityId: 'workspace-1' }, userId: 'user-1' } ) acceptJoin(fire, doc.clientID, 'doc-1', mode === 'acknowledged') await vi.advanceTimersByTimeAsync(0) @@ -1417,7 +1556,7 @@ describe('FileDocProvider', () => { doc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.docIdKey, 'doc-1') const awareness = new awarenessProtocol.Awareness(doc) const provider = new FileDocProvider(socket, 'file-1', doc, awareness, { - workspaceId: 'workspace-1', + owner: { entityType: 'workspace' as const, entityId: 'workspace-1' }, userId: 'user-1', }) acceptJoin(fire, doc.clientID, 'doc-1') @@ -1455,6 +1594,7 @@ describe('FileDocProvider', () => { try { const { provider, doc, emit, fire } = createProvider(true) acceptJoin(fire, doc.clientID, 'doc-1') + await flushMicrotasks(20) const serverDoc = new Y.Doc() const config = serverDoc.getMap(FILE_DOC_SEED.configMap) config.set(FILE_DOC_SEED.docIdKey, 'doc-1') @@ -1636,13 +1776,15 @@ describe('FileDocProvider', () => { socket, 'shared-file', docA, - new awarenessProtocol.Awareness(docA) + new awarenessProtocol.Awareness(docA), + workspaceScope ) const second = new FileDocProvider( socket, 'shared-file', docB, - new awarenessProtocol.Awareness(docB) + new awarenessProtocol.Awareness(docB), + workspaceScope ) fire(FILE_DOC_EVENTS.JOIN_SUCCESS, { schemaVersion: FILE_DOC_SCHEMA_VERSION, @@ -1663,7 +1805,10 @@ describe('FileDocProvider', () => { expect(emit).not.toHaveBeenCalledWith(FILE_DOC_EVENTS.LEAVE, expect.anything()) second.destroy() - expect(emit).toHaveBeenCalledWith(FILE_DOC_EVENTS.LEAVE, { fileId: 'shared-file' }) + expect(emit).toHaveBeenCalledWith(FILE_DOC_EVENTS.LEAVE, { + fileId: 'shared-file', + owner: workspaceScope.owner, + }) }) it('keeps an unseeded document retryable after the readiness deadline and accepts late server content', () => { diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts index 17c3e07ad13..5c152303184 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/file-doc-provider.ts @@ -10,13 +10,20 @@ import { FILE_DOC_SEED, FILE_DOC_TIMEOUTS, type FileDocInvalidated, + type FileDocPermission, type FileDocUpdateAck, type FileDocUpdatePayload, type JoinFileDocError, type JoinFileDocSuccess, toFileDocBytes, } from '@sim/realtime-protocol/file-doc' -import { ROOM_TYPES } from '@sim/realtime-protocol/rooms' +import { + type FileDocOwner, + fileDocOwnerWireFields, + fileDocRoom, + parseFileDocTarget, +} from '@sim/realtime-protocol/file-doc-target' +import { roomName } from '@sim/realtime-protocol/rooms' import { generateShortId } from '@sim/utils/id' import { backoffWithJitter } from '@sim/utils/retry' import * as decoding from 'lib0/decoding' @@ -36,6 +43,8 @@ import { PendingFileDocUpdateJournal } from '@/app/workspace/[workspaceId]/files */ interface FileDocProviderEvents { synced: (synced: boolean) => void + permission: (canWrite: boolean) => void + 'write-access-lost': () => void 'join-error': (error: JoinFileDocError) => void } @@ -60,8 +69,8 @@ function hasYjsUpdateContent(update: Uint8Array): boolean { } interface FileDocProviderScope { - workspaceId: string userId: string + owner: FileDocOwner } interface PendingClientUpdate { @@ -129,6 +138,10 @@ export class FileDocProvider extends ObservableV2 { >() synced = false + canWrite = true + private readonly owner: FileDocOwner + private readonly scopedPermissions: boolean + private readonly membershipKey: string /** * The current readiness failure, or `null`. Retryable timeouts clear once authoritative sync * completes; terminal rejections remain latched. Consumers read it when subscribing so an earlier @@ -177,11 +190,15 @@ export class FileDocProvider extends ObservableV2 { private readonly fileId: string, readonly doc: Y.Doc, readonly awareness: awarenessProtocol.Awareness, - scope?: FileDocProviderScope + scope: FileDocProviderScope ) { super() - this.journal = scope ? new PendingFileDocUpdateJournal({ ...scope, fileId: this.fileId }) : null + this.owner = scope.owner + this.scopedPermissions = this.owner.entityType !== 'workspace' + this.canWrite = !this.scopedPermissions + this.membershipKey = roomName(fileDocRoom({ fileId, owner: this.owner })) + this.journal = new PendingFileDocUpdateJournal({ ...scope, fileId: this.fileId }) this.registerActiveProvider() // Restore an empty local awareness state if it has been cleared. A fresh @@ -198,6 +215,7 @@ export class FileDocProvider extends ObservableV2 { socket.on(FILE_DOC_EVENTS.MESSAGE, this.handleMessage) socket.on(FILE_DOC_EVENTS.JOIN_SUCCESS, this.handleJoinSuccess) + socket.on(FILE_DOC_EVENTS.PERMISSION, this.handlePermission) socket.on(FILE_DOC_EVENTS.JOIN_ERROR, this.handleJoinError) socket.on(FILE_DOC_EVENTS.INVALIDATED, this.handleInvalidated) socket.on(ROOM_ACCESS_REVOKED_EVENT, this.handleAccessRevoked) @@ -211,7 +229,7 @@ export class FileDocProvider extends ObservableV2 { // Count this provider against the shared socket's membership of the file's room, so the room is // left only when the last provider for this file tears down (see {@link releaseRoomMembership}). - retainRoomMembership(socket, fileId) + retainRoomMembership(socket, this.membershipKey) if (socket.connected) this.join() @@ -244,6 +262,7 @@ export class FileDocProvider extends ObservableV2 { if (this.disposed || this.fatal || (this.synced && this.isSeeded())) return this.joinError = { fileId: this.fileId, + ...fileDocOwnerWireFields(this.owner), error: 'Realtime document was not ready in time', code: 'READINESS_TIMEOUT', retryable: true, @@ -309,6 +328,7 @@ export class FileDocProvider extends ObservableV2 { }, FILE_DOC_TIMEOUTS.joinAckMs) this.socket.emit(FILE_DOC_EVENTS.JOIN, { fileId: this.fileId, + ...fileDocOwnerWireFields(this.owner), clientId: this.doc.clientID, schemaVersion: FILE_DOC_SCHEMA_VERSION, }) @@ -386,10 +406,12 @@ export class FileDocProvider extends ObservableV2 { private handleJoinSuccess = (data: JoinFileDocSuccess) => { if ( data.fileId !== this.fileId || + !this.matchesTarget(data) || !this.joinPending || (data.clientId !== undefined && data.clientId !== this.doc.clientID) ) return + if (this.scopedPermissions) this.setCanWrite(data.canWrite === true) this.clearJoinAckTimer() this.joinPending = false this.joinRetryAttempt = 0 @@ -405,6 +427,12 @@ export class FileDocProvider extends ObservableV2 { return } const recoveryDocId = data.docId + if (this.scopedPermissions && !this.canWrite) { + void this.journal.discard(recoveryDocId).then(() => { + this.finishAcceptJoin(data, generation, null) + }) + return + } if (!this.recoveryLoad || this.recoveryLoad.docId !== recoveryDocId) { this.recoveryLoad = { docId: recoveryDocId, @@ -412,7 +440,7 @@ export class FileDocProvider extends ObservableV2 { } } void this.recoveryLoad.promise.then((recovered) => { - this.finishAcceptJoin(data, generation, recovered) + this.finishAcceptJoin(data, generation, this.canWrite ? recovered : null) }) } @@ -516,6 +544,7 @@ export class FileDocProvider extends ObservableV2 { if (this.fatal || this.disposed) return const error: JoinFileDocError = { fileId: this.fileId, + ...fileDocOwnerWireFields(this.owner), error: message, code, retryable: false, @@ -538,7 +567,7 @@ export class FileDocProvider extends ObservableV2 { private registerActiveProvider(): void { const active = FileDocProvider.activeProviders.get(this.socket) - if (active?.fileId === this.fileId) { + if (active?.fileId === this.membershipKey) { active.providers.add(this) return } @@ -552,14 +581,14 @@ export class FileDocProvider extends ObservableV2 { } } FileDocProvider.activeProviders.set(this.socket, { - fileId: this.fileId, + fileId: this.membershipKey, providers: new Set([this]), }) } private unregisterActiveProvider(): void { const active = FileDocProvider.activeProviders.get(this.socket) - if (active?.fileId !== this.fileId) return + if (active?.fileId !== this.membershipKey) return active.providers.delete(this) if (active.providers.size === 0) FileDocProvider.activeProviders.delete(this.socket) } @@ -605,7 +634,8 @@ export class FileDocProvider extends ObservableV2 { * instead of silently accepting keystrokes that go nowhere. */ private handleAccessRevoked = (data: RoomAccessRevokedBroadcast) => { - if (data.room?.type !== ROOM_TYPES.WORKSPACE_FILE_DOC || data.room.id !== this.fileId) return + const room = fileDocRoom({ fileId: this.fileId, owner: this.owner }) + if (data.room?.type !== room.type || data.room.id !== room.id) return this.failFatally(data.message, 'ACCESS_REVOKED') } @@ -693,7 +723,13 @@ export class FileDocProvider extends ObservableV2 { private handleDocUpdate = (update: Uint8Array, origin: unknown) => { /** A terminal document cannot publish; inbound and recovery updates must not echo. */ - if (this.fatal || origin === this || origin === RECOVERY_ORIGIN) return + if ( + this.fatal || + (this.scopedPermissions && !this.canWrite) || + origin === this || + origin === RECOVERY_ORIGIN + ) + return // Agent-streamed frames must reach peers (so a collaborator sees the stream live) but must NOT be // treated by the server as a durable user edit — the copilot's final `edit_content` write is the // authoritative persist. Tag them so the relay applies + fans out but skips persist bookkeeping. @@ -799,13 +835,15 @@ export class FileDocProvider extends ObservableV2 { this.disposed || this.fatal || !this.socket.connected || - !this.joinAccepted + !this.joinAccepted || + !this.canWrite ) return const generation = this.connectionGeneration const payload: FileDocUpdatePayload = { fileId: this.fileId, + ...fileDocOwnerWireFields(this.owner), docId, updateId: pending.updateId, update: pending.update, @@ -836,6 +874,10 @@ export class FileDocProvider extends ObservableV2 { return } + if (this.scopedPermissions && ack.code === 'ACCESS_REVOKED') { + this.setCanWrite(false) + return + } if (!ack.retryable) { const message = ack.code === 'ACCESS_REVOKED' @@ -919,7 +961,14 @@ export class FileDocProvider extends ObservableV2 { this.clearUpdateTimers() if (this.updateMode === 'acknowledged' && docId) { - const payload: FileDocUpdatePayload = { fileId: this.fileId, docId, updateId, update } + if (!this.canWrite) return + const payload: FileDocUpdatePayload = { + fileId: this.fileId, + ...fileDocOwnerWireFields(this.owner), + docId, + updateId, + update, + } this.socket .timeout(FILE_DOC_TIMEOUTS.updateAckMs) .emit(FILE_DOC_EVENTS.UPDATE, payload, (error: Error | null, ack?: FileDocUpdateAck) => { @@ -1025,6 +1074,45 @@ export class FileDocProvider extends ObservableV2 { this.socket.emit(FILE_DOC_EVENTS.MESSAGE, encoding.toUint8Array(encoder)) } + private matchesTarget(data: { + fileId: string + owner?: FileDocOwner + projectId?: string + }): boolean { + const target = parseFileDocTarget(data) + if (!target || target.fileId !== this.fileId) return false + if (!target.owner) return !this.scopedPermissions + return ( + target.owner.entityType === this.owner.entityType && + target.owner.entityId === this.owner.entityId + ) + } + + private handlePermission = (data: FileDocPermission) => { + if (!this.matchesTarget(data) || data.fileId !== this.fileId || !this.scopedPermissions) return + this.setCanWrite(data.canWrite) + if (data.canWrite) this.sendInFlightUpdate() + } + + private setCanWrite(canWrite: boolean) { + if (this.canWrite === canWrite) return + this.canWrite = canWrite + if (!canWrite) this.clearUpdateTimers() + if (this.scopedPermissions && !canWrite) { + this.pendingUpdatesDrained = true + this.pendingUpdateBatch = [] + this.inFlightUpdate = null + this.flushingUpdate = null + this.updateBeforeUnloadProtection() + const docId = this.docId() + const discarded = docId ? this.journal?.discard(docId) : undefined + void Promise.resolve(discarded).then(() => { + if (!this.disposed) this.emit('write-access-lost', []) + }) + } + this.emit('permission', [canWrite]) + } + private setSynced(synced: boolean) { if (this.synced === synced) return this.synced = synced @@ -1071,11 +1159,15 @@ export class FileDocProvider extends ObservableV2 { // Only actually leave the room when this was the last provider for the file on the shared socket — // otherwise a sibling surface (e.g. the Files editor vs. the embedded chat panel) would be stranded. - if (releaseRoomMembership(this.socket, this.fileId)) { - this.socket.emit(FILE_DOC_EVENTS.LEAVE, { fileId: this.fileId }) + if (releaseRoomMembership(this.socket, this.membershipKey)) { + this.socket.emit(FILE_DOC_EVENTS.LEAVE, { + fileId: this.fileId, + ...fileDocOwnerWireFields(this.owner), + }) } this.socket.off(FILE_DOC_EVENTS.MESSAGE, this.handleMessage) this.socket.off(FILE_DOC_EVENTS.JOIN_SUCCESS, this.handleJoinSuccess) + this.socket.off(FILE_DOC_EVENTS.PERMISSION, this.handlePermission) this.socket.off(FILE_DOC_EVENTS.JOIN_ERROR, this.handleJoinError) this.socket.off(FILE_DOC_EVENTS.INVALIDATED, this.handleInvalidated) this.socket.off(ROOM_ACCESS_REVOKED_EVENT, this.handleAccessRevoked) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/pending-update-journal.test.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/pending-update-journal.test.ts index ae430d15d8b..a62d140ad10 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/pending-update-journal.test.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/pending-update-journal.test.ts @@ -19,7 +19,7 @@ import { function journal(): PendingFileDocUpdateJournal { return new PendingFileDocUpdateJournal({ - workspaceId: 'workspace-1', + owner: { entityType: 'workspace' as const, entityId: 'workspace-1' }, fileId: 'file-1', userId: 'user-1', }) @@ -325,7 +325,7 @@ describe('PendingFileDocUpdateJournal', () => { it('isolates records by user, workspace, and file', async () => { const first = journal() const otherUser = new PendingFileDocUpdateJournal({ - workspaceId: 'workspace-1', + owner: { entityType: 'workspace' as const, entityId: 'workspace-1' }, fileId: 'file-1', userId: 'user-2', }) diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/pending-update-journal.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/pending-update-journal.ts index e4e91a2dd45..7954617effe 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/pending-update-journal.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/pending-update-journal.ts @@ -2,6 +2,7 @@ import { createLogger } from '@sim/logger' import { FILE_DOC_LIMITS } from '@sim/realtime-protocol/file-doc' +import type { FileDocOwner } from '@sim/realtime-protocol/file-doc-target' import { get, update as updateValue } from 'idb-keyval' import * as Y from 'yjs' @@ -28,9 +29,9 @@ interface JournalDocument extends PendingDocumentRecovery { } interface PendingUpdateJournalScope { - workspaceId: string fileId: string userId: string + owner: FileDocOwner } interface JournalSaveResult { @@ -105,7 +106,7 @@ export class PendingFileDocUpdateJournal { private readonly key: string private mutationQueue = Promise.resolve() - constructor({ workspaceId, fileId, userId }: PendingUpdateJournalScope) { + constructor({ owner, fileId, userId }: PendingUpdateJournalScope) { const origin = typeof location === 'undefined' ? 'server' : location.origin this.key = [ 'sim', @@ -113,7 +114,7 @@ export class PendingFileDocUpdateJournal { JOURNAL_VERSION, origin, userId, - workspaceId, + ...(owner.entityType === 'workspace' ? [owner.entityId] : [owner.entityType, owner.entityId]), fileId, ].join(':') } @@ -215,6 +216,17 @@ export class PendingFileDocUpdateJournal { ) } + /** Drop only this document's unaccepted recovery after confirmed loss of write access. */ + discard(docId: string): Promise { + return this.enqueue( + () => + updateValue(this.key, (value) => + record(liveDocuments(value, Date.now()).filter((document) => document.docId !== docId)) + ), + undefined + ) + } + clear(docId: string, acknowledgedUpdate: Uint8Array): Promise { return this.enqueue( () => diff --git a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/use-file-doc-collaboration.ts b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/use-file-doc-collaboration.ts index 2bad83b13da..5dfb35cf237 100644 --- a/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/use-file-doc-collaboration.ts +++ b/apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/collaboration/use-file-doc-collaboration.ts @@ -105,7 +105,7 @@ export function useFileDocCollaboration({ const doc = docRef.current as Y.Doc const awareness = awarenessRef.current as Awareness const fileProvider = new FileDocProvider(socket, fileId, doc, awareness, { - workspaceId, + owner: { entityType: 'workspace', entityId: workspaceId }, userId, }) setProvider(fileProvider) diff --git a/apps/sim/background/cleanup-file-versions.ts b/apps/sim/background/cleanup-file-versions.ts index 76f1c321b4c..593b3ac42d6 100644 --- a/apps/sim/background/cleanup-file-versions.ts +++ b/apps/sim/background/cleanup-file-versions.ts @@ -1,198 +1,27 @@ -import { dbFor } from '@sim/db' -import { workspaceFileVersion } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { chunkArray } from '@sim/utils/helpers' import { task } from '@trigger.dev/sdk' -import { and, count, gt, inArray, isNotNull, lt, min, or, sql } from 'drizzle-orm' import type { CleanupJobPayload } from '@/lib/billing/cleanup-dispatcher' -import { - DEFAULT_BATCH_SIZE, - DEFAULT_DELETE_CHUNK_SIZE, - DEFAULT_MAX_BATCHES_PER_TABLE, - DEFAULT_WORKSPACE_CHUNK_SIZE, -} from '@/lib/cleanup/batch-delete' +import { DEFAULT_BATCH_SIZE, DEFAULT_MAX_BATCHES_PER_TABLE } from '@/lib/cleanup/batch-delete' import { retentionCleanupQueue } from '@/lib/cleanup/queue' -import { cleanupProjectFileVersions } from '@/lib/projects/files/retention' -import { enqueueWorkspaceFileStorageCleanups } from '@/lib/uploads/contexts/workspace/workspace-file-storage-cleanup-outbox' -import { MAX_SUPERSEDED_FILE_VERSIONS } from '@/lib/uploads/contexts/workspace/workspace-file-versions' +import { cleanupFileVersions, fileRetentionOwners } from '@/lib/file-retention' const logger = createLogger('CleanupFileVersions') - -/** All cleanup queries run on the dedicated cleanup pool. */ -const cleanupDb = dbFor('cleanup') - -/** Candidate files whose histories are ranked in one query. */ -const FILES_PER_QUERY = 500 - -/** - * Bounds one run like the other cleanup jobs: {@link DEFAULT_MAX_BATCHES_PER_TABLE} batches per - * workspace chunk and this many versions overall. The next run resumes where this one stopped. - */ const MAX_VERSIONS_PER_RUN = DEFAULT_BATCH_SIZE * DEFAULT_MAX_BATCHES_PER_TABLE -/** - * Superseded versions a free file keeps (its newest 100 with the current one); versions beyond it - * are pruned whatever their age. Paid plans are bounded only by the inline write-time ceiling. - */ -const FREE_MAX_SUPERSEDED_VERSIONS = 99 - -/** - * Newest superseded versions retention never prunes, whatever their age, so a file always keeps its - * newest ten versions with the current one. - */ -const KEEP_SUPERSEDED = 9 - -/** - * Files in the group that can lose any version: more superseded versions than the keep-latest floor, - * and either one older than the cutoff or more than the plan allows. Ranking only these keeps the - * sort to the histories that need pruning instead of every superseded row in the group. - */ -async function selectCandidateFileIds( - workspaceIds: string[], - cutoff: Date, - maxSuperseded: number -): Promise { - const rows = await cleanupDb - .select({ fileId: workspaceFileVersion.fileId }) - .from(workspaceFileVersion) - .where( - and( - inArray(workspaceFileVersion.workspaceId, workspaceIds), - isNotNull(workspaceFileVersion.supersededAt) - ) - ) - .groupBy(workspaceFileVersion.fileId) - .having( - and( - gt(count(), KEEP_SUPERSEDED), - or( - lt( - min(workspaceFileVersion.supersededAt), - sql.param(cutoff, workspaceFileVersion.supersededAt) - ), - gt(count(), maxSuperseded) - ) - ) - ) - return rows.map((row) => row.fileId) -} - -/** - * Superseded versions of the given files past retention: older than the cutoff or beyond the plan's - * count, but never among the newest {@link KEEP_SUPERSEDED} superseded versions of a file. - */ -function selectExpiredVersions( - fileIds: string[], - cutoff: Date, - maxSuperseded: number, - batchSize: number -) { - const ranked = cleanupDb - .select({ - id: workspaceFileVersion.id, - supersededAt: workspaceFileVersion.supersededAt, - rank: sql`row_number() over (partition by ${workspaceFileVersion.fileId} order by ${workspaceFileVersion.version} desc)`.as( - 'rank' - ), - }) - .from(workspaceFileVersion) - .where( - and( - inArray(workspaceFileVersion.fileId, fileIds), - isNotNull(workspaceFileVersion.supersededAt) - ) - ) - .as('ranked') - - return cleanupDb - .select({ id: ranked.id }) - .from(ranked) - .where( - and( - gt(ranked.rank, KEEP_SUPERSEDED), - or(lt(ranked.supersededAt, cutoff), gt(ranked.rank, maxSuperseded)) - ) - ) - .limit(batchSize) -} - -/** - * Deletes the expired version rows and enqueues their stored objects on the storage-cleanup outbox - * in the same transaction, so a row never outlives its release and every released object is - * deleted durably — the outbox retries failures and treats an already-missing object as done. - */ -function deleteVersions(rows: Array<{ id: string }>): Promise { - return cleanupDb.transaction(async (tx) => { - const removed = await tx - .delete(workspaceFileVersion) - .where( - and( - inArray( - workspaceFileVersion.id, - rows.map((row) => row.id) - ), - isNotNull(workspaceFileVersion.supersededAt) - ) - ) - .returning({ key: workspaceFileVersion.key }) - await enqueueWorkspaceFileStorageCleanups( - tx, - removed.map((row) => row.key) - ) - return removed.length - }) -} - export async function runCleanupFileVersions(payload: CleanupJobPayload): Promise { const startTime = Date.now() - const { workspaceIds, retentionHours, label, plan } = payload - let projectDeleted = 0 - for (const projectId of [...new Set(payload.projectIds ?? [])].sort()) { - if (projectDeleted >= MAX_VERSIONS_PER_RUN) break - projectDeleted += await cleanupProjectFileVersions( - projectId, - MAX_VERSIONS_PER_RUN - projectDeleted - ) - } - if (workspaceIds.length === 0) { - logger.info(`[${label}] No workspaces to process`) - return - } - - const cutoff = new Date(Date.now() - retentionHours * 60 * 60 * 1000) - const maxSuperseded = - plan === 'free' ? FREE_MAX_SUPERSEDED_VERSIONS : MAX_SUPERSEDED_FILE_VERSIONS - logger.info( - `[${label}] Processing ${workspaceIds.length} workspaces, cutoff: ${cutoff.toISOString()}` + const owners = fileRetentionOwners(payload) + const deleted = await cleanupFileVersions( + owners, + { + plan: payload.plan, + cutoff: new Date(Date.now() - payload.retentionHours * 60 * 60 * 1000), + label: payload.label, + }, + MAX_VERSIONS_PER_RUN ) - - let deleted = projectDeleted - let attempted = projectDeleted - for (const group of chunkArray(workspaceIds, DEFAULT_WORKSPACE_CHUNK_SIZE)) { - if (attempted >= MAX_VERSIONS_PER_RUN) break - const candidates = await selectCandidateFileIds(group, cutoff, maxSuperseded) - let batches = 0 - for (const fileIds of chunkArray(candidates, FILES_PER_QUERY)) { - let exhausted = false - while ( - !exhausted && - batches < DEFAULT_MAX_BATCHES_PER_TABLE && - attempted < MAX_VERSIONS_PER_RUN - ) { - batches++ - const batchSize = Math.min(DEFAULT_DELETE_CHUNK_SIZE, MAX_VERSIONS_PER_RUN - attempted) - const expired = await selectExpiredVersions(fileIds, cutoff, maxSuperseded, batchSize) - attempted += expired.length - const removed = expired.length > 0 ? await deleteVersions(expired) : 0 - deleted += removed - exhausted = expired.length < batchSize || removed === 0 - } - if (!exhausted) break - } - } - const elapsed = ((Date.now() - startTime) / 1000).toFixed(2) - logger.info(`[${label}] File version cleanup: ${deleted} released in ${elapsed}s`) + logger.info(`[${payload.label}] File version cleanup: ${deleted} released in ${elapsed}s`) } export const cleanupFileVersionsTask = task({ diff --git a/apps/sim/background/cleanup-soft-deletes.test.ts b/apps/sim/background/cleanup-soft-deletes.test.ts index 08271563f8f..22169f396ea 100644 --- a/apps/sim/background/cleanup-soft-deletes.test.ts +++ b/apps/sim/background/cleanup-soft-deletes.test.ts @@ -1,5 +1,4 @@ import { - dbChainMock, dbChainMockFns, hasMockCondition, queueTableRows, @@ -18,10 +17,6 @@ import { uploadsMetadataMockFns, } from '@sim/testing/mocks/uploads-metadata.mock' import { workflowsUtilsMock, workflowsUtilsMockFns } from '@sim/testing/mocks/workflows-utils.mock' -import { - workspaceFileManagerMock, - workspaceFileManagerMockFns, -} from '@sim/testing/mocks/workspace-file-manager.mock' import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' const { @@ -77,19 +72,14 @@ vi.mock('@/lib/uploads', () => uploadsMock) vi.mock('@/lib/uploads/server/metadata', () => uploadsMetadataMock) -const { mockReleaseWorkspaceFileVersionsForPurgeInTx } = vi.hoisted(() => ({ - mockReleaseWorkspaceFileVersionsForPurgeInTx: vi.fn(), -})) -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-versions', () => ({ - releaseWorkspaceFileVersionsForPurgeInTx: mockReleaseWorkspaceFileVersionsForPurgeInTx, +vi.mock('@/lib/file-retention/folders', () => ({ + cleanupArchivedWorkspaceFileFolders: vi.fn(async () => 0), })) vi.mock('@/lib/workflows/utils', () => workflowsUtilsMock) vi.mock('@/lib/folders/naming', () => ({ deduplicateFolderName: mockDeduplicateFolderName })) -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => workspaceFileManagerMock) - import { runCleanupSoftDeletes } from '@/background/cleanup-soft-deletes' const { mockDecrementStorageUsageForBillingContextInTx, mockResolveStorageBillingContext } = @@ -102,10 +92,8 @@ const { mockDeleteFiles } = storageServiceMockFns const { mockDeleteFileMetadata } = uploadsMetadataMockFns const { mockIsUsingCloudStorage } = uploadsMockFns const { mockDeduplicateWorkflowName } = workflowsUtilsMockFns -const { mockAllocateUniqueWorkspaceFileName } = workspaceFileManagerMockFns mockDeleteFileMetadata.mockImplementation(async () => true) mockDeduplicateWorkflowName.mockImplementation(async (name: string) => name) -mockAllocateUniqueWorkspaceFileName.mockImplementation(async (_ws: string, name: string) => name) const basePayload = { label: 'free/1', @@ -134,64 +122,22 @@ describe('cleanup soft deletes', () => { }) }) - it('releases version history inside the transaction that purges the file rows', async () => { - mockSelectRowsByIdChunks - .mockResolvedValueOnce([]) - .mockResolvedValueOnce([]) - .mockResolvedValueOnce([ - { - id: 'file-purged', - key: 'workspace/ws-1/file-purged', - workspaceId: 'ws-1', - context: 'workspace', - sizeBytes: 5, - }, - { - id: 'file-failed', - key: 'workspace/ws-1/file-failed', - workspaceId: 'ws-1', - context: 'workspace', - sizeBytes: 4, - }, - ]) - mockDeleteFiles.mockResolvedValueOnce({ - deleted: 1, - failed: [{ key: 'workspace/ws-1/file-failed', error: 'storage unavailable' }], - }) - dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'file-purged', sizeBytes: 5 }]) - - await runCleanupSoftDeletes(basePayload) - - expect(mockReleaseWorkspaceFileVersionsForPurgeInTx).toHaveBeenCalledOnce() - expect(mockReleaseWorkspaceFileVersionsForPurgeInTx).toHaveBeenCalledWith( - dbChainMock.db, - ['file-purged'], - expect.any(Date) - ) - expect(dbChainMockFns.transaction.mock.invocationCallOrder[0]).toBeLessThan( - mockReleaseWorkspaceFileVersionsForPurgeInTx.mock.invocationCallOrder[0] - ) - expect(mockReleaseWorkspaceFileVersionsForPurgeInTx.mock.invocationCallOrder[0]).toBeLessThan( - dbChainMockFns.delete.mock.invocationCallOrder[0] - ) - }) - - it('keeps metadata rows whose object deletion failed', async () => { + it('keeps unbilled metadata rows whose object deletion failed', async () => { mockSelectRowsByIdChunks .mockResolvedValueOnce([]) .mockResolvedValueOnce([]) .mockResolvedValueOnce([ { id: 'file-failed', - key: 'workspace/ws-1/file-failed', + key: 'knowledge-base/file-failed', workspaceId: 'ws-1', - context: 'workspace', + context: 'knowledge-base', sizeBytes: 11, }, ]) mockDeleteFiles.mockResolvedValueOnce({ deleted: 0, - failed: [{ key: 'workspace/ws-1/file-failed', error: 'storage unavailable' }], + failed: [{ key: 'knowledge-base/file-failed', error: 'storage unavailable' }], }) await runCleanupSoftDeletes(basePayload) @@ -203,42 +149,6 @@ describe('cleanup soft deletes', () => { ).toBe(false) }) - it('decrements the current workspace payer only for rows conditionally deleted', async () => { - mockSelectRowsByIdChunks - .mockResolvedValueOnce([]) - .mockResolvedValueOnce([]) - .mockResolvedValueOnce([ - { - id: 'file-deleted', - key: 'workspace/ws-1/file-deleted', - workspaceId: 'ws-1', - context: 'workspace', - sizeBytes: 7, - }, - { - id: 'file-restored', - key: 'workspace/ws-1/file-restored', - workspaceId: 'ws-1', - context: 'workspace', - sizeBytes: 13, - }, - ]) - mockDeleteFiles.mockResolvedValueOnce({ deleted: 2, failed: [] }) - dbChainMockFns.returning.mockResolvedValueOnce([{ id: 'file-deleted', sizeBytes: 7 }]) - - await runCleanupSoftDeletes(basePayload) - - expect(mockResolveStorageBillingContext).toHaveBeenCalledOnce() - expect(mockDecrementStorageUsageForBillingContextInTx).toHaveBeenCalledWith( - dbChainMock.db, - expect.objectContaining({ workspaceId: 'ws-1' }), - 7 - ) - expect(mockDeleteFiles.mock.invocationCallOrder[0]).toBeLessThan( - dbChainMockFns.transaction.mock.invocationCallOrder[0] - ) - }) - it('hard-deletes mothership metadata without touching stored-byte counters', async () => { mockSelectRowsByIdChunks .mockResolvedValueOnce([]) @@ -420,12 +330,7 @@ describe('folder cleanup target', () => { expect(target?.additionalPredicate).toBeDefined() expect(target?.additionalPredicate?.type).toBe('inArray') expect(target?.additionalPredicate?.column).toBe(schemaMock.folder.resourceType) - expect(target?.additionalPredicate?.values).toEqual([ - 'workflow', - 'file', - 'knowledge_base', - 'table', - ]) + expect(target?.additionalPredicate?.values).toEqual(['workflow', 'knowledge_base', 'table']) }) /** @@ -462,24 +367,6 @@ describe('folder cleanup target', () => { expect(dbChainMockFns.set).toHaveBeenCalledWith({ folderId: null, name: 'Report (2)' }) }) - it('falls back to an id-suffixed name when the copy-suffix range is exhausted', async () => { - // Letting the allocator throw would abort the sweep — the exact stall this guards against. - const onBatch = await getFolderOnBatch() - queueTableRows(schemaMock.folder, [{ id: 'folder-1' }]) - queueTableRows(schemaMock.workflow, []) - queueTableRows(schemaMock.workspaceFiles, [ - { id: 'f1', originalName: 'report.pdf', workspaceId: 'ws-1' }, - ]) - mockAllocateUniqueWorkspaceFileName.mockRejectedValueOnce(new Error('conflict')) - - await expect(onBatch([{ id: 'folder-1' }])).resolves.toBeUndefined() - - expect(dbChainMockFns.set).toHaveBeenCalledWith({ - folderId: null, - originalName: 'report.pdf (f1)', - }) - }) - it('re-roots an active SUBFOLDER, which hits the same unique index', async () => { /** * `folder.parentId` is also ON DELETE SET NULL and @@ -515,32 +402,6 @@ describe('folder cleanup target', () => { expect(dbChainMockFns.set).toHaveBeenCalledWith({ parentId: null, name: 'Reports (1)' }) }) - it('recovers when the allocator RETURNS a colliding name and the update raises', async () => { - /** - * `allocateUniqueWorkspaceFileName` fails open — `fileExistsInWorkspace` swallows query - * errors and returns false — so it can hand back a name already taken at the root. Only - * the UPDATE discovers that, and an uncaught 23505 aborts the batch: the exact stall this - * hook prevents. Guarding the name lookup alone is not enough. - */ - const onBatch = await getFolderOnBatch() - queueTableRows(schemaMock.folder, [{ id: 'folder-1' }]) - queueTableRows(schemaMock.workflow, []) - queueTableRows(schemaMock.workspaceFiles, [ - { id: 'f1', originalName: 'report.pdf', workspaceId: 'ws-1' }, - ]) - mockAllocateUniqueWorkspaceFileName.mockResolvedValueOnce('taken.pdf') - dbChainMockFns.update.mockImplementationOnce(() => ({ - set: () => ({ where: () => Promise.reject(new Error('duplicate key value (23505)')) }), - })) - - await expect(onBatch([{ id: 'folder-1' }])).resolves.toBeUndefined() - - expect(dbChainMockFns.set).toHaveBeenCalledWith({ - folderId: null, - originalName: 'report.pdf (f1)', - }) - }) - it('leaves children alone when the folder was restored between select and onBatch', async () => { /** * The DELETE re-asserts eligibility and so correctly skips a restored folder. Without the @@ -561,7 +422,6 @@ describe('folder cleanup target', () => { await onBatch([{ id: 'folder-1' }]) expect(mockDeduplicateWorkflowName).not.toHaveBeenCalled() - expect(mockAllocateUniqueWorkspaceFileName).not.toHaveBeenCalled() expect(dbChainMockFns.update).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/background/cleanup-soft-deletes.ts b/apps/sim/background/cleanup-soft-deletes.ts index bb185880e56..49e82024b83 100644 --- a/apps/sim/background/cleanup-soft-deletes.ts +++ b/apps/sim/background/cleanup-soft-deletes.ts @@ -1,4 +1,4 @@ -import { db, dbFor } from '@sim/db' +import { dbFor } from '@sim/db' import { copilotChats, document, @@ -9,7 +9,6 @@ import { userTableDefinitions, workflow, workflowMcpServer, - workspaceFile, workspaceFiles, } from '@sim/db/schema' import { createLogger } from '@sim/logger' @@ -17,18 +16,12 @@ import { chunkArray } from '@sim/utils/helpers' import { task } from '@trigger.dev/sdk' import { and, asc, eq, inArray, isNotNull, isNull, lt, sql } from 'drizzle-orm' import { type CleanupJobPayload, runCleanupWithLimits } from '@/lib/billing/cleanup-dispatcher' -import { - decrementStorageUsageForBillingContextInTx, - lockWorkspaceStorageForMutationInTx, - resolveStorageBillingContext, -} from '@/lib/billing/storage' import { batchDeleteByWorkspaceAndTimestamp, chunkedBatchDelete, chunkedBatchDeleteByScope, consumeRowBudget, DEFAULT_DELETE_CHUNK_SIZE, - DEFAULT_MAX_BATCHES_PER_TABLE, type RowBudget, selectRowsByIdChunks, } from '@/lib/cleanup/batch-delete' @@ -40,30 +33,18 @@ import { cleanupOwnerCondition, resolveCleanupOwnerScope, } from '@/lib/cleanup/resource-scope' +import { beginFileArchiveCleanup, fileRetentionOwners } from '@/lib/file-retention' import { deduplicateFolderName } from '@/lib/folders/naming' import { requireWorkspaceFolder } from '@/lib/folders/scope' import { settleDetachedConnectorReservations } from '@/lib/knowledge/connectors/detachment' import { hardDeleteDocuments } from '@/lib/knowledge/documents/service' -import { - cleanupArchivedProjectFileFolders, - cleanupArchivedProjectFiles, -} from '@/lib/projects/files/retention' -import { lockWorkspaceProject } from '@/lib/projects/membership' -import type { StorageContext } from '@/lib/uploads' import { isUsingCloudStorage, StorageService } from '@/lib/uploads' -import { allocateUniqueWorkspaceFileName } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { releaseWorkspaceFileVersionsForPurgeInTx } from '@/lib/uploads/contexts/workspace/workspace-file-versions' import { deleteFileMetadata } from '@/lib/uploads/server/metadata' -import { getWorkspaceFileSize } from '@/lib/uploads/shared/types' import { deduplicateWorkflowName } from '@/lib/workflows/utils' const logger = createLogger('CleanupSoftDeletes') -/** - * Cleanup queries run on the dedicated cleanup pool. The one exception is the - * billable-file transaction below, which couples row deletion with a storage - * billing decrement — billing writes stay on the default client. - */ +/** Resource cleanup queries run on the dedicated cleanup pool. */ const cleanupDb = dbFor('cleanup') const KB_ORPHAN_BINDING_BATCH_SIZE = 500 @@ -79,303 +60,6 @@ const KB_RETENTION_BATCH_SIZE = 100 const KB_DOCUMENT_DELETE_BATCH_SIZE = 500 const KB_DOCUMENT_DELETE_MAX_BATCHES = 50 -interface WorkspaceFileScope { - /** Rows from `workspace_file` (singular, legacy workspace-context only). */ - legacyRows: Array<{ id: string; key: string; workspaceId: string }> - /** Rows from `workspace_files` (plural, multi-context). */ - multiContextRows: Array<{ - id: string - key: string - workspaceId: string | null - context: StorageContext - size: number - }> -} - -interface WorkspaceFileStorageCleanupResult { - filesDeleted: number - filesFailed: number - legacyRows: WorkspaceFileScope['legacyRows'] - multiContextRows: WorkspaceFileScope['multiContextRows'] -} - -/** - * Select every soft-deleted file row that's eligible for permanent removal. - * Returned once and reused for both S3 deletion and DB deletion so the external - * cleanup cannot drift from the row-level cleanup. - */ -async function selectExpiredWorkspaceFiles( - scope: CleanupOwnerScope, - retentionDate: Date, - budgets?: CleanupBudgets -): Promise { - const [legacyRows, multiContextRows] = await Promise.all([ - selectRowsByIdChunks( - scope.kind === 'workspace' ? scope.ids : [], - (chunkIds, chunkLimit) => - cleanupDb - .select({ - id: workspaceFile.id, - key: workspaceFile.key, - workspaceId: workspaceFile.workspaceId, - }) - .from(workspaceFile) - .where( - and( - inArray(workspaceFile.workspaceId, chunkIds), - isNotNull(workspaceFile.deletedAt), - lt(workspaceFile.deletedAt, retentionDate) - ) - ) - .limit(chunkLimit), - { budget: budgets?.legacyFiles } - ), - selectRowsByIdChunks( - scope.ids, - (chunkIds, chunkLimit) => - cleanupDb - .select({ - id: workspaceFiles.id, - key: workspaceFiles.key, - workspaceId: workspaceFiles.workspaceId, - context: workspaceFiles.context, - sizeBytes: workspaceFiles.sizeBytes, - }) - .from(workspaceFiles) - .where( - and( - cleanupOwnerCondition(workspaceFiles, scope, chunkIds), - scope.kind === 'organization' - ? eq(workspaceFiles.context, 'knowledge-base') - : undefined, - isNotNull(workspaceFiles.deletedAt), - lt(workspaceFiles.deletedAt, retentionDate) - ) - ) - .limit(chunkLimit), - { budget: budgets?.files } - ), - ]) - - return { - legacyRows, - multiContextRows: multiContextRows.map((r) => ({ - id: r.id, - key: r.key, - workspaceId: r.workspaceId, - context: r.context as StorageContext, - size: getWorkspaceFileSize(r), - })), - } -} - -async function cleanupWorkspaceFileStorage( - scope: WorkspaceFileScope -): Promise { - type Candidate = - | { source: 'legacy'; context: StorageContext; row: WorkspaceFileScope['legacyRows'][number] } - | { - source: 'multiContext' - context: StorageContext - row: WorkspaceFileScope['multiContextRows'][number] - } - - const result: WorkspaceFileStorageCleanupResult = { - filesDeleted: 0, - filesFailed: 0, - legacyRows: [], - multiContextRows: [], - } - if (!isUsingCloudStorage()) { - return { - ...result, - legacyRows: scope.legacyRows, - multiContextRows: scope.multiContextRows, - } - } - - const candidatesByContext = new Map() - const addCandidate = (candidate: Candidate) => { - const bucket = candidatesByContext.get(candidate.context) - if (bucket) bucket.push(candidate) - else candidatesByContext.set(candidate.context, [candidate]) - } - for (const row of scope.legacyRows) { - addCandidate({ source: 'legacy', context: 'workspace', row }) - } - for (const row of scope.multiContextRows) { - addCandidate({ source: 'multiContext', context: row.context, row }) - } - - for (const [context, candidates] of candidatesByContext) { - for (const batch of chunkArray(candidates, DEFAULT_DELETE_CHUNK_SIZE)) { - const deletion = await StorageService.deleteFiles( - batch.map(({ row }) => row.key), - context - ) - const failedKeys = new Set(deletion.failed.map(({ key }) => key)) - result.filesDeleted += batch.filter(({ row }) => !failedKeys.has(row.key)).length - result.filesFailed += deletion.failed.length - - for (const candidate of batch) { - if (failedKeys.has(candidate.row.key)) continue - if (candidate.source === 'legacy') result.legacyRows.push(candidate.row) - else result.multiContextRows.push(candidate.row) - } - for (const { key, error } of deletion.failed) { - logger.error(`Failed to delete storage file ${key} (context: ${context}):`, { error }) - } - } - } - - return result -} - -async function deleteExpiredLegacyWorkspaceFileRows( - rows: WorkspaceFileScope['legacyRows'], - retentionDate: Date, - label: string -): Promise<{ deleted: number; failed: number }> { - const result = { deleted: 0, failed: 0 } - for (const batch of chunkArray(rows, DEFAULT_DELETE_CHUNK_SIZE)) { - try { - const deleted = await cleanupDb - .delete(workspaceFile) - .where( - and( - inArray( - workspaceFile.id, - batch.map(({ id }) => id) - ), - isNotNull(workspaceFile.deletedAt), - lt(workspaceFile.deletedAt, retentionDate) - ) - ) - .returning({ id: workspaceFile.id }) - result.deleted += deleted.length - result.failed += batch.length - deleted.length - } catch (error) { - result.failed += batch.length - logger.error(`[${label}/workspaceFile] Exact-row delete failed`, { error }) - } - } - return result -} - -async function deleteExpiredUnbilledWorkspaceFileRows( - rows: WorkspaceFileScope['multiContextRows'], - retentionDate: Date, - label: string -): Promise<{ deleted: number; failed: number }> { - const result = { deleted: 0, failed: 0 } - const rowsByContext = new Map() - for (const row of rows) { - if (row.context === 'workspace') continue - const bucket = rowsByContext.get(row.context) - if (bucket) bucket.push(row) - else rowsByContext.set(row.context, [row]) - } - - for (const [context, contextRows] of rowsByContext) { - for (const batch of chunkArray(contextRows, DEFAULT_DELETE_CHUNK_SIZE)) { - try { - const deleted = await cleanupDb - .delete(workspaceFiles) - .where( - and( - inArray( - workspaceFiles.id, - batch.map(({ id }) => id) - ), - eq(workspaceFiles.context, context), - isNotNull(workspaceFiles.deletedAt), - lt(workspaceFiles.deletedAt, retentionDate) - ) - ) - .returning({ id: workspaceFiles.id }) - result.deleted += deleted.length - result.failed += batch.length - deleted.length - } catch (error) { - result.failed += batch.length - logger.error(`[${label}/workspaceFiles] Exact-row ${context} delete failed`, { error }) - } - } - } - return result -} - -async function deleteExpiredBillableWorkspaceFileRows( - rows: WorkspaceFileScope['multiContextRows'], - retentionDate: Date, - label: string -): Promise<{ deleted: number; failed: number }> { - const result = { deleted: 0, failed: 0 } - const rowsByWorkspace = new Map() - for (const row of rows) { - if (row.context !== 'workspace') continue - if (!row.workspaceId) { - result.failed++ - logger.error(`[${label}/workspaceFiles] Billable row has no workspace attribution`, { - fileId: row.id, - }) - continue - } - const bucket = rowsByWorkspace.get(row.workspaceId) - if (bucket) bucket.push(row) - else rowsByWorkspace.set(row.workspaceId, [row]) - } - - for (const [workspaceId, workspaceRows] of rowsByWorkspace) { - for (const batch of chunkArray(workspaceRows, DEFAULT_DELETE_CHUNK_SIZE)) { - try { - const deletedCount = await db.transaction(async (tx) => { - await lockWorkspaceProject(tx, workspaceId) - await lockWorkspaceStorageForMutationInTx(tx, workspaceId) - const billingContext = await resolveStorageBillingContext(workspaceId, tx) - await releaseWorkspaceFileVersionsForPurgeInTx( - tx, - batch.map(({ id }) => id), - retentionDate - ) - const deletedRows = await tx - .delete(workspaceFiles) - .where( - and( - inArray( - workspaceFiles.id, - batch.map(({ id }) => id) - ), - eq(workspaceFiles.workspaceId, workspaceId), - eq(workspaceFiles.context, 'workspace'), - isNotNull(workspaceFiles.deletedAt), - lt(workspaceFiles.deletedAt, retentionDate) - ) - ) - .returning({ - id: workspaceFiles.id, - sizeBytes: workspaceFiles.sizeBytes, - }) - const deletedBytes = deletedRows.reduce( - (total, row) => total + getWorkspaceFileSize(row), - 0 - ) - await decrementStorageUsageForBillingContextInTx(tx, billingContext, deletedBytes) - return deletedRows.length - }) - result.deleted += deletedCount - result.failed += batch.length - deletedCount - } catch (error) { - result.failed += batch.length - logger.error(`[${label}/workspaceFiles] Atomic delete and decrement failed`, { - error, - workspaceId, - }) - } - } - } - return result -} - async function hardDeleteKnowledgeBaseDocuments( knowledgeBaseIds: string[], label: string @@ -473,20 +157,7 @@ interface CleanupBatchContext { label: string } -/** - * Applies one re-root, treating the deduplicated name as a HINT rather than a guarantee. - * - * Both name allocators can hand back a name that is already taken: `fileExistsInWorkspace` - * swallows query errors and returns `false`, so `allocateUniqueWorkspaceFileName` fails OPEN, - * and `deduplicateWorkflowName`'s lookups can throw outright. Either way the UPDATE raises - * 23505, and an uncaught 23505 here aborts the batch — precisely the permanent retention stall - * this whole hook exists to prevent. So any failure retries with the row id, which is unique by - * construction and cannot collide. - * - * A failure of that retry is swallowed too: one unfixable row must not stop the other children - * from being made safe. It is logged at error level because the folder's DELETE can then still - * stall on that row via the FK's SET NULL. - */ +/** Retry a colliding name without letting one child permanently stall unrelated folder cleanup. */ async function reRootOne( preferred: () => Promise, withUniqueName: () => Promise, @@ -509,20 +180,7 @@ async function reRootOne( } } -/** - * Re-roots any still-active workflow or workspace file filed under a folder that is about to be - * hard-deleted, giving it a collision-free name first. - * - * The `folder_id` FKs are `ON DELETE SET NULL`, so Postgres already re-roots these rows on its - * own. The problem is the name: both tables carry a partial unique index keyed on - * `coalesce(folder_id, '')`, so an implicit SET NULL can land a row on a name the workspace root - * already holds and abort the whole DELETE with a 23505. `chunkedBatchDelete` counts that as a - * failed batch and stops, and the same poison row re-fails on every later run — folder retention - * would stall permanently for that workspace chunk. Renaming here leaves the SET NULL a no-op. - * - * An active child inside a soft-deleted folder is already an anomaly — the delete cascade - * archives children — so this normally selects nothing, which is why the per-row loop is fine. - */ +/** Surviving workflow children need collision-free root names before their folder FK clears. */ async function reRootActiveFolderChildren( folderIds: string[], retentionDate: Date, @@ -598,52 +256,11 @@ async function reRootActiveFolderChildrenUnguarded( ) } - const files = await cleanupDb - .select({ - id: workspaceFiles.id, - originalName: workspaceFiles.originalName, - workspaceId: workspaceFiles.workspaceId, - }) - .from(workspaceFiles) - .where( - and( - inArray(workspaceFiles.folderId, expiredIds), - isNull(workspaceFiles.deletedAt), - eq(workspaceFiles.context, 'workspace') - ) - ) - - for (const row of files) { - const workspaceId = row.workspaceId - if (!workspaceId) continue - await reRootOne( - async () => { - const originalName = await allocateUniqueWorkspaceFileName( - workspaceId, - row.originalName, - null - ) - await cleanupDb - .update(workspaceFiles) - .set({ folderId: null, originalName }) - .where(eq(workspaceFiles.id, row.id)) - }, - () => - cleanupDb - .update(workspaceFiles) - .set({ folderId: null, originalName: `${row.originalName} (${row.id})` }) - .where(eq(workspaceFiles.id, row.id)), - `workspace file ${row.id}`, - label - ) - } - /** * Subfolders are exposed to exactly the same failure. `folder.parentId` is itself * `ON DELETE SET NULL`, and `folder_workspace_resource_parent_name_active_unique` keys on * `coalesce(parent_id, '')`, so purging a parent re-roots a surviving active child into a - * namespace where its name may already be taken — the identical 23505 stall. Covering only - * workflows and files would leave the class half-closed. + * namespace where its name may already be taken — the identical 23505 stall. Subfolder names therefore need the same collision handling. */ const childFolders = await cleanupDb .select({ @@ -682,10 +299,8 @@ async function reRootActiveFolderChildrenUnguarded( ) } - if (workflows.length > 0 || files.length > 0) { - logger.warn( - `[${label}] Re-rooted ${workflows.length} workflow(s) and ${files.length} file(s) out of folders being purged` - ) + if (workflows.length > 0) { + logger.warn(`[${label}] Re-rooted ${workflows.length} workflow(s) out of folders being purged`) } } @@ -694,19 +309,7 @@ const CLEANUP_TARGETS = [ table: folderTable, softDeleteCol: folderTable.deletedAt, wsCol: folderTable.workspaceId, - /** - * `folder` is shared by all four resource types, every one of which now writes here. - * The predicate is kept (rather than dropped) so a resource type added to the enum - * before its cutover lands is not silently hard-deleted by this pass. One widened - * predicate rather than a target per type: same table, same soft-delete column, same - * workspace scoping — splitting it would only multiply the batched scans. - */ - additionalPredicate: inArray(folderTable.resourceType, [ - 'workflow', - 'file', - 'knowledge_base', - 'table', - ]), + additionalPredicate: inArray(folderTable.resourceType, ['workflow', 'knowledge_base', 'table']), onBatch: (rows: { id: string }[], ctx: CleanupBatchContext) => reRootActiveFolderChildren( rows.map(({ id }) => id), @@ -840,19 +443,8 @@ export async function runCleanupSoftDeletes( const startTime = Date.now() const { workspaceIds, retentionHours, label } = payload const scope = resolveCleanupOwnerScope(payload) - const projectFileBudget = budgets?.files ?? { - remaining: DEFAULT_DELETE_CHUNK_SIZE * DEFAULT_MAX_BATCHES_PER_TABLE, - } - const projectFolderBudget = budgets?.folders ?? { - remaining: DEFAULT_DELETE_CHUNK_SIZE * DEFAULT_MAX_BATCHES_PER_TABLE, - } - for (const projectId of [...new Set(payload.projectIds ?? [])].sort()) { - if (projectFileBudget.remaining <= 0 && projectFolderBudget.remaining <= 0) break - await cleanupArchivedProjectFiles(projectId, projectFileBudget) - await cleanupArchivedProjectFileFolders(projectId, projectFolderBudget) - } - - if (scope.ids.length === 0) { + const fileOwners = fileRetentionOwners(payload) + if (scope.ids.length === 0 && fileOwners.length === 0) { logger.info(`[${label}] No resource owners to process`) return } @@ -866,7 +458,7 @@ export async function runCleanupSoftDeletes( // external cleanup (chats + S3) AND the DB deletes below — selecting twice // could return different subsets above the LIMIT cap and orphan or // prematurely purge data. - const [doomedWorkflows, fileScope, expiredSoftDeletedChats] = await Promise.all([ + const [doomedWorkflows, fileArchiveCleanup, expiredSoftDeletedChats] = await Promise.all([ selectRowsByIdChunks( workspaceIds, (chunkIds, chunkLimit) => @@ -883,7 +475,12 @@ export async function runCleanupSoftDeletes( .limit(chunkLimit), { budget: budgets?.workflows } ), - selectExpiredWorkspaceFiles(scope, retentionDate, budgets), + beginFileArchiveCleanup(fileOwners, { + cutoff: retentionDate, + plan: payload.plan, + label, + budgets, + }), selectRowsByIdChunks( scope.ids, (chunkIds, chunkLimit) => @@ -923,8 +520,7 @@ export async function runCleanupSoftDeletes( chatCleanup = await prepareChatCleanup([...doomedChatIds], label) } - const fileCleanup = await cleanupWorkspaceFileStorage(fileScope) - if (budgets && fileCleanup.filesFailed) throw new Error('File storage cleanup failed') + const fileCleanup = await fileArchiveCleanup.cleanupStorage() let totalDeleted = 0 @@ -977,31 +573,7 @@ export async function runCleanupSoftDeletes( } } - const legacyFileResult = await deleteExpiredLegacyWorkspaceFileRows( - fileCleanup.legacyRows, - retentionDate, - label - ) - totalDeleted += legacyFileResult.deleted - - const billableFileResult = await deleteExpiredBillableWorkspaceFileRows( - fileCleanup.multiContextRows, - retentionDate, - label - ) - totalDeleted += billableFileResult.deleted - - const unbilledFileResult = await deleteExpiredUnbilledWorkspaceFileRows( - fileCleanup.multiContextRows, - retentionDate, - label - ) - totalDeleted += unbilledFileResult.deleted - if ( - budgets && - (legacyFileResult.failed || billableFileResult.failed || unbilledFileResult.failed) - ) - throw new Error('File row cleanup failed') + totalDeleted += await fileCleanup.deleteRows() const knowledgeBaseResult = await cleanupExpiredKnowledgeBases( scope, diff --git a/apps/sim/executor/utils/resolved-secret-trace-registry.ts b/apps/sim/executor/utils/resolved-secret-trace-registry.ts index e9158202c07..e4463fa44d7 100644 --- a/apps/sim/executor/utils/resolved-secret-trace-registry.ts +++ b/apps/sim/executor/utils/resolved-secret-trace-registry.ts @@ -77,6 +77,7 @@ export type ResolvedSecretIncompletenessReason = | 'table-run-state-provenance-unavailable' | 'mounted-file-provenance-unavailable' | 'workspace-file-provenance-unknown' + | 'project-file-provenance-unavailable' | 'file-source-unidentified' | 'table-snapshot-unsafe-for-mount' | 'restored-provenance-untrusted' diff --git a/apps/sim/hooks/queries/public-shares.ts b/apps/sim/hooks/queries/public-shares.ts index e2552b12531..5f33d20d163 100644 --- a/apps/sim/hooks/queries/public-shares.ts +++ b/apps/sim/hooks/queries/public-shares.ts @@ -19,7 +19,7 @@ export const FILE_SHARE_STALE_TIME = 30 * 1000 /** * Query key factories for public shares */ -export const shareKeys = { +const shareKeys = { all: ['publicShares'] as const, details: () => [...shareKeys.all, 'detail'] as const, detail: (workspaceId: string, fileId: string) => diff --git a/apps/sim/hooks/queries/workspace-file-table.ts b/apps/sim/hooks/queries/workspace-file-table.ts index c9f6573b8a9..98eeef30078 100644 --- a/apps/sim/hooks/queries/workspace-file-table.ts +++ b/apps/sim/hooks/queries/workspace-file-table.ts @@ -11,7 +11,7 @@ import { */ export const WORKSPACE_CSV_PREVIEW_STALE_TIME = 30 * 1000 -export const workspaceFileTableKeys = { +const workspaceFileTableKeys = { all: ['workspaceFileTable'] as const, previews: () => [...workspaceFileTableKeys.all, 'preview'] as const, preview: (workspaceId: string, fileId: string, key: string, version?: number) => diff --git a/apps/sim/hooks/queries/workspace-files.ts b/apps/sim/hooks/queries/workspace-files.ts index 61eeb5c1f04..028b6e5d696 100644 --- a/apps/sim/hooks/queries/workspace-files.ts +++ b/apps/sim/hooks/queries/workspace-files.ts @@ -66,7 +66,7 @@ export const workspaceFilesKeys = { cloudConfigured: () => [...workspaceFilesKeys.all, 'cloudConfigured'] as const, } -export const WORKSPACE_FILES_LIST_STALE_TIME = 30 * 1000 +const WORKSPACE_FILES_LIST_STALE_TIME = 30 * 1000 export const WORKSPACE_FILE_CONTENT_STALE_TIME = 30 * 1000 export const WORKSPACE_FILE_BINARY_STALE_TIME = 30 * 1000 /** Cloud storage (S3/Blob) is env-driven and does not change at runtime. */ diff --git a/apps/sim/hooks/use-file-content-source.tsx b/apps/sim/hooks/use-file-content-source.tsx index 4c342369166..7180bfce7d6 100644 --- a/apps/sim/hooks/use-file-content-source.tsx +++ b/apps/sim/hooks/use-file-content-source.tsx @@ -3,9 +3,10 @@ import { createContext, useContext } from 'react' import { type EmbeddedFileRef, - extractEmbeddedFileRef, + resolveEmbeddedFileRef, storedFileId, } from '@/lib/uploads/utils/embedded-image-ref' +import type { EditableFileOwner } from '@/lib/workspace-files/ownership' export interface FileContentUrlOptions { /** Request the uncompiled source instead of the rendered/compiled bytes. */ @@ -56,6 +57,7 @@ export interface ImageDimensionsSource { * this source so the same components work in both contexts. */ export interface FileContentSource { + owner?: EditableFileOwner buildUrl: (key: string, opts?: FileContentUrlOptions) => string /** * Map an embedded image `src` to a display URL scoped to the current context: the in-app source @@ -85,14 +87,20 @@ function buildServeUrl( /** Build a source whose embeds resolve through `inlineBase` (the workspace- or token-scoped inline route). */ function inlineImageSource( buildUrl: FileContentSource['buildUrl'], - inlineBase: string + inlineBase: string, + owner?: EditableFileOwner ): FileContentSource { return { buildUrl, resolveImageSrc: (src) => { if (!src) return src - const ref = extractEmbeddedFileRef(src) - return ref ? `${inlineBase}?${inlineRefQuery(ref)}` : src + const resolved = resolveEmbeddedFileRef( + src, + owner, + typeof window === 'undefined' ? undefined : window.location.origin + ) + if (resolved.kind === 'rejected') return undefined + return resolved.kind === 'file' ? `${inlineBase}?${inlineRefQuery(resolved.reference)}` : src }, } } @@ -108,9 +116,11 @@ export function createWorkspaceFileContentSource( storageContext: 'workspace' | 'mothership' = 'workspace' ): FileContentSource { return { + owner: { entityType: 'workspace', entityId: workspaceId }, ...inlineImageSource( (key, opts) => buildServeUrl(key, opts, storageContext), - `/api/workspaces/${workspaceId}/files/inline` + `/api/workspaces/${workspaceId}/files/inline`, + { entityType: 'workspace', entityId: workspaceId } ), ...imageDimensions, } @@ -119,16 +129,18 @@ export function createWorkspaceFileContentSource( /** * Public share source. Direct file bytes come from the token content URL; embedded images route through * `/api/files/public/{token}/inline`, which serves them only when referenced by the shared document and - * in its workspace. + * in its canonical owner. */ export function createPublicFileContentSource( token: string, - contentUrl: string + contentUrl: string, + owner?: EditableFileOwner ): FileContentSource { return inlineImageSource( (_key, opts) => opts?.preview ? `${contentUrl}${contentUrl.includes('?') ? '&' : '?'}preview=1` : contentUrl, - `/api/files/public/${token}/inline` + `/api/files/public/${token}/inline`, + owner ) } diff --git a/apps/sim/lib/api/contracts/file-browser.ts b/apps/sim/lib/api/contracts/file-browser.ts new file mode 100644 index 00000000000..4a037fe5a73 --- /dev/null +++ b/apps/sim/lib/api/contracts/file-browser.ts @@ -0,0 +1,33 @@ +import { z } from 'zod' +import { nonEmptyIdSchema } from '@/lib/api/contracts/primitives' +import { FILE_BROWSER_SIZES, FILE_BROWSER_TYPES } from '@/lib/workspace-files/browser' + +export const fileBrowserCreatorSchema = z.object({ + id: nonEmptyIdSchema, + name: z.string(), + image: z.string().nullable(), + deleted: z.boolean(), +}) +export const fileBrowserItemSchema = z.object({ + id: nonEmptyIdSchema, + kind: z.enum(['file', 'folder']), + name: z.string(), + parentId: nonEmptyIdSchema.nullable(), + size: z.number().nonnegative(), + type: z.string(), + createdAt: z.coerce.date(), + updatedAt: z.coerce.date(), + creator: fileBrowserCreatorSchema.nullable(), +}) +export const fileBrowserTypesQuerySchema = z + .union([z.enum(FILE_BROWSER_TYPES), z.array(z.enum(FILE_BROWSER_TYPES)).max(4)]) + .transform((value) => (typeof value === 'string' ? [value] : value)) + .default([]) +export const fileBrowserSizesQuerySchema = z + .union([z.enum(FILE_BROWSER_SIZES), z.array(z.enum(FILE_BROWSER_SIZES)).max(3)]) + .transform((value) => (typeof value === 'string' ? [value] : value)) + .default([]) +export const fileBrowserCreatorsQuerySchema = z + .union([nonEmptyIdSchema, z.array(nonEmptyIdSchema).max(100)]) + .transform((value) => (typeof value === 'string' ? [value] : value)) + .default([]) diff --git a/apps/sim/lib/api/contracts/file-copy-input.ts b/apps/sim/lib/api/contracts/file-copy-input.ts new file mode 100644 index 00000000000..b032fafac43 --- /dev/null +++ b/apps/sim/lib/api/contracts/file-copy-input.ts @@ -0,0 +1,41 @@ +import { z } from 'zod' +import { MAX_WORKSPACE_FILE_BULK_REQUEST_IDS } from '@/lib/workspace-files/limits' + +const copyIdSchema = z.string().trim().min(1).max(200) +const copyOwnerSchema = z.strictObject({ + entityType: z.enum(['workspace', 'project']).describe('Resource scope that owns these files.'), + entityId: z.string().min(1).max(200).describe('Identifier of the owning workspace or Project.'), +}) +const copyIdsSchema = z + .array(copyIdSchema) + .max(MAX_WORKSPACE_FILE_BULK_REQUEST_IDS, 'Too many selected file items') + .refine((ids) => new Set(ids).size === ids.length, 'Selected identifiers must be unique') + .default([]) + +export const fileCopySourceSchema = z + .strictObject({ + owner: copyOwnerSchema.describe('Owner from which to read the selected files and folders.'), + fileIds: copyIdsSchema.describe('Identifiers of individual files to copy.'), + folderIds: copyIdsSchema.describe( + 'Identifiers of folders to copy with their active descendants and files.' + ), + }) + .refine((source) => source.fileIds.length + source.folderIds.length > 0, { + message: 'Select at least one file or folder to copy', + }) +export type FileCopySource = z.output + +export const fileCopyDestinationSchema = z.strictObject({ + owner: copyOwnerSchema.describe('Owner in which to create the copied files and folders.'), + folderId: copyIdSchema + .nullable() + .default(null) + .describe('Existing destination folder identifier. Omit or use null for the owner root.'), +}) +export type FileCopyDestination = z.output + +export const fileCopyInputSchema = z.strictObject({ + source: fileCopySourceSchema.describe('Selection to read under the source owner.'), + destination: fileCopyDestinationSchema.describe('Destination requiring file write access.'), +}) +export type FileCopyInput = z.output diff --git a/apps/sim/lib/api/contracts/file-copy.ts b/apps/sim/lib/api/contracts/file-copy.ts new file mode 100644 index 00000000000..3912a4fcf56 --- /dev/null +++ b/apps/sim/lib/api/contracts/file-copy.ts @@ -0,0 +1,85 @@ +import { z } from 'zod' +import { fileCopyDestinationSchema, fileCopyInputSchema } from '@/lib/api/contracts/file-copy-input' +import { noInputSchema, nonEmptyIdSchema } from '@/lib/api/contracts/primitives' +import { projectFileFolderRecordSchema } from '@/lib/api/contracts/project-file-folders' +import { projectFileRecordSchema } from '@/lib/api/contracts/project-files' +import { defineRouteContract } from '@/lib/api/contracts/types' + +export const copyFileItemsBodySchema = fileCopyInputSchema +export type CopyFileItemsBody = z.input + +export const copiedFileSchema = projectFileRecordSchema + .omit({ key: true, path: true, url: true, share: true }) + .extend({ + id: projectFileRecordSchema.shape.id.describe('Identifier of the new file.'), + name: projectFileRecordSchema.shape.name.describe( + 'Name of the copied file, including its extension.' + ), + size: projectFileRecordSchema.shape.size.describe('Current content size in bytes.'), + type: projectFileRecordSchema.shape.type.describe('MIME type of the copied file.'), + width: projectFileRecordSchema.shape.width.describe( + 'Known image width in pixels, when available.' + ), + height: projectFileRecordSchema.shape.height.describe( + 'Known image height in pixels, when available.' + ), + uploadedBy: projectFileRecordSchema.shape.uploadedBy.describe( + 'User who performed the copy, when available.' + ), + folderId: projectFileRecordSchema.shape.folderId.describe( + 'Containing folder identifier, or null at the owner root.' + ), + folderPath: projectFileRecordSchema.shape.folderPath.describe( + 'Containing folder path, or null at the owner root.' + ), + deletedAt: z.iso.datetime().nullable().describe('Archive time, or null for an active file.'), + uploadedAt: z.iso.datetime().describe('Time the new file was created.'), + updatedAt: z.iso.datetime().describe('Time the file metadata last changed.'), + contentUpdatedAt: z.iso + .datetime() + .nullable() + .describe('Time the current file content was written.'), + owner: fileCopyDestinationSchema.shape.owner.describe( + 'Canonical destination owner of the copied file.' + ), + revision: nonEmptyIdSchema.describe('Current content revision for subsequent edits.'), + }) +export type CopiedFile = z.output + +export const copiedFileFolderSchema = projectFileFolderRecordSchema.extend({ + id: projectFileFolderRecordSchema.shape.id.describe('Identifier of the new folder.'), + userId: projectFileFolderRecordSchema.shape.userId.describe( + 'User who performed the copy, when available.' + ), + name: projectFileFolderRecordSchema.shape.name.describe('Name of the copied folder.'), + parentId: projectFileFolderRecordSchema.shape.parentId.describe( + 'Parent folder identifier, or null at the owner root.' + ), + path: projectFileFolderRecordSchema.shape.path.describe( + 'Path of the copied folder within its owner.' + ), + sortOrder: projectFileFolderRecordSchema.shape.sortOrder.describe( + 'Display ordering value within the parent folder.' + ), + deletedAt: z.iso.datetime().nullable().describe('Archive time, or null for an active folder.'), + createdAt: z.iso.datetime().describe('Time the new folder was created.'), + updatedAt: z.iso.datetime().describe('Time the folder metadata last changed.'), + owner: fileCopyDestinationSchema.shape.owner.describe( + 'Canonical destination owner of the copied folder.' + ), +}) +export type CopiedFileFolder = z.output + +export const copyFileItemsResponseSchema = z.object({ + files: z.array(copiedFileSchema).describe('Created files with new identities.'), + folders: z.array(copiedFileFolderSchema).describe('Created folders in their new hierarchy.'), +}) +export type CopyFileItemsResponse = z.output + +export const copyFileItemsContract = defineRouteContract({ + method: 'POST', + path: '/api/files/copy', + query: noInputSchema, + body: copyFileItemsBodySchema, + response: { mode: 'json', schema: copyFileItemsResponseSchema, status: 201 }, +}) diff --git a/apps/sim/lib/api/contracts/project-file-doc.ts b/apps/sim/lib/api/contracts/project-file-doc.ts new file mode 100644 index 00000000000..d1589f6e66e --- /dev/null +++ b/apps/sim/lib/api/contracts/project-file-doc.ts @@ -0,0 +1,57 @@ +import { z } from 'zod' +import { persistFileDocResponseSchema } from '@/lib/api/contracts/file-doc' +import { defineRouteContract } from '@/lib/api/contracts/types' + +export const projectFileDocParamsSchema = z.object({ + projectId: z.string().min(1), + fileId: z.string().min(1), +}) +export type ProjectFileDocParams = z.output + +export const projectFileDocAccessResponseSchema = z.object({ + projectId: z.string(), + fileId: z.string(), + canRead: z.literal(true), + canWrite: z.boolean(), + docId: z.string().min(1).max(128).nullable(), +}) +export type ProjectFileDocAccessResponse = z.output + +export const projectFileDocAccessContract = defineRouteContract({ + method: 'POST', + path: '/api/internal/project-file-doc/[projectId]/[fileId]/access', + params: projectFileDocParamsSchema, + response: { mode: 'json', schema: projectFileDocAccessResponseSchema }, +}) + +export const projectFileDocSeedResponseSchema = z.object({ + update: z.string(), + version: z.number().int(), +}) +export type ProjectFileDocSeedResponse = z.output + +export const projectFileDocSeedContract = defineRouteContract({ + method: 'POST', + path: '/api/internal/project-file-doc/[projectId]/[fileId]/seed', + params: projectFileDocParamsSchema, + response: { mode: 'json', schema: projectFileDocSeedResponseSchema }, +}) + +export const projectFileDocPersistBodySchema = z + .object({ + docState: z + .string() + .min(1) + .max(16 * 1024 * 1024), + expectedVersion: z.number().int().optional(), + }) + .strict() +export type ProjectFileDocPersistBody = z.input + +export const projectFileDocPersistContract = defineRouteContract({ + method: 'POST', + path: '/api/internal/project-file-doc/[projectId]/[fileId]/persist', + params: projectFileDocParamsSchema, + body: projectFileDocPersistBodySchema, + response: { mode: 'json', schema: persistFileDocResponseSchema }, +}) diff --git a/apps/sim/lib/api/contracts/project-file-downloads.ts b/apps/sim/lib/api/contracts/project-file-downloads.ts new file mode 100644 index 00000000000..9df5656d831 --- /dev/null +++ b/apps/sim/lib/api/contracts/project-file-downloads.ts @@ -0,0 +1,32 @@ +import { PASTE_LIMITS } from '@sim/utils/paste' +import type { z } from 'zod' +import { + projectFileParamsSchema, + projectFilesParamsSchema, +} from '@/lib/api/contracts/project-files' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { downloadWorkspaceFileItemsQuerySchema } from '@/lib/api/contracts/workspace-file-folders' +import { exportWorkspaceFileSnapshotBodySchema } from '@/lib/api/contracts/workspace-files' + +export const downloadProjectFileItemsQuerySchema = downloadWorkspaceFileItemsQuerySchema.strict() +export type DownloadProjectFileItemsQuery = z.output +export const downloadProjectFileItemsContract = defineRouteContract({ + method: 'GET', + path: '/api/projects/[id]/files/download', + params: projectFilesParamsSchema, + query: downloadProjectFileItemsQuerySchema, + response: { mode: 'binary' }, +}) + +/** JSON can encode each source byte as a six-byte Unicode escape, plus the envelope. */ +export const MAX_PROJECT_FILE_SNAPSHOT_BODY_BYTES = 6 * PASTE_LIMITS.RICH_MARKDOWN_BYTES + 1024 + +export const exportProjectFileSnapshotBodySchema = exportWorkspaceFileSnapshotBodySchema.strict() +export type ExportProjectFileSnapshotBody = z.input +export const exportProjectFileSnapshotContract = defineRouteContract({ + method: 'POST', + path: '/api/projects/[id]/files/[fileId]/export', + params: projectFileParamsSchema, + body: exportProjectFileSnapshotBodySchema, + response: { mode: 'binary' }, +}) diff --git a/apps/sim/lib/api/contracts/project-file-extraction.ts b/apps/sim/lib/api/contracts/project-file-extraction.ts new file mode 100644 index 00000000000..456f40ff8db --- /dev/null +++ b/apps/sim/lib/api/contracts/project-file-extraction.ts @@ -0,0 +1,18 @@ +import { z } from 'zod' +import { nonEmptyIdSchema } from '@/lib/api/contracts/primitives' +import { projectFileParamsSchema } from '@/lib/api/contracts/project-files' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { extractWorkspaceFileResponseSchema } from '@/lib/api/contracts/workspace-files' + +export const extractProjectFileResponseSchema = extractWorkspaceFileResponseSchema.extend({ + folderId: nonEmptyIdSchema, + folderDisplayPath: z.string(), +}) +export type ExtractProjectFileResponse = z.output + +export const extractProjectFileContract = defineRouteContract({ + method: 'POST', + path: '/api/projects/[id]/files/[fileId]/extract', + params: projectFileParamsSchema, + response: { mode: 'json', schema: extractProjectFileResponseSchema }, +}) diff --git a/apps/sim/lib/api/contracts/project-file-folders.ts b/apps/sim/lib/api/contracts/project-file-folders.ts new file mode 100644 index 00000000000..730ac106604 --- /dev/null +++ b/apps/sim/lib/api/contracts/project-file-folders.ts @@ -0,0 +1,68 @@ +import { z } from 'zod' +import { nonEmptyIdSchema } from '@/lib/api/contracts/primitives' +import { + projectFileCapabilitiesSchema, + projectFilesParamsSchema, +} from '@/lib/api/contracts/project-files' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + createWorkspaceFileFolderBodySchema, + listWorkspaceFileFoldersQuerySchema, + updateWorkspaceFileFolderBodySchema, + workspaceFileFolderSchema, +} from '@/lib/api/contracts/workspace-file-folders' + +export const projectFileFolderParamsSchema = projectFilesParamsSchema.extend({ + folderId: nonEmptyIdSchema, +}) +export type ProjectFileFolderParams = z.input + +export const projectFileFolderRecordSchema = workspaceFileFolderSchema + .omit({ workspaceId: true }) + .extend({ + owner: z.object({ entityType: z.literal('project'), entityId: nonEmptyIdSchema }), + userId: nonEmptyIdSchema, + }) +export type ProjectFileFolderRecord = z.output + +export const listProjectFileFoldersQuerySchema = listWorkspaceFileFoldersQuerySchema +export type ListProjectFileFoldersQuery = z.output + +export const listProjectFileFoldersResponseSchema = z.object({ + folders: z.array(projectFileFolderRecordSchema), + capabilities: projectFileCapabilitiesSchema, +}) +export type ListProjectFileFoldersResponse = z.output + +export const listProjectFileFoldersContract = defineRouteContract({ + method: 'GET', + path: '/api/projects/[id]/files/folders', + params: projectFilesParamsSchema, + query: listProjectFileFoldersQuerySchema, + response: { mode: 'json', schema: listProjectFileFoldersResponseSchema }, +}) + +export const createProjectFileFolderBodySchema = createWorkspaceFileFolderBodySchema +export type CreateProjectFileFolderBody = z.input + +export const updateProjectFileFolderBodySchema = updateWorkspaceFileFolderBodySchema +export type UpdateProjectFileFolderBody = z.input + +export const projectFileFolderResponseSchema = z.object({ folder: projectFileFolderRecordSchema }) +export type ProjectFileFolderResponse = z.output + +export const createProjectFileFolderContract = defineRouteContract({ + method: 'POST', + path: '/api/projects/[id]/files/folders', + params: projectFilesParamsSchema, + body: createProjectFileFolderBodySchema, + response: { mode: 'json', schema: projectFileFolderResponseSchema }, +}) + +export const updateProjectFileFolderContract = defineRouteContract({ + method: 'PATCH', + path: '/api/projects/[id]/files/folders/[folderId]', + params: projectFileFolderParamsSchema, + body: updateProjectFileFolderBodySchema, + response: { mode: 'json', schema: projectFileFolderResponseSchema }, +}) diff --git a/apps/sim/lib/api/contracts/project-file-lifecycle.ts b/apps/sim/lib/api/contracts/project-file-lifecycle.ts new file mode 100644 index 00000000000..cb300dfbc07 --- /dev/null +++ b/apps/sim/lib/api/contracts/project-file-lifecycle.ts @@ -0,0 +1,112 @@ +import { z } from 'zod' +import { noInputSchema, nonEmptyIdSchema } from '@/lib/api/contracts/primitives' +import { + projectFileFolderParamsSchema, + projectFileFolderRecordSchema, +} from '@/lib/api/contracts/project-file-folders' +import { + projectFileParamsSchema, + projectFileRecordSchema, + projectFilesParamsSchema, +} from '@/lib/api/contracts/project-files' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + bulkArchiveWorkspaceFileItemsBodySchema, + moveWorkspaceFileItemsBodySchema, +} from '@/lib/api/contracts/workspace-file-folders' +import { renameWorkspaceFileBodySchema } from '@/lib/api/contracts/workspace-files' + +export const renameProjectFileBodySchema = renameWorkspaceFileBodySchema.strict() +export type RenameProjectFileBody = z.input + +export const renameProjectFileResponseSchema = z.object({ file: projectFileRecordSchema }) +export type RenameProjectFileResponse = z.output + +export const renameProjectFileContract = defineRouteContract({ + method: 'PATCH', + path: '/api/projects/[id]/files/[fileId]', + params: projectFileParamsSchema, + body: renameProjectFileBodySchema, + response: { mode: 'json', schema: renameProjectFileResponseSchema }, +}) + +export const moveProjectFileItemsBodySchema = moveWorkspaceFileItemsBodySchema.strict() +export type MoveProjectFileItemsBody = z.input + +export const moveProjectFileItemsResponseSchema = z.object({ + movedFiles: z.number().int().nonnegative().describe('Number of files moved.'), + movedFolders: z.number().int().nonnegative().describe('Number of folders moved.'), + movedFileIds: z.array(nonEmptyIdSchema).describe('Identifiers of moved files.'), + movedFolderIds: z.array(nonEmptyIdSchema).describe('Identifiers of moved folders.'), +}) +export type MoveProjectFileItemsResponse = z.output + +export const moveProjectFileItemsContract = defineRouteContract({ + method: 'POST', + path: '/api/projects/[id]/files/move', + params: projectFilesParamsSchema, + body: moveProjectFileItemsBodySchema, + response: { mode: 'json', schema: moveProjectFileItemsResponseSchema }, +}) + +export const archiveProjectFileItemsBodySchema = bulkArchiveWorkspaceFileItemsBodySchema.strict() +export type ArchiveProjectFileItemsBody = z.input + +export const archiveProjectFileItemsResponseSchema = z.object({ + deletedItems: z + .object({ + files: z.number().int().nonnegative().describe('Number of affected files.'), + folders: z.number().int().nonnegative().describe('Number of affected folders.'), + }) + .describe('Counts of affected file items.'), + affectedIds: z + .object({ + fileIds: z.array(nonEmptyIdSchema).describe('Identifiers of affected files.'), + folderIds: z.array(nonEmptyIdSchema).describe('Identifiers of affected folders.'), + }) + .describe('Identifiers of affected file items.'), +}) +export type ArchiveProjectFileItemsResponse = z.output + +export const archiveProjectFileItemsContract = defineRouteContract({ + method: 'POST', + path: '/api/projects/[id]/files/archive', + params: projectFilesParamsSchema, + body: archiveProjectFileItemsBodySchema, + response: { mode: 'json', schema: archiveProjectFileItemsResponseSchema }, +}) + +export const restoreProjectFileResponseSchema = z.object({ + restored: z.literal(true), + file: projectFileRecordSchema, +}) +export type RestoreProjectFileResponse = z.output + +export const restoreProjectFileContract = defineRouteContract({ + method: 'POST', + path: '/api/projects/[id]/files/[fileId]/restore', + params: projectFileParamsSchema, + body: noInputSchema, + response: { mode: 'json', schema: restoreProjectFileResponseSchema }, +}) + +export const restoreProjectFileFolderResponseSchema = z.object({ + folder: projectFileFolderRecordSchema, + restoredItems: z + .object({ + files: z.number().int().nonnegative().describe('Number of affected files.'), + folders: z.number().int().nonnegative().describe('Number of affected folders.'), + }) + .describe('Counts of affected file items.'), +}) +export type RestoreProjectFileFolderResponse = z.output< + typeof restoreProjectFileFolderResponseSchema +> + +export const restoreProjectFileFolderContract = defineRouteContract({ + method: 'POST', + path: '/api/projects/[id]/files/folders/[folderId]/restore', + params: projectFileFolderParamsSchema, + body: noInputSchema, + response: { mode: 'json', schema: restoreProjectFileFolderResponseSchema }, +}) diff --git a/apps/sim/lib/api/contracts/project-file-shares.ts b/apps/sim/lib/api/contracts/project-file-shares.ts new file mode 100644 index 00000000000..8c8c8f7c618 --- /dev/null +++ b/apps/sim/lib/api/contracts/project-file-shares.ts @@ -0,0 +1,47 @@ +import { z } from 'zod' +import { + projectFileCapabilitiesSchema, + projectFileParamsSchema, +} from '@/lib/api/contracts/project-files' +import { + shareAuthTypeSchema, + shareRecordSchema, + upsertFileShareBodySchema, +} from '@/lib/api/contracts/public-shares' +import { defineRouteContract } from '@/lib/api/contracts/types' + +export const projectFileSharePolicySchema = z.object({ + canPublish: z + .boolean() + .describe( + 'Whether current Project sharing policies permit publishing or updating an active share.' + ), + allowedAuthTypes: z + .array(shareAuthTypeSchema) + .max(4) + .describe('Authentication modes permitted by every applicable active environment policy.'), +}) +export type ProjectFileSharePolicy = z.output +export const getProjectFileShareResponseSchema = z.object({ + share: shareRecordSchema.nullable(), + policy: projectFileSharePolicySchema, + capabilities: projectFileCapabilitiesSchema, +}) +export type GetProjectFileShareResponse = z.output +export const updateProjectFileShareBodySchema = upsertFileShareBodySchema +export type UpdateProjectFileShareBody = z.input +export const updateProjectFileShareResponseSchema = z.object({ share: shareRecordSchema }) +export type UpdateProjectFileShareResponse = z.output +export const getProjectFileShareContract = defineRouteContract({ + method: 'GET', + path: '/api/projects/[id]/files/[fileId]/share', + params: projectFileParamsSchema, + response: { mode: 'json', schema: getProjectFileShareResponseSchema }, +}) +export const updateProjectFileShareContract = defineRouteContract({ + method: 'PUT', + path: '/api/projects/[id]/files/[fileId]/share', + params: projectFileParamsSchema, + body: updateProjectFileShareBodySchema, + response: { mode: 'json', schema: updateProjectFileShareResponseSchema }, +}) diff --git a/apps/sim/lib/api/contracts/project-file-uploads.ts b/apps/sim/lib/api/contracts/project-file-uploads.ts new file mode 100644 index 00000000000..1d71eb7a92d --- /dev/null +++ b/apps/sim/lib/api/contracts/project-file-uploads.ts @@ -0,0 +1,92 @@ +import { z } from 'zod' +import { folderIdSchema, noInputSchema, nonEmptyIdSchema } from '@/lib/api/contracts/primitives' +import { + projectFileRecordSchema, + projectFilesParamsSchema, +} from '@/lib/api/contracts/project-files' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { v2CreateFileUploadBodySchema } from '@/lib/api/contracts/v2/files' +import { + v2PartUrlsBodySchema, + v2PartUrlsDataSchema, + v2UploadStatusSchema, + v2UploadTokenHeadersSchema, + v2UploadTransferSchema, +} from '@/lib/api/contracts/v2/uploads' + +export const projectFileUploadParamsSchema = projectFilesParamsSchema.extend({ + uploadId: nonEmptyIdSchema, +}) +export type ProjectFileUploadParams = z.input + +export const createProjectFileUploadBodySchema = v2CreateFileUploadBodySchema + .omit({ workspaceId: true }) + .extend({ folderId: folderIdSchema.nullish() }) + .strict() +export type CreateProjectFileUploadBody = z.input + +export const projectFileUploadSessionSchema = z + .object({ + id: nonEmptyIdSchema, + purpose: z.literal('project_file'), + status: v2UploadStatusSchema, + name: z.string(), + contentType: z.string(), + size: z.number().int().nonnegative(), + expiresAt: z.string().datetime(), + error: z.string().nullable(), + result: projectFileRecordSchema.nullable(), + }) + .strict() +export type ProjectFileUploadSession = z.output + +export const createProjectFileUploadResponseSchema = z + .object({ + session: projectFileUploadSessionSchema, + uploadToken: z.string().min(1), + transfer: v2UploadTransferSchema, + }) + .strict() +export type CreateProjectFileUploadResponse = z.output + +export const createProjectFileUploadContract = defineRouteContract({ + method: 'POST', + path: '/api/projects/[id]/files/uploads', + params: projectFilesParamsSchema, + body: createProjectFileUploadBodySchema, + response: { mode: 'json', schema: createProjectFileUploadResponseSchema, status: 201 }, +}) + +export const getProjectFileUploadContract = defineRouteContract({ + method: 'GET', + path: '/api/projects/[id]/files/uploads/[uploadId]', + params: projectFileUploadParamsSchema, + headers: v2UploadTokenHeadersSchema, + response: { mode: 'json', schema: projectFileUploadSessionSchema }, +}) + +export const abortProjectFileUploadContract = defineRouteContract({ + method: 'DELETE', + path: '/api/projects/[id]/files/uploads/[uploadId]', + params: projectFileUploadParamsSchema, + headers: v2UploadTokenHeadersSchema, + response: { mode: 'json', schema: projectFileUploadSessionSchema }, +}) + +export const completeProjectFileUploadContract = defineRouteContract({ + method: 'POST', + path: '/api/projects/[id]/files/uploads/[uploadId]/complete', + params: projectFileUploadParamsSchema, + headers: v2UploadTokenHeadersSchema, + body: noInputSchema.default({}), + response: { mode: 'json', schema: projectFileUploadSessionSchema }, +}) + +export const getProjectFileUploadPartUrlsContract = defineRouteContract({ + method: 'POST', + path: '/api/projects/[id]/files/uploads/[uploadId]/parts', + params: projectFileUploadParamsSchema, + headers: v2UploadTokenHeadersSchema, + body: v2PartUrlsBodySchema, + response: { mode: 'json', schema: v2PartUrlsDataSchema }, +}) diff --git a/apps/sim/lib/api/contracts/project-file-versions.ts b/apps/sim/lib/api/contracts/project-file-versions.ts new file mode 100644 index 00000000000..e754ff9fc8b --- /dev/null +++ b/apps/sim/lib/api/contracts/project-file-versions.ts @@ -0,0 +1,94 @@ +import { z } from 'zod' +import { noInputSchema, versionNumberPathSchema } from '@/lib/api/contracts/primitives' +import { projectFileParamsSchema, projectFileRecordSchema } from '@/lib/api/contracts/project-files' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v2DeleteFileVersionResultSchema, + v2FileVersionSchema, + v2ListFileVersionsQuerySchema, + v2RevertFileVersionBodySchema, +} from '@/lib/api/contracts/v2/file-versions' +import { writtenFileRevisionSchema } from '@/lib/api/contracts/v2/files' + +export const projectFileVersionSchema = v2FileVersionSchema.meta({ + id: 'ProjectFileVersion', + title: 'Project file version', + description: 'One recorded version of a shared Project file.', +}) +export type ProjectFileVersion = z.output +export const projectFileVersionParamsSchema = projectFileParamsSchema.extend({ + version: versionNumberPathSchema.describe('Version number.'), +}) +export type ProjectFileVersionParams = z.input +export const listProjectFileVersionsQuerySchema = v2ListFileVersionsQuerySchema.omit({ + workspaceId: true, +}) +export type ListProjectFileVersionsQuery = z.output +export const listProjectFileVersionsResponseSchema = z.object({ + revision: writtenFileRevisionSchema, + versions: z.array(projectFileVersionSchema), + nextCursor: z.string().nullable(), +}) +export type ListProjectFileVersionsResponse = z.output +export const getProjectFileVersionResponseSchema = z.object({ version: projectFileVersionSchema }) +export type GetProjectFileVersionResponse = z.output +export const revertProjectFileVersionBodySchema = v2RevertFileVersionBodySchema.omit({ + workspaceId: true, +}) +export type RevertProjectFileVersionBody = z.input +export const revertProjectFileVersionResponseSchema = z.object({ + reverted: z.boolean(), + file: projectFileRecordSchema, + version: projectFileVersionSchema, + revision: writtenFileRevisionSchema, +}) +export type RevertProjectFileVersionResponse = z.output< + typeof revertProjectFileVersionResponseSchema +> +export const deleteProjectFileVersionResponseSchema = v2DeleteFileVersionResultSchema.extend({ + deleted: z + .literal(true) + .describe( + 'The version is no longer available; stored-object cleanup is retried asynchronously when needed.' + ), +}) +export type DeleteProjectFileVersionResponse = z.output< + typeof deleteProjectFileVersionResponseSchema +> + +export const listProjectFileVersionsContract = defineRouteContract({ + method: 'GET', + path: '/api/projects/[id]/files/[fileId]/versions', + params: projectFileParamsSchema, + query: listProjectFileVersionsQuerySchema, + response: { mode: 'json', schema: listProjectFileVersionsResponseSchema }, +}) +export const getProjectFileVersionContract = defineRouteContract({ + method: 'GET', + path: '/api/projects/[id]/files/[fileId]/versions/[version]', + params: projectFileVersionParamsSchema, + query: noInputSchema, + response: { mode: 'json', schema: getProjectFileVersionResponseSchema }, +}) +export const readProjectFileVersionContentContract = defineRouteContract({ + method: 'GET', + path: '/api/projects/[id]/files/[fileId]/versions/[version]/content', + params: projectFileVersionParamsSchema, + query: noInputSchema, + response: { mode: 'binary' }, +}) +export const revertProjectFileVersionContract = defineRouteContract({ + method: 'POST', + path: '/api/projects/[id]/files/[fileId]/versions/[version]/revert', + params: projectFileVersionParamsSchema, + query: noInputSchema, + body: revertProjectFileVersionBodySchema, + response: { mode: 'json', schema: revertProjectFileVersionResponseSchema }, +}) +export const deleteProjectFileVersionContract = defineRouteContract({ + method: 'DELETE', + path: '/api/projects/[id]/files/[fileId]/versions/[version]', + params: projectFileVersionParamsSchema, + query: noInputSchema, + response: { mode: 'json', schema: deleteProjectFileVersionResponseSchema }, +}) diff --git a/apps/sim/lib/api/contracts/project-files.ts b/apps/sim/lib/api/contracts/project-files.ts new file mode 100644 index 00000000000..9f7926edaeb --- /dev/null +++ b/apps/sim/lib/api/contracts/project-files.ts @@ -0,0 +1,170 @@ +import { z } from 'zod' +import { + fileBrowserCreatorSchema, + fileBrowserCreatorsQuerySchema, + fileBrowserItemSchema, + fileBrowserSizesQuerySchema, + fileBrowserTypesQuerySchema, +} from '@/lib/api/contracts/file-browser' +import { + booleanQueryFlagSchema, + folderIdSchema, + inlineFileRefQuerySchema, + nonEmptyIdSchema, +} from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { v2PaginationFields, v2SearchSchema, v2SortFields } from '@/lib/api/contracts/v2/shared' +import { + workspaceCsvPreviewQuerySchema, + workspaceCsvPreviewResponseSchema, +} from '@/lib/api/contracts/workspace-file-table' +import { + createWorkspaceFileBodySchema, + updateWorkspaceFileContentBodySchema, + workspaceFileRecordSchema, + workspaceFileScopeSchema, +} from '@/lib/api/contracts/workspace-files' +import { FILE_BROWSER_SORTS } from '@/lib/workspace-files/browser' + +export const projectFilesParamsSchema = z.object({ id: nonEmptyIdSchema }) +export type ProjectFilesParams = z.input + +export const projectFileParamsSchema = projectFilesParamsSchema.extend({ fileId: nonEmptyIdSchema }) +export type ProjectFileParams = z.input + +export const projectFileRecordSchema = workspaceFileRecordSchema + .omit({ workspaceId: true, storageContext: true, vfsNamespace: true }) + .extend({ + owner: z.object({ entityType: z.literal('project'), entityId: nonEmptyIdSchema }), + uploadedBy: nonEmptyIdSchema, + }) +export type ProjectFileRecord = z.output + +export const projectFileCapabilitiesSchema = z.object({ + canRead: z.boolean(), + canWrite: z.boolean(), +}) +export type ProjectFileCapabilities = z.output + +export const listProjectFilesQuerySchema = z + .object({ + scope: workspaceFileScopeSchema.default('active'), + folderId: folderIdSchema.optional(), + recursive: booleanQueryFlagSchema.optional(), + search: v2SearchSchema.optional(), + types: fileBrowserTypesQuerySchema, + sizes: fileBrowserSizesQuerySchema, + creatorIds: fileBrowserCreatorsQuerySchema, + ...v2SortFields(FILE_BROWSER_SORTS, { sortBy: 'updated', sortOrder: 'desc' }), + ...v2PaginationFields(), + }) + .refine((query) => !query.recursive || query.folderId == null, { + message: 'Recursive listing starts at the Project root; omit folderId', + path: ['folderId'], + }) +export type ListProjectFilesQuery = z.output + +export const listProjectFilesResponseSchema = z.object({ + files: z.array(projectFileRecordSchema), + items: z.array(fileBrowserItemSchema), + creators: z.array(fileBrowserCreatorSchema), + nextCursor: z.string().nullable(), + capabilities: projectFileCapabilitiesSchema, +}) +export type ListProjectFilesResponse = z.output + +export const listProjectFilesContract = defineRouteContract({ + method: 'GET', + path: '/api/projects/[id]/files', + params: projectFilesParamsSchema, + query: listProjectFilesQuerySchema, + response: { mode: 'json', schema: listProjectFilesResponseSchema }, +}) + +export const getProjectFileResponseSchema = z.object({ + file: projectFileRecordSchema, + capabilities: projectFileCapabilitiesSchema, +}) +export type GetProjectFileResponse = z.output + +export const getProjectFileContract = defineRouteContract({ + method: 'GET', + path: '/api/projects/[id]/files/[fileId]', + params: projectFileParamsSchema, + response: { mode: 'json', schema: getProjectFileResponseSchema }, +}) + +export const createProjectFileBodySchema = createWorkspaceFileBodySchema.safeExtend({ + exactName: z.boolean().default(false), + folderPath: z.string().max(4096).optional(), +}) +export type CreateProjectFileBody = z.input + +export const createProjectFileContract = defineRouteContract({ + method: 'POST', + path: '/api/projects/[id]/files', + params: projectFilesParamsSchema, + body: createProjectFileBodySchema, + response: { mode: 'json', schema: getProjectFileResponseSchema, status: 201 }, +}) + +export const updateProjectFileContentBodySchema = updateWorkspaceFileContentBodySchema + .safeExtend({ + expectedRevision: z.string().min(1).max(1024).optional(), + contentType: z.string().trim().min(1).max(255).optional(), + }) + .strict() +export type UpdateProjectFileContentBody = z.input + +export const updateProjectFileContentResponseSchema = getProjectFileResponseSchema.safeExtend({ + file: projectFileRecordSchema.extend({ currentVersion: z.number().int().positive() }), +}) +export type UpdateProjectFileContentResponse = z.output< + typeof updateProjectFileContentResponseSchema +> + +export const updateProjectFileContentContract = defineRouteContract({ + method: 'PUT', + path: '/api/projects/[id]/files/[fileId]/content', + params: projectFileParamsSchema, + body: updateProjectFileContentBodySchema, + response: { mode: 'json', schema: updateProjectFileContentResponseSchema }, +}) + +export const readProjectFileContentContract = defineRouteContract({ + method: 'GET', + path: '/api/projects/[id]/files/[fileId]/content', + params: projectFileParamsSchema, + response: { mode: 'binary' }, +}) + +export const projectFileArtifactQuerySchema = z.object({ + preview: z.enum(['1', '0']).optional(), + v: z.string().max(128).optional(), + t: z.string().max(32).optional(), +}) +export type ProjectFileArtifactQuery = z.input + +export const readProjectFileArtifactContract = defineRouteContract({ + method: 'GET', + path: '/api/projects/[id]/files/[fileId]/artifact', + params: projectFileParamsSchema, + query: projectFileArtifactQuerySchema, + response: { mode: 'binary' }, +}) + +export const getInlineProjectFileContract = defineRouteContract({ + method: 'GET', + path: '/api/projects/[id]/files/inline', + params: projectFilesParamsSchema, + query: inlineFileRefQuerySchema, + response: { mode: 'binary' }, +}) + +export const getProjectCsvPreviewContract = defineRouteContract({ + method: 'GET', + path: '/api/projects/[id]/files/[fileId]/csv-preview', + params: projectFileParamsSchema, + query: workspaceCsvPreviewQuerySchema, + response: { mode: 'json', schema: workspaceCsvPreviewResponseSchema }, +}) diff --git a/apps/sim/lib/api/contracts/projects.ts b/apps/sim/lib/api/contracts/projects.ts index 0339cd07738..99abb40d820 100644 --- a/apps/sim/lib/api/contracts/projects.ts +++ b/apps/sim/lib/api/contracts/projects.ts @@ -23,6 +23,8 @@ const projectSchema = z.object({ environments: z.array(projectEnvironmentSchema), capabilities: z.object({ administer: z.boolean(), issues: z.boolean() }), }) +export type Project = z.output + const projectParamsSchema = z.object({ id: nonEmptyIdSchema }) const projectQuerySchema = z.object({ organizationId: organizationIdSchema.optional(), diff --git a/apps/sim/lib/api/contracts/public-shares.ts b/apps/sim/lib/api/contracts/public-shares.ts index 84a6362fc69..4fd88574d3b 100644 --- a/apps/sim/lib/api/contracts/public-shares.ts +++ b/apps/sim/lib/api/contracts/public-shares.ts @@ -65,8 +65,6 @@ const getFileShareResponseSchema = z.object({ share: shareRecordSchema.nullable(), }) -export type GetFileShareResponse = z.output - export const getFileShareContract = defineRouteContract({ method: 'GET', path: '/api/workspaces/[id]/files/[fileId]/share', @@ -138,7 +136,7 @@ export const getPublicFileContentContract = defineRouteContract({ /** * Binary stream of an image embedded in a shared document. Authorized by the parent * document's active share — the route serves the bytes only when the reference is - * actually embedded in the shared document AND the file lives in the same workspace, + * actually embedded in the shared document AND the file has the same canonical owner, * and only when the bytes are a renderable raster image. */ export const getPublicInlineFileContract = defineRouteContract({ diff --git a/apps/sim/lib/api/contracts/realtime-file-lists.ts b/apps/sim/lib/api/contracts/realtime-file-lists.ts new file mode 100644 index 00000000000..0b68a6297af --- /dev/null +++ b/apps/sim/lib/api/contracts/realtime-file-lists.ts @@ -0,0 +1,22 @@ +import { z } from 'zod' +import { defineRouteContract } from '@/lib/api/contracts/types' + +const projectFileListAccessParamsSchema = z.object({ + projectId: z + .string() + .min(1) + .max(200) + .regex(/^[^/:\s]+$/), +}) + +const projectFileListAccessResponseSchema = z.object({ + projectId: z.string().min(1), + canRead: z.literal(true), +}) + +export const projectFileListAccessContract = defineRouteContract({ + method: 'POST', + path: '/api/internal/project-file-list/[projectId]/access', + params: projectFileListAccessParamsSchema, + response: { mode: 'json', schema: projectFileListAccessResponseSchema }, +}) diff --git a/apps/sim/lib/api/contracts/v2/file-copy.ts b/apps/sim/lib/api/contracts/v2/file-copy.ts new file mode 100644 index 00000000000..65adf70f339 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/file-copy.ts @@ -0,0 +1,12 @@ +import { copyFileItemsBodySchema, copyFileItemsResponseSchema } from '@/lib/api/contracts/file-copy' +import { noInputSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { v2DataResponse } from '@/lib/api/contracts/v2/shared' + +export const v2CopyFileItemsContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/files/copy', + query: noInputSchema, + body: copyFileItemsBodySchema, + response: { mode: 'json', schema: v2DataResponse(copyFileItemsResponseSchema), status: 201 }, +}) diff --git a/apps/sim/lib/api/contracts/v2/files.ts b/apps/sim/lib/api/contracts/v2/files.ts index 9e8a8003ca5..77bb81ca928 100644 --- a/apps/sim/lib/api/contracts/v2/files.ts +++ b/apps/sim/lib/api/contracts/v2/files.ts @@ -168,7 +168,7 @@ export const v2FileShareSchema = shareRecordSchema .meta({ id: 'V2FileShare', title: 'File share', - description: 'Public-safe share configuration for a workspace file.', + description: 'Public-safe share configuration for a file.', }) export type V2FileShare = z.output @@ -345,7 +345,7 @@ export const v2CreateFileBodySchema = z export type V2CreateFileBody = z.input /** Sortable file fields. `name` is the uploaded file name, not the storage key. */ -export const v2FileSortFields = ['name', 'size', 'uploadedAt', 'updatedAt'] as const +const v2FileSortFields = ['name', 'size', 'uploadedAt', 'updatedAt'] as const export type V2FileSortBy = (typeof v2FileSortFields)[number] @@ -1071,7 +1071,7 @@ export const v2MoveFileItemsContract = defineRouteContract({ * Comma-separated only: v2 rejects a query parameter sent more than once, so a * repeated-parameter form would never reach this schema. */ -function v2QuerySelectionListSchema(field: string) { +export function v2QuerySelectionListSchema(field: string) { return z .string() .optional() diff --git a/apps/sim/lib/api/contracts/v2/list-pagination.test.ts b/apps/sim/lib/api/contracts/v2/list-pagination.test.ts index e594e674224..f11a22ae1bf 100644 --- a/apps/sim/lib/api/contracts/v2/list-pagination.test.ts +++ b/apps/sim/lib/api/contracts/v2/list-pagination.test.ts @@ -64,6 +64,8 @@ const PAGED_LISTS = [ 'GET /api/v2/credentials', 'GET /api/v2/custom-tools', 'GET /api/v2/files', + 'GET /api/v2/projects/[projectId]/files', + 'GET /api/v2/projects/[projectId]/files/[fileId]/versions', 'GET /api/v2/files/[fileId]/versions', 'GET /api/v2/knowledge', 'GET /api/v2/knowledge/[knowledgeBaseId]/connectors', @@ -124,6 +126,7 @@ const PAGED_LISTS = [ const FULL_SET_LISTS = [ 'GET /api/v2/credentials/providers', 'GET /api/v2/files/folders', + 'GET /api/v2/projects/[projectId]/files/folders', 'GET /api/v2/knowledge/[knowledgeBaseId]/tags', 'GET /api/v2/knowledge/[knowledgeBaseId]/tags/usage', 'GET /api/v2/knowledge/folders', @@ -253,6 +256,15 @@ const CURSOR_BINDINGS: Record = { 'recursive', ], 'GET /api/v2/files/[fileId]/versions': ['sortBy', 'sortOrder'], + 'GET /api/v2/projects/[projectId]/files/[fileId]/versions': ['sortBy', 'sortOrder'], + 'GET /api/v2/projects/[projectId]/files': [ + 'scope', + 'folderPath', + 'recursive', + 'search', + 'sortBy', + 'sortOrder', + ], 'GET /api/v2/knowledge': ['workspaceId', 'scope', 'folderPath', 'search', 'sortBy', 'sortOrder'], 'GET /api/v2/knowledge/[knowledgeBaseId]/connectors': ['workspaceId', 'sortBy', 'sortOrder'], 'GET /api/v2/knowledge/[knowledgeBaseId]/connectors/[connectorId]/documents': [ @@ -364,6 +376,8 @@ const CURSOR_BINDINGS: Record = { * resolves the path before fingerprinting it. */ const CURSOR_BOUND_PATH_PARAMS: Record = { + 'GET /api/v2/projects/[projectId]/files': ['projectId'], + 'GET /api/v2/projects/[projectId]/files/[fileId]/versions': ['projectId', 'fileId'], 'GET /api/v2/credentials/[credentialId]/members': ['credentialId'], 'GET /api/v2/organizations/[organizationId]/sso/providers': ['organizationId'], 'GET /api/v2/organizations/[organizationId]/domains': ['organizationId'], diff --git a/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts b/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts index d0b268056d6..88020141502 100644 --- a/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts +++ b/apps/sim/lib/api/contracts/v2/openapi/files-audit.ts @@ -1,4 +1,5 @@ import { v2GetAuditLogContract, v2ListAuditLogsContract } from '@/lib/api/contracts/v2/audit-logs' +import { v2CopyFileItemsContract } from '@/lib/api/contracts/v2/file-copy' import { v2DeleteFileVersionContract, v2DownloadFileVersionContract, @@ -36,6 +37,12 @@ import { v2UpdateFileContentContract, v2UpsertFileShareContract, } from '@/lib/api/contracts/v2/files' +import { projectFileDownloadOpenApiRoutes } from '@/lib/api/contracts/v2/openapi/project-file-downloads' +import { projectFileFolderOpenApiRoutes } from '@/lib/api/contracts/v2/openapi/project-file-folders' +import { projectFileShareOpenApiRoutes } from '@/lib/api/contracts/v2/openapi/project-file-shares' +import { projectFileUploadOpenApiRoutes } from '@/lib/api/contracts/v2/openapi/project-file-uploads' +import { projectFileVersionOpenApiRoutes } from '@/lib/api/contracts/v2/openapi/project-file-versions' +import { projectFileOpenApiRoutes } from '@/lib/api/contracts/v2/openapi/project-files' import { documentedSchema, type ErrorResponseId, @@ -63,6 +70,7 @@ import { type OpenApiSuccessMetadata, } from '@/lib/api/openapi/types' import { auditLogOperations } from '@/lib/audit-logs/application/operations' +import { fileCopyOperation } from '@/lib/workspace-files/application/copy-operation' import { fileOperations } from '@/lib/workspace-files/application/operations' import { MAX_ZIP_DOWNLOAD_FILES } from '@/lib/workspace-files/limits' @@ -154,6 +162,38 @@ function auditOperation( } const declaredRoutes = [ + defineOpenApiRoute( + v2CopyFileItemsContract, + filesOperation({ + applicationOperation: fileCopyOperation, + operationId: 'copyFileItems', + summary: 'Copy File Items', + description: `Copy selected files and folder trees between workspace or Project owners. Source read and destination write access are checked independently. Copies receive new identities and retain source secret provenance. Registration is atomic, with destination names resolved by the existing copy rules. ${WORKSPACE_API_KEY_DENIED}`, + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], + success: { description: 'New file and folder identities with their destination owner.' }, + }), + { + query: v2CopyFileItemsContract.query, + body: documentedSchema( + v2CopyFileItemsContract.body, + 'CopyFileItemsRequest', + 'Copy file items request', + 'Exact source owner and selected identifiers, plus the destination owner and folder.' + ), + response: documentedSchema( + v2CopyFileItemsContract.response.schema, + 'V2CopyFileItemsResponse', + 'Copy file items response', + 'Created files and folders, without private storage keys.' + ), + } + ), + ...projectFileOpenApiRoutes, + ...projectFileDownloadOpenApiRoutes, + ...projectFileVersionOpenApiRoutes, + ...projectFileFolderOpenApiRoutes, + ...projectFileShareOpenApiRoutes, + ...projectFileUploadOpenApiRoutes, defineOpenApiRoute( v2ListFilesContract, filesOperation({ @@ -1396,7 +1436,7 @@ export const filesAuditOpenApiDocument = defineOpenApiDocument({ info: { title: 'Sim API v2 — Files & Audit Logs', description: - 'Version 2 of the Sim REST API for workspace files, resumable uploads, public shares, and organization audit logs.', + 'Version 2 of the Sim REST API for workspace and Project files, resumable uploads, public shares, and organization audit logs.', version: '2.0.0', contact: { name: 'Sim Support', @@ -1413,7 +1453,7 @@ export const filesAuditOpenApiDocument = defineOpenApiDocument({ { name: 'Files', description: - 'Create, upload, download, organize, share, version, and delete workspace files.', + 'Create, upload, download, organize, share, version, and delete workspace and Project files.', }, { name: 'Audit Logs', diff --git a/apps/sim/lib/api/contracts/v2/openapi/project-file-downloads.ts b/apps/sim/lib/api/contracts/v2/openapi/project-file-downloads.ts new file mode 100644 index 00000000000..187d91c73b9 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/openapi/project-file-downloads.ts @@ -0,0 +1,78 @@ +import { + documentedSchema, + HEAD_MIRRORS_GET, + HEAD_OMITS_PAYLOAD_HEADERS, + RESOURCE_CONFLICT_ERRORS, + WORKSPACE_API_KEY_DENIED, +} from '@/lib/api/contracts/v2/openapi/shared' +import { + v2DownloadProjectFileItemsContract, + v2ExportProjectFileSnapshotContract, +} from '@/lib/api/contracts/v2/project-file-downloads' +import { defineOpenApiRoute } from '@/lib/api/openapi/types' +import { projectFileOperations } from '@/lib/projects/files/application/operations' +import { MAX_ZIP_DOWNLOAD_FILES } from '@/lib/workspace-files/limits' + +export const projectFileDownloadOpenApiRoutes = [ + defineOpenApiRoute( + v2DownloadProjectFileItemsContract, + { + applicationOperation: projectFileOperations.downloadItems, + operationId: 'downloadProjectFileItems', + summary: 'Download Project File Items', + description: `Download selected files and recursive folder contents as one ZIP archive. Duplicate selections are included once. Select at most ${MAX_ZIP_DOWNLOAD_FILES} files in total; archive bytes are bounded. Unknown or archived selections are rejected. Downloads record an audit event. ${WORKSPACE_API_KEY_DENIED} ${HEAD_MIRRORS_GET} ${HEAD_OMITS_PAYLOAD_HEADERS}`, + tags: ['Files'], + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], + success: { + description: 'Selected files in their Project folder paths.', + headers: ['Content-Type', 'Content-Disposition', 'Content-Length'], + contentTypes: ['application/zip'], + }, + }, + { + params: documentedSchema( + v2DownloadProjectFileItemsContract.params, + 'ProjectFilesParams', + 'Project file collection', + 'The Project that owns the requested files.' + ), + query: documentedSchema( + v2DownloadProjectFileItemsContract.query, + 'DownloadProjectFileItemsQuery', + 'Project archive selection', + 'File and folder identifiers within the Project.' + ), + } + ), + defineOpenApiRoute( + v2ExportProjectFileSnapshotContract, + { + applicationOperation: projectFileOperations.exportSnapshot, + operationId: 'exportProjectFileSnapshot', + summary: 'Export Project File Snapshot', + description: `Export the supplied visible Markdown snapshot without changing the stored file or its history. Readable embedded Project assets are bundled in a ZIP; a snapshot without bundled assets is returned as Markdown. Missing or unreadable assets remain as references. Total bytes are bounded. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Files'], + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], + success: { + description: 'Markdown snapshot or its archive with embedded assets.', + headers: ['Content-Type', 'Content-Disposition', 'Content-Length'], + contentTypes: ['text/markdown', 'application/zip'], + }, + }, + { + params: documentedSchema( + v2ExportProjectFileSnapshotContract.params, + 'ProjectFileParams', + 'Project file identity', + 'The owning Project and the file identifier.' + ), + query: v2ExportProjectFileSnapshotContract.query, + body: documentedSchema( + v2ExportProjectFileSnapshotContract.body, + 'ExportProjectFileSnapshotRequest', + 'Project Markdown snapshot', + 'The visible document content to export.' + ), + } + ), +] as const diff --git a/apps/sim/lib/api/contracts/v2/openapi/project-file-folders.ts b/apps/sim/lib/api/contracts/v2/openapi/project-file-folders.ts new file mode 100644 index 00000000000..17bbe2ae39f --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/openapi/project-file-folders.ts @@ -0,0 +1,159 @@ +import { + documentedSchema, + FULL_SET_LIST, + RATE_LIMIT_HEADERS, + RESOURCE_CONFLICT_ERRORS, + RESOURCE_ERRORS, + WORKSPACE_API_KEY_DENIED, +} from '@/lib/api/contracts/v2/openapi/shared' +import { + v2CreateProjectFileFolderContract, + v2ListProjectFileFoldersContract, + v2RestoreProjectFileFolderContract, + v2UpdateProjectFileFolderContract, +} from '@/lib/api/contracts/v2/project-file-folders' +import { defineOpenApiRoute } from '@/lib/api/openapi/types' +import { projectFileOperations } from '@/lib/projects/files/application/operations' + +export const projectFileFolderOpenApiRoutes = [ + defineOpenApiRoute( + v2ListProjectFileFoldersContract, + { + applicationOperation: projectFileOperations.listFolders, + operationId: 'listProjectFileFolders', + summary: 'List Project File Folders', + description: `List the Project folder tree with stable identifiers. Use scope=archived to find folders eligible for restore. ${FULL_SET_LIST} ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Files'], + errors: RESOURCE_ERRORS, + success: { + description: 'The complete set of folders in the selected lifecycle scope.', + headers: RATE_LIMIT_HEADERS, + }, + }, + { + params: documentedSchema( + v2ListProjectFileFoldersContract.params, + 'ProjectFilesParams', + 'Project file collection', + 'The Project that owns the requested files.' + ), + query: documentedSchema( + v2ListProjectFileFoldersContract.query, + 'ListProjectFileFoldersQuery', + 'Project folder list query', + 'Select active, archived, or all folders.' + ), + response: documentedSchema( + v2ListProjectFileFoldersContract.response.schema, + 'V2ProjectFileFolderListResponse', + 'Project folder list response', + 'Folders in their existing manual order; nextCursor is always null.' + ), + } + ), + defineOpenApiRoute( + v2CreateProjectFileFolderContract, + { + applicationOperation: projectFileOperations.createFolder, + operationId: 'createProjectFileFolder', + summary: 'Create Project File Folder', + description: `Create a folder under an existing parent, or at the Project root when parentId is omitted. Sibling names must be unique. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Files'], + errors: RESOURCE_CONFLICT_ERRORS, + success: { + description: 'The created folder and its owner and creator attribution.', + headers: RATE_LIMIT_HEADERS, + }, + }, + { + params: documentedSchema( + v2CreateProjectFileFolderContract.params, + 'ProjectFilesParams', + 'Project file collection', + 'The Project that owns the requested files.' + ), + query: v2CreateProjectFileFolderContract.query, + body: documentedSchema( + v2CreateProjectFileFolderContract.body, + 'CreateProjectFileFolderRequest', + 'Create Project folder request', + 'The folder name and optional parent identifier.' + ), + response: documentedSchema( + v2CreateProjectFileFolderContract.response.schema, + 'V2ProjectFileFolderResponse', + 'Project folder response', + 'One folder with canonical ownership and creator attribution.' + ), + } + ), + defineOpenApiRoute( + v2UpdateProjectFileFolderContract, + { + applicationOperation: projectFileOperations.updateFolder, + operationId: 'updateProjectFileFolder', + summary: 'Update Project File Folder', + description: `Rename, move, or reorder a folder while retaining its identity and descendants. Omitted fields stay unchanged; parentId=null moves the folder to the root. Cross-owner parents and cycles are rejected. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Files'], + errors: RESOURCE_CONFLICT_ERRORS, + success: { description: 'The updated folder.', headers: RATE_LIMIT_HEADERS }, + }, + { + params: documentedSchema( + v2UpdateProjectFileFolderContract.params, + 'ProjectFileFolderParams', + 'Project folder identity', + 'The Project and folder identifiers.' + ), + query: v2UpdateProjectFileFolderContract.query, + body: documentedSchema( + v2UpdateProjectFileFolderContract.body, + 'UpdateProjectFileFolderRequest', + 'Update Project folder request', + 'The fields to change on the folder.' + ), + response: documentedSchema( + v2UpdateProjectFileFolderContract.response.schema, + 'V2ProjectFileFolderResponse', + 'Project folder response', + 'One folder with canonical ownership and creator attribution.' + ), + } + ), + defineOpenApiRoute( + v2RestoreProjectFileFolderContract, + { + applicationOperation: projectFileOperations.restoreFolder, + operationId: 'restoreProjectFileFolder', + summary: 'Restore Project File Folder', + description: `Restore an archived folder and the files and subfolders archived with it. Find identifiers with List Project File Folders using scope=archived. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Files'], + errors: RESOURCE_CONFLICT_ERRORS, + success: { + description: 'The restored folder and affected item counts.', + headers: RATE_LIMIT_HEADERS, + }, + }, + { + params: documentedSchema( + v2RestoreProjectFileFolderContract.params, + 'ProjectFileFolderParams', + 'Project folder identity', + 'The Project and folder identifiers.' + ), + query: v2RestoreProjectFileFolderContract.query, + body: documentedSchema( + v2RestoreProjectFileFolderContract.body, + 'RestoreProjectFileFolderRequest', + 'Restore Project folder request', + 'An empty object; the folder is identified in the path.' + ), + response: documentedSchema( + v2RestoreProjectFileFolderContract.response.schema, + 'V2RestoreProjectFileFolderResponse', + 'Restore Project folder response', + 'The restored folder and affected item counts.' + ), + } + ), +] as const diff --git a/apps/sim/lib/api/contracts/v2/openapi/project-file-shares.ts b/apps/sim/lib/api/contracts/v2/openapi/project-file-shares.ts new file mode 100644 index 00000000000..b8dde906cc4 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/openapi/project-file-shares.ts @@ -0,0 +1,75 @@ +import { + documentedSchema, + RATE_LIMIT_HEADERS, + RESOURCE_ERRORS, + WORKSPACE_API_KEY_DENIED, +} from '@/lib/api/contracts/v2/openapi/shared' +import { + v2GetProjectFileShareContract, + v2UpdateProjectFileShareContract, +} from '@/lib/api/contracts/v2/project-file-shares' +import { defineOpenApiRoute } from '@/lib/api/openapi/types' +import { projectFileOperations } from '@/lib/projects/files/application/operations' + +export const projectFileShareOpenApiRoutes = [ + defineOpenApiRoute( + v2GetProjectFileShareContract, + { + applicationOperation: projectFileOperations.readShare, + operationId: 'getProjectFileShare', + summary: 'Get Project File Share', + description: `Get a Project file's public-share configuration. An unshared file returns data: null; a disabled share retains its configuration with isActive: false. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Files'], + errors: RESOURCE_ERRORS, + success: { description: 'Current nullable file-share state.', headers: RATE_LIMIT_HEADERS }, + }, + { + params: documentedSchema( + v2GetProjectFileShareContract.params, + 'ProjectFileParams', + 'Project file identity', + 'The owning Project and the file identifier.' + ), + query: v2GetProjectFileShareContract.query, + response: documentedSchema( + v2GetProjectFileShareContract.response.schema, + 'V2GetProjectFileShareResponse', + 'Get Project file share response', + 'Current public-share state for a Project file.' + ), + } + ), + defineOpenApiRoute( + v2UpdateProjectFileShareContract, + { + applicationOperation: projectFileOperations.updateShare, + operationId: 'updateProjectFileShare', + summary: 'Update Project File Share', + description: `Create or update a Project file's public share. isActive is required; omitted settings retain their current values except credentials unused by the selected access mode, which are cleared. Disabling retains the token and access configuration. Publication requires Project edit access and the current sharing policy across accessible active environments. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Files'], + errors: RESOURCE_ERRORS, + success: { description: 'The updated Project file share.', headers: RATE_LIMIT_HEADERS }, + }, + { + params: documentedSchema( + v2UpdateProjectFileShareContract.params, + 'ProjectFileParams', + 'Project file identity', + 'The owning Project and the file identifier.' + ), + query: v2UpdateProjectFileShareContract.query, + body: documentedSchema( + v2UpdateProjectFileShareContract.body, + 'UpdateProjectFileShareRequest', + 'Update Project file share request', + 'Desired public-share state and access policy. Share tokens are generated by the server.' + ), + response: documentedSchema( + v2UpdateProjectFileShareContract.response.schema, + 'V2UpdateProjectFileShareResponse', + 'Update Project file share response', + 'Updated public-share state for a Project file.' + ), + } + ), +] as const diff --git a/apps/sim/lib/api/contracts/v2/openapi/project-file-uploads.ts b/apps/sim/lib/api/contracts/v2/openapi/project-file-uploads.ts new file mode 100644 index 00000000000..ba82761c002 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/openapi/project-file-uploads.ts @@ -0,0 +1,192 @@ +import { + documentedSchema, + RATE_LIMIT_HEADERS, + RESOURCE_CONFLICT_ERRORS, + WORKSPACE_API_KEY_DENIED, +} from '@/lib/api/contracts/v2/openapi/shared' +import { + v2AbortProjectFileUploadContract, + v2CompleteProjectFileUploadContract, + v2CreateProjectFileUploadContract, + v2GetProjectFileUploadContract, + v2GetProjectFileUploadPartUrlsContract, +} from '@/lib/api/contracts/v2/project-file-uploads' +import { defineOpenApiRoute } from '@/lib/api/openapi/types' +import { projectFileOperations } from '@/lib/projects/files/application/operations' + +export const projectFileUploadOpenApiRoutes = [ + defineOpenApiRoute( + v2CreateProjectFileUploadContract, + { + applicationOperation: projectFileOperations.uploadCreate, + operationId: 'createProjectFileUpload', + summary: 'Create Project File Upload', + description: `Create a resumable Project file upload. The file is registered only after the signed transfer and completion succeed. The original API credential and upload-token are required on every control request. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Files'], + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge', 'UnsupportedMediaType'], + success: { description: 'Create Project File Upload result.', headers: RATE_LIMIT_HEADERS }, + }, + { + params: documentedSchema( + v2CreateProjectFileUploadContract.params, + 'ProjectFilesParams', + 'Project upload identity', + 'The owning Project and, for control operations, the upload session.' + ), + query: v2CreateProjectFileUploadContract.query, + body: documentedSchema( + v2CreateProjectFileUploadContract.body, + 'ProjectUploadCreateRequest', + 'Create Project File Upload request', + 'The parameters for this upload operation.' + ), + response: documentedSchema( + v2CreateProjectFileUploadContract.response.schema, + 'V2CreateProjectFileUploadResponse', + 'Create Project File Upload response', + 'The authorized upload state or transfer instructions.' + ), + } + ), + defineOpenApiRoute( + v2GetProjectFileUploadContract, + { + applicationOperation: projectFileOperations.uploadRead, + operationId: 'getProjectFileUpload', + summary: 'Get Project File Upload', + description: `Read the current state of a Project upload, including its file after completion. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Files'], + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge', 'UnsupportedMediaType'], + success: { description: 'Get Project File Upload result.', headers: RATE_LIMIT_HEADERS }, + }, + { + params: documentedSchema( + v2GetProjectFileUploadContract.params, + 'ProjectFileUploadParams', + 'Project upload identity', + 'The owning Project and, for control operations, the upload session.' + ), + query: v2GetProjectFileUploadContract.query, + headers: documentedSchema( + v2GetProjectFileUploadContract.headers, + 'ProjectUploadTokenHeaders', + 'Project upload control token', + 'The signed token issued at upload creation, in addition to the original API credential.' + ), + response: documentedSchema( + v2GetProjectFileUploadContract.response.schema, + 'V2ProjectFileUploadResponse', + 'Project file upload response', + 'The authorized upload state or transfer instructions.' + ), + } + ), + defineOpenApiRoute( + v2AbortProjectFileUploadContract, + { + applicationOperation: projectFileOperations.uploadCancel, + operationId: 'abortProjectFileUpload', + summary: 'Abort Project File Upload', + description: `Abort a pending Project upload and schedule its unregistered bytes for cleanup. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Files'], + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge', 'UnsupportedMediaType'], + success: { description: 'Abort Project File Upload result.', headers: RATE_LIMIT_HEADERS }, + }, + { + params: documentedSchema( + v2AbortProjectFileUploadContract.params, + 'ProjectFileUploadParams', + 'Project upload identity', + 'The owning Project and, for control operations, the upload session.' + ), + query: v2AbortProjectFileUploadContract.query, + headers: documentedSchema( + v2AbortProjectFileUploadContract.headers, + 'ProjectUploadTokenHeaders', + 'Project upload control token', + 'The signed token issued at upload creation, in addition to the original API credential.' + ), + response: documentedSchema( + v2AbortProjectFileUploadContract.response.schema, + 'V2ProjectFileUploadResponse', + 'Project file upload response', + 'The authorized upload state or transfer instructions.' + ), + } + ), + defineOpenApiRoute( + v2CompleteProjectFileUploadContract, + { + applicationOperation: projectFileOperations.uploadComplete, + operationId: 'completeProjectFileUpload', + summary: 'Complete Project File Upload', + description: `Finalize verified bytes and atomically register one Project file. Retrying completion returns the same file without billing twice. Current edit access is checked again. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Files'], + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge', 'UnsupportedMediaType'], + success: { description: 'Complete Project File Upload result.', headers: RATE_LIMIT_HEADERS }, + }, + { + params: documentedSchema( + v2CompleteProjectFileUploadContract.params, + 'ProjectFileUploadParams', + 'Project upload identity', + 'The owning Project and, for control operations, the upload session.' + ), + query: v2CompleteProjectFileUploadContract.query, + headers: documentedSchema( + v2CompleteProjectFileUploadContract.headers, + 'ProjectUploadTokenHeaders', + 'Project upload control token', + 'The signed token issued at upload creation, in addition to the original API credential.' + ), + response: documentedSchema( + v2CompleteProjectFileUploadContract.response.schema, + 'V2ProjectFileUploadResponse', + 'Project file upload response', + 'The authorized upload state or transfer instructions.' + ), + } + ), + defineOpenApiRoute( + v2GetProjectFileUploadPartUrlsContract, + { + applicationOperation: projectFileOperations.uploadParts, + operationId: 'getProjectFileUploadPartUrls', + summary: 'Get Project File Upload Part URLs', + description: `Request signed multipart transfer URLs for an active Project upload. Send exactly the returned transfer headers when uploading each part. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Files'], + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge', 'UnsupportedMediaType'], + success: { + description: 'Get Project File Upload Part URLs result.', + headers: RATE_LIMIT_HEADERS, + }, + }, + { + params: documentedSchema( + v2GetProjectFileUploadPartUrlsContract.params, + 'ProjectFileUploadParams', + 'Project upload identity', + 'The owning Project and, for control operations, the upload session.' + ), + query: v2GetProjectFileUploadPartUrlsContract.query, + headers: documentedSchema( + v2GetProjectFileUploadPartUrlsContract.headers, + 'ProjectUploadTokenHeaders', + 'Project upload control token', + 'The signed token issued at upload creation, in addition to the original API credential.' + ), + body: documentedSchema( + v2GetProjectFileUploadPartUrlsContract.body, + 'ProjectFileUploadPartUrlsRequest', + 'Get Project File Upload Part URLs request', + 'The parameters for this upload operation.' + ), + response: documentedSchema( + v2GetProjectFileUploadPartUrlsContract.response.schema, + 'V2ProjectFileUploadPartUrlsResponse', + 'Get Project File Upload Part URLs response', + 'The authorized upload state or transfer instructions.' + ), + } + ), +] diff --git a/apps/sim/lib/api/contracts/v2/openapi/project-file-versions.ts b/apps/sim/lib/api/contracts/v2/openapi/project-file-versions.ts new file mode 100644 index 00000000000..e843601dbee --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/openapi/project-file-versions.ts @@ -0,0 +1,172 @@ +import { + documentedSchema, + RATE_LIMIT_HEADERS, + RESOURCE_CONFLICT_ERRORS, + RESOURCE_ERRORS, + WORKSPACE_API_KEY_DENIED, +} from '@/lib/api/contracts/v2/openapi/shared' +import { + v2DeleteProjectFileVersionContract, + v2GetProjectFileVersionContract, + v2ListProjectFileVersionsContract, + v2ReadProjectFileVersionContentContract, + v2RevertProjectFileVersionContract, +} from '@/lib/api/contracts/v2/project-file-versions' +import { defineOpenApiRoute } from '@/lib/api/openapi/types' +import { projectFileOperations } from '@/lib/projects/files/application/operations' + +export const projectFileVersionOpenApiRoutes = [ + defineOpenApiRoute( + v2ListProjectFileVersionsContract, + { + applicationOperation: projectFileOperations.listVersions, + operationId: 'listProjectFileVersions', + summary: 'List Project File Versions', + description: `List recorded versions of a shared Project file, newest first by default. Retention follows the Project payer and preserves the newest ten versions; removed versions leave gaps in numbering. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Files'], + errors: RESOURCE_ERRORS, + success: { + description: 'A cursor-paginated page of Project file versions.', + headers: RATE_LIMIT_HEADERS, + }, + }, + { + params: documentedSchema( + v2ListProjectFileVersionsContract.params, + 'ProjectFileParams', + 'Project file identity', + 'The owning Project and the file identifier.' + ), + query: documentedSchema( + v2ListProjectFileVersionsContract.query, + 'ListProjectFileVersionsQuery', + 'List Project file versions query', + 'Sort direction and cursor pagination within this file history.' + ), + response: documentedSchema( + v2ListProjectFileVersionsContract.response.schema, + 'V2ProjectFileVersionListResponse', + 'List Project File Versions response', + 'A cursor-paginated page of Project file versions.' + ), + } + ), + defineOpenApiRoute( + v2GetProjectFileVersionContract, + { + applicationOperation: projectFileOperations.readVersion, + operationId: 'getProjectFileVersion', + summary: 'Get Project File Version', + description: `Get metadata and author attribution for a recorded Project file version. Missing or permanently removed versions return \`404\`. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Files'], + errors: RESOURCE_ERRORS, + success: { description: 'Metadata for the selected version.', headers: RATE_LIMIT_HEADERS }, + }, + { + params: documentedSchema( + v2GetProjectFileVersionContract.params, + 'ProjectFileVersionParams', + 'Project file version identity', + 'The Project, file, and selected version when applicable.' + ), + query: v2GetProjectFileVersionContract.query, + response: documentedSchema( + v2GetProjectFileVersionContract.response.schema, + 'V2ProjectFileVersionResponse', + 'Get Project File Version response', + 'Metadata for the selected version.' + ), + } + ), + defineOpenApiRoute( + v2ReadProjectFileVersionContentContract, + { + applicationOperation: projectFileOperations.readVersionContent, + operationId: 'readProjectFileVersionContent', + summary: 'Read Project File Version Content', + description: `Read the stored source bytes of a Project file version using the file’s current name. Generated documents return their editable source, rather than a compiled Office or page export. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Files'], + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], + success: { + description: 'The selected version’s stored source bytes.', + headers: RATE_LIMIT_HEADERS, + contentTypes: ['application/octet-stream'], + }, + }, + { + params: documentedSchema( + v2ReadProjectFileVersionContentContract.params, + 'ProjectFileVersionParams', + 'Project file version identity', + 'The Project, file, and selected version when applicable.' + ), + query: v2ReadProjectFileVersionContentContract.query, + } + ), + defineOpenApiRoute( + v2RevertProjectFileVersionContract, + { + applicationOperation: projectFileOperations.revertVersion, + operationId: 'revertProjectFileVersion', + summary: 'Revert Project File Version', + description: `Make an earlier version current by recording its source bytes as a new revert version. Reverting to the current version is a no-op. Use expectedRevision to reject changes made since the last read; a stale revision or concurrent edit returns \`409\`. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Files'], + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], + success: { + description: 'The file and current version after the revert.', + headers: RATE_LIMIT_HEADERS, + }, + }, + { + params: documentedSchema( + v2RevertProjectFileVersionContract.params, + 'ProjectFileVersionParams', + 'Project file version identity', + 'The Project, file, and selected version when applicable.' + ), + query: v2RevertProjectFileVersionContract.query, + body: documentedSchema( + v2RevertProjectFileVersionContract.body, + 'RevertProjectFileVersionRequest', + 'Revert Project file version request', + 'Optional revision or current-version preconditions.' + ), + response: documentedSchema( + v2RevertProjectFileVersionContract.response.schema, + 'V2ProjectFileVersionRevertResponse', + 'Revert Project File Version response', + 'The file and current version after the revert.' + ), + } + ), + defineOpenApiRoute( + v2DeleteProjectFileVersionContract, + { + applicationOperation: projectFileOperations.deleteVersion, + operationId: 'deleteProjectFileVersion', + summary: 'Delete Project File Version', + description: `Permanently remove one superseded Project file version from history. Deleting the current version returns \`409\`; other versions and the current file remain available. Stored-object cleanup is retried asynchronously when needed. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Files'], + errors: RESOURCE_CONFLICT_ERRORS, + success: { + description: 'Deletion acknowledgement for the superseded version.', + headers: RATE_LIMIT_HEADERS, + }, + }, + { + params: documentedSchema( + v2DeleteProjectFileVersionContract.params, + 'ProjectFileVersionParams', + 'Project file version identity', + 'The Project, file, and selected version when applicable.' + ), + query: v2DeleteProjectFileVersionContract.query, + response: documentedSchema( + v2DeleteProjectFileVersionContract.response.schema, + 'V2ProjectFileVersionDeleteResponse', + 'Delete Project File Version response', + 'Deletion acknowledgement for the superseded version.' + ), + } + ), +] diff --git a/apps/sim/lib/api/contracts/v2/openapi/project-files.ts b/apps/sim/lib/api/contracts/v2/openapi/project-files.ts new file mode 100644 index 00000000000..3588768689b --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/openapi/project-files.ts @@ -0,0 +1,387 @@ +import { + documentedSchema, + RATE_LIMIT_HEADERS, + RESOURCE_CONFLICT_ERRORS, + RESOURCE_ERRORS, + WORKSPACE_API_KEY_DENIED, +} from '@/lib/api/contracts/v2/openapi/shared' +import { v2UnzipProjectFileContract } from '@/lib/api/contracts/v2/project-file-extraction' +import { + v2ArchiveProjectFileItemsContract, + v2MoveProjectFileItemsContract, + v2RenameProjectFileContract, + v2RestoreProjectFileContract, +} from '@/lib/api/contracts/v2/project-file-lifecycle' +import { v2SearchProjectFileContentContract } from '@/lib/api/contracts/v2/project-file-search' +import { + v2CreateProjectFileContract, + v2GetProjectFileMetadataContract, + v2ListProjectFilesContract, + v2ReadProjectFileContentContract, + v2UpdateProjectFileContentContract, +} from '@/lib/api/contracts/v2/project-files' +import { defineOpenApiRoute } from '@/lib/api/openapi/types' +import { projectFileOperations } from '@/lib/projects/files/application/operations' + +export const projectFileOpenApiRoutes = [ + defineOpenApiRoute( + v2UnzipProjectFileContract, + { + applicationOperation: projectFileOperations.extractArchive, + operationId: 'unzipProjectFile', + summary: 'Unzip Project File', + description: `Extract a ZIP archive into a new sibling folder in the same Project. Use List Project Files to inspect its contents. Concurrent extraction of the same archive returns \`409\`; size or processing-time limits return \`413\`. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Files'], + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], + success: { + description: 'Counts and destination folder for the unpacked archive.', + headers: RATE_LIMIT_HEADERS, + }, + }, + { + params: documentedSchema( + v2UnzipProjectFileContract.params, + 'ProjectFileParams', + 'Project file identity', + 'The owning Project and the file identifier.' + ), + query: v2UnzipProjectFileContract.query, + response: documentedSchema( + v2UnzipProjectFileContract.response.schema, + 'V2ProjectFileUnzipResponse', + 'Unzip Project file response', + 'Counts and destination folder for the unpacked archive.' + ), + } + ), + defineOpenApiRoute( + v2SearchProjectFileContentContract, + { + applicationOperation: projectFileOperations.searchContent, + operationId: 'searchProjectFileContent', + summary: 'Search Project File Content', + description: `Search indexed text in active Project files, returning matching lines with file IDs and line numbers. Folder filters narrow both results and reported coverage. Missing matches are inconclusive when complete is false, or skippedFiles or partialFiles is nonzero. truncated means additional matches exist beyond maxResults. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Files'], + errors: [...RESOURCE_CONFLICT_ERRORS, 'Locked'], + success: { + description: 'Matching lines and the index coverage they were drawn from.', + headers: RATE_LIMIT_HEADERS, + }, + }, + { + params: documentedSchema( + v2SearchProjectFileContentContract.params, + 'ProjectFilesParams', + 'Project file collection', + 'The Project that owns the requested files.' + ), + query: documentedSchema( + v2SearchProjectFileContentContract.query, + 'SearchProjectFileContentQuery', + 'Project file search query', + 'The search text, mode, result limit, and optional folder scope.' + ), + response: documentedSchema( + v2SearchProjectFileContentContract.response.schema, + 'V2ProjectFileSearchResultsResponse', + 'File search results response', + 'Matching lines from indexed file content.' + ), + } + ), + defineOpenApiRoute( + v2RenameProjectFileContract, + { + applicationOperation: projectFileOperations.rename, + operationId: 'renameProjectFile', + summary: 'Rename Project File', + description: `Rename a shared Project file while retaining its identity and history. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Files'], + errors: RESOURCE_CONFLICT_ERRORS, + success: { description: 'Metadata for the renamed file.', headers: RATE_LIMIT_HEADERS }, + }, + { + params: documentedSchema( + v2RenameProjectFileContract.params, + 'ProjectFileParams', + 'Project file identity', + 'The owning Project and the file identifier.' + ), + query: v2RenameProjectFileContract.query, + body: documentedSchema( + v2RenameProjectFileContract.body, + 'RenameProjectFileRequest', + 'Rename Project File request', + 'The new file name.' + ), + response: documentedSchema( + v2RenameProjectFileContract.response.schema, + 'V2ProjectFileMetadataResponse', + 'Project file metadata response', + 'Metadata for one authorized Project file.' + ), + } + ), + defineOpenApiRoute( + v2MoveProjectFileItemsContract, + { + applicationOperation: projectFileOperations.moveItems, + operationId: 'moveProjectFileItems', + summary: 'Move Project File Items', + description: `Move selected files and folders into an existing folder within the same Project. Folder contents move with their parent. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Files'], + errors: RESOURCE_CONFLICT_ERRORS, + success: { + description: 'Counts and identifiers of moved items.', + headers: RATE_LIMIT_HEADERS, + }, + }, + { + params: documentedSchema( + v2MoveProjectFileItemsContract.params, + 'ProjectFilesParams', + 'Project file collection', + 'The Project that owns the requested files.' + ), + query: v2MoveProjectFileItemsContract.query, + body: documentedSchema( + v2MoveProjectFileItemsContract.body, + 'MoveProjectFileItemsRequest', + 'Move Project File Items request', + 'Selected files and folders and their destination.' + ), + response: documentedSchema( + v2MoveProjectFileItemsContract.response.schema, + 'V2MoveProjectFileItemsResponse', + 'Project file operation response', + 'Counts and identifiers of moved items.' + ), + } + ), + defineOpenApiRoute( + v2ArchiveProjectFileItemsContract, + { + applicationOperation: projectFileOperations.archiveItems, + operationId: 'archiveProjectFileItems', + summary: 'Archive Project File Items', + description: `Archive selected files and folders. Folder contents are archived recursively and remain recoverable until retention removes them. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Files'], + errors: RESOURCE_CONFLICT_ERRORS, + success: { + description: 'Counts and identifiers of archived items.', + headers: RATE_LIMIT_HEADERS, + }, + }, + { + params: documentedSchema( + v2ArchiveProjectFileItemsContract.params, + 'ProjectFilesParams', + 'Project file collection', + 'The Project that owns the requested files.' + ), + query: v2ArchiveProjectFileItemsContract.query, + body: documentedSchema( + v2ArchiveProjectFileItemsContract.body, + 'ArchiveProjectFileItemsRequest', + 'Archive Project File Items request', + 'Files and folders to archive.' + ), + response: documentedSchema( + v2ArchiveProjectFileItemsContract.response.schema, + 'V2ArchiveProjectFileItemsResponse', + 'Project file operation response', + 'Counts and identifiers of archived items.' + ), + } + ), + defineOpenApiRoute( + v2RestoreProjectFileContract, + { + applicationOperation: projectFileOperations.restore, + operationId: 'restoreProjectFile', + summary: 'Restore Project File', + description: `Restore an archived Project file. If its former folder is unavailable, restore it to the Project root with an available name. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Files'], + errors: RESOURCE_CONFLICT_ERRORS, + success: { description: 'Metadata for the restored file.', headers: RATE_LIMIT_HEADERS }, + }, + { + params: documentedSchema( + v2RestoreProjectFileContract.params, + 'ProjectFileParams', + 'Project file identity', + 'The owning Project and the file identifier.' + ), + query: v2RestoreProjectFileContract.query, + body: documentedSchema( + v2RestoreProjectFileContract.body, + 'RestoreProjectFileRequest', + 'Restore Project file request', + 'An empty object; the file is identified by its path parameters.' + ), + response: documentedSchema( + v2RestoreProjectFileContract.response.schema, + 'V2ProjectFileMetadataResponse', + 'Project file metadata response', + 'Metadata for one authorized Project file.' + ), + } + ), + + defineOpenApiRoute( + v2CreateProjectFileContract, + { + applicationOperation: projectFileOperations.create, + operationId: 'createProjectFile', + summary: 'Create Project File', + description: `Create a shared Project file from inline text or base64 bytes. Names are exact; an existing sibling name returns a conflict. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Files'], + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge', 'UnsupportedMediaType'], + success: { + description: 'The created file and its content revision.', + headers: RATE_LIMIT_HEADERS, + }, + }, + { + params: documentedSchema( + v2CreateProjectFileContract.params, + 'ProjectFilesParams', + 'Project file collection', + 'The Project that owns the requested files.' + ), + query: v2CreateProjectFileContract.query, + body: documentedSchema( + v2CreateProjectFileContract.body, + 'CreateProjectFileRequest', + 'Create Project file request', + 'Name, content, encoding, and containing folder within the Project.' + ), + response: documentedSchema( + v2CreateProjectFileContract.response.schema, + 'V2ProjectFileMetadataResponse', + 'Project file metadata response', + 'Metadata for one authorized Project file.' + ), + } + ), + defineOpenApiRoute( + v2ReadProjectFileContentContract, + { + applicationOperation: projectFileOperations.readContent, + operationId: 'readProjectFileContent', + summary: 'Read Project File Source', + description: `Read the stored source bytes of a Project file. Generated documents return their generation source; rendered downloads are separate. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Files'], + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge'], + success: { + description: 'The current stored content bytes.', + headers: ['Content-Type', 'Content-Disposition', 'Content-Length'], + contentTypes: ['application/octet-stream'], + }, + }, + { + params: documentedSchema( + v2ReadProjectFileContentContract.params, + 'ProjectFileParams', + 'Project file identity', + 'The owning Project and the file identifier.' + ), + query: v2ReadProjectFileContentContract.query, + } + ), + defineOpenApiRoute( + v2UpdateProjectFileContentContract, + { + applicationOperation: projectFileOperations.updateContent, + operationId: 'updateProjectFileContent', + summary: 'Replace Project File Content', + description: `Replace the complete content of a Project file. Supply expectedRevision to reject a stale edit with 409. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Files'], + errors: [...RESOURCE_CONFLICT_ERRORS, 'PayloadTooLarge', 'UnsupportedMediaType'], + success: { + description: 'The updated file and its content revision.', + headers: RATE_LIMIT_HEADERS, + }, + }, + { + params: documentedSchema( + v2UpdateProjectFileContentContract.params, + 'ProjectFileParams', + 'Project file identity', + 'The owning Project and the file identifier.' + ), + query: v2UpdateProjectFileContentContract.query, + body: documentedSchema( + v2UpdateProjectFileContentContract.body, + 'UpdateProjectFileContentRequest', + 'Project content replacement', + 'Complete replacement bytes and an optional optimistic concurrency revision.' + ), + response: documentedSchema( + v2UpdateProjectFileContentContract.response.schema, + 'V2ProjectFileMetadataResponse', + 'Project file metadata response', + 'Metadata for one authorized Project file.' + ), + } + ), + defineOpenApiRoute( + v2ListProjectFilesContract, + { + applicationOperation: projectFileOperations.list, + operationId: 'listProjectFiles', + summary: 'List Project Files', + description: `List shared Project files with cursor pagination. Use scope=archived to find archived files. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Files'], + errors: RESOURCE_ERRORS, + success: { description: 'A page of Project files.', headers: RATE_LIMIT_HEADERS }, + }, + { + params: documentedSchema( + v2ListProjectFilesContract.params, + 'ProjectFilesParams', + 'Project file collection', + 'The Project that owns the requested files.' + ), + query: documentedSchema( + v2ListProjectFilesContract.query, + 'ListProjectFilesQuery', + 'Project file list query', + 'Filters, sorting, and pagination within one Project.' + ), + response: documentedSchema( + v2ListProjectFilesContract.response.schema, + 'V2ProjectFileListResponse', + 'Project file list response', + 'A page of files owned by the requested Project.' + ), + } + ), + defineOpenApiRoute( + v2GetProjectFileMetadataContract, + { + applicationOperation: projectFileOperations.readMetadata, + operationId: 'getProjectFileMetadata', + summary: 'Get Project File Metadata', + description: `Get an active file's metadata and Project ownership. ${WORKSPACE_API_KEY_DENIED}`, + tags: ['Files'], + errors: RESOURCE_ERRORS, + success: { description: 'Project file metadata.', headers: RATE_LIMIT_HEADERS }, + }, + { + params: documentedSchema( + v2GetProjectFileMetadataContract.params, + 'ProjectFileParams', + 'Project file identity', + 'The owning Project and the file identifier.' + ), + query: v2GetProjectFileMetadataContract.query, + response: documentedSchema( + v2GetProjectFileMetadataContract.response.schema, + 'V2ProjectFileMetadataResponse', + 'Project file metadata response', + 'Metadata for one authorized Project file.' + ), + } + ), +] as const diff --git a/apps/sim/lib/api/contracts/v2/project-file-downloads.ts b/apps/sim/lib/api/contracts/v2/project-file-downloads.ts new file mode 100644 index 00000000000..57033617102 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/project-file-downloads.ts @@ -0,0 +1,46 @@ +import { z } from 'zod' +import { noInputSchema } from '@/lib/api/contracts/primitives' +import { exportProjectFileSnapshotBodySchema } from '@/lib/api/contracts/project-file-downloads' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { v2QuerySelectionListSchema } from '@/lib/api/contracts/v2/files' +import { + v2ProjectFileParamsSchema, + v2ProjectFilesParamsSchema, +} from '@/lib/api/contracts/v2/project-files' +import { MAX_ZIP_DOWNLOAD_FILES } from '@/lib/workspace-files/limits' + +export const v2DownloadProjectFileItemsQuerySchema = z + .object({ + fileIds: v2QuerySelectionListSchema('fileIds').describe( + `File identifiers to include, comma-separated. At most ${MAX_ZIP_DOWNLOAD_FILES} entries.` + ), + folderIds: v2QuerySelectionListSchema('folderIds').describe( + `Folder identifiers to include recursively, comma-separated. The resolved selection allows at most ${MAX_ZIP_DOWNLOAD_FILES} files.` + ), + }) + .strict() +export type V2DownloadProjectFileItemsQuery = z.output + +export const v2DownloadProjectFileItemsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/projects/[projectId]/files/bulk-download', + params: v2ProjectFilesParamsSchema, + query: v2DownloadProjectFileItemsQuerySchema, + response: { mode: 'binary' }, +}) + +export const v2ExportProjectFileSnapshotBodySchema = exportProjectFileSnapshotBodySchema.extend({ + content: exportProjectFileSnapshotBodySchema.shape.content.describe( + 'Visible Markdown snapshot to export. This does not replace the stored file or create a version.' + ), +}) +export type V2ExportProjectFileSnapshotBody = z.input + +export const v2ExportProjectFileSnapshotContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/projects/[projectId]/files/[fileId]/export', + params: v2ProjectFileParamsSchema, + query: noInputSchema, + body: v2ExportProjectFileSnapshotBodySchema, + response: { mode: 'binary' }, +}) diff --git a/apps/sim/lib/api/contracts/v2/project-file-extraction.ts b/apps/sim/lib/api/contracts/v2/project-file-extraction.ts new file mode 100644 index 00000000000..afae040004e --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/project-file-extraction.ts @@ -0,0 +1,21 @@ +import type { z } from 'zod' +import { noInputSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { v2UnzipFileDataSchema } from '@/lib/api/contracts/v2/files' +import { v2ProjectFileParamsSchema } from '@/lib/api/contracts/v2/project-files' +import { v2DataResponse } from '@/lib/api/contracts/v2/shared' + +export const v2UnzipProjectFileDataSchema = v2UnzipFileDataSchema.meta({ + id: 'V2ProjectFileUnzipResult', + title: 'Project unzip result', + description: 'Outcome of unzipping a Project archive into a sibling folder.', +}) +export type V2ProjectFileUnzipResult = z.output + +export const v2UnzipProjectFileContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/projects/[projectId]/files/[fileId]/unzip', + params: v2ProjectFileParamsSchema, + query: noInputSchema, + response: { mode: 'json', schema: v2DataResponse(v2UnzipProjectFileDataSchema) }, +}) diff --git a/apps/sim/lib/api/contracts/v2/project-file-folders.ts b/apps/sim/lib/api/contracts/v2/project-file-folders.ts new file mode 100644 index 00000000000..a923438bf80 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/project-file-folders.ts @@ -0,0 +1,119 @@ +import { z } from 'zod' +import { noInputSchema, nonEmptyIdSchema } from '@/lib/api/contracts/primitives' +import { + createProjectFileFolderBodySchema, + listProjectFileFoldersQuerySchema, + projectFileFolderRecordSchema, + updateProjectFileFolderBodySchema, +} from '@/lib/api/contracts/project-file-folders' +import { restoreProjectFileFolderResponseSchema } from '@/lib/api/contracts/project-file-lifecycle' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { v2ProjectFilesParamsSchema } from '@/lib/api/contracts/v2/project-files' +import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' + +export const v2ProjectFileFolderParamsSchema = v2ProjectFilesParamsSchema.extend({ + folderId: nonEmptyIdSchema.describe('Folder identifier within the Project.'), +}) +export type V2ProjectFileFolderParams = z.input + +export const v2ProjectFileFolderSchema = projectFileFolderRecordSchema.extend({ + id: nonEmptyIdSchema.describe('Stable folder identifier.'), + owner: projectFileFolderRecordSchema.shape.owner + .extend({ + entityType: z.literal('project').describe('The folder is owned by a Project.'), + entityId: nonEmptyIdSchema.describe('Identifier of the owning Project.'), + }) + .strict() + .describe('Canonical Project owner of the folder.'), + name: z.string().describe('Folder name.'), + parentId: nonEmptyIdSchema.nullable().describe('Parent folder identifier, or null at the root.'), + path: z.string().describe('Display path with slash characters in folder names escaped.'), + sortOrder: z.number().describe('Position within its parent folder.'), + userId: nonEmptyIdSchema.describe('Creator or successor identifier.'), + createdAt: z.iso.datetime().describe('Time the folder was created.'), + updatedAt: z.iso.datetime().describe('Time the folder was last changed.'), + deletedAt: z.iso.datetime().nullable().describe('Archive time, or null for an active folder.'), +}) +export type V2ProjectFileFolder = z.output + +export const v2ListProjectFileFoldersQuerySchema = listProjectFileFoldersQuerySchema + .extend({ + scope: listProjectFileFoldersQuerySchema.shape.scope.describe('Folder lifecycle scope.'), + }) + .strict() +export type V2ListProjectFileFoldersQuery = z.output + +export const v2CreateProjectFileFolderBodySchema = createProjectFileFolderBodySchema + .extend({ + name: createProjectFileFolderBodySchema.shape.name.describe('Name for the new folder.'), + parentId: nonEmptyIdSchema + .nullable() + .optional() + .describe('Parent folder identifier; omit or use null for the root.'), + }) + .strict() +export type V2CreateProjectFileFolderBody = z.input + +export const v2UpdateProjectFileFolderBodySchema = updateProjectFileFolderBodySchema + .extend({ + name: updateProjectFileFolderBodySchema.shape.name.describe( + 'New folder name; omit to leave unchanged.' + ), + parentId: nonEmptyIdSchema + .nullable() + .optional() + .describe( + 'New parent folder identifier; null moves to the root, omission leaves the parent unchanged.' + ), + sortOrder: updateProjectFileFolderBodySchema.shape.sortOrder.describe( + 'New manual position; omit to leave unchanged.' + ), + }) + .strict() +export type V2UpdateProjectFileFolderBody = z.input + +export const v2RestoreProjectFileFolderResponseSchema = + restoreProjectFileFolderResponseSchema.extend({ + folder: v2ProjectFileFolderSchema.describe('Restored Project folder.'), + }) +export type V2RestoreProjectFileFolderResponse = z.output< + typeof v2RestoreProjectFileFolderResponseSchema +> + +export const v2ListProjectFileFoldersContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/projects/[projectId]/files/folders', + params: v2ProjectFilesParamsSchema, + query: v2ListProjectFileFoldersQuerySchema, + response: { + mode: 'json', + schema: v2CursorListResponse(v2ProjectFileFolderSchema, { paged: false }), + }, +}) + +export const v2CreateProjectFileFolderContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/projects/[projectId]/files/folders', + params: v2ProjectFilesParamsSchema, + query: noInputSchema, + body: v2CreateProjectFileFolderBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2ProjectFileFolderSchema), status: 201 }, +}) + +export const v2UpdateProjectFileFolderContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/projects/[projectId]/files/folders/[folderId]', + params: v2ProjectFileFolderParamsSchema, + query: noInputSchema, + body: v2UpdateProjectFileFolderBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2ProjectFileFolderSchema) }, +}) + +export const v2RestoreProjectFileFolderContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/projects/[projectId]/files/folders/[folderId]/restore', + params: v2ProjectFileFolderParamsSchema, + query: noInputSchema, + body: noInputSchema, + response: { mode: 'json', schema: v2DataResponse(v2RestoreProjectFileFolderResponseSchema) }, +}) diff --git a/apps/sim/lib/api/contracts/v2/project-file-lifecycle.ts b/apps/sim/lib/api/contracts/v2/project-file-lifecycle.ts new file mode 100644 index 00000000000..68f782f1177 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/project-file-lifecycle.ts @@ -0,0 +1,77 @@ +import type { z } from 'zod' +import { noInputSchema } from '@/lib/api/contracts/primitives' +import { + archiveProjectFileItemsBodySchema, + archiveProjectFileItemsResponseSchema, + moveProjectFileItemsResponseSchema, +} from '@/lib/api/contracts/project-file-lifecycle' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { v2MoveFileItemsBodySchema, v2RenameFileBodySchema } from '@/lib/api/contracts/v2/files' +import { + v2ProjectFileParamsSchema, + v2ProjectFileSchema, + v2ProjectFilesParamsSchema, +} from '@/lib/api/contracts/v2/project-files' +import { v2DataResponse } from '@/lib/api/contracts/v2/shared' + +export const v2RenameProjectFileBodySchema = v2RenameFileBodySchema.omit({ workspaceId: true }) +export type V2RenameProjectFileBody = z.input + +export const v2ArchiveProjectFileItemsBodySchema = archiveProjectFileItemsBodySchema.safeExtend({ + fileIds: archiveProjectFileItemsBodySchema.shape.fileIds.describe( + 'Identifiers of the files to archive.' + ), + folderIds: archiveProjectFileItemsBodySchema.shape.folderIds.describe( + 'Identifiers of folders to archive recursively, including their files and descendants.' + ), +}) +export type V2ArchiveProjectFileItemsBody = z.input + +export const v2MoveProjectFileItemsBodySchema = archiveProjectFileItemsBodySchema.safeExtend({ + fileIds: archiveProjectFileItemsBodySchema.shape.fileIds.describe( + 'Identifiers of the files to move.' + ), + folderIds: archiveProjectFileItemsBodySchema.shape.folderIds.describe( + 'Identifiers of folders to move with their contents.' + ), + targetFolderPath: v2MoveFileItemsBodySchema.shape.targetFolderPath.describe( + 'Existing destination folder path within the Project. Omit to move items to the Project root.' + ), +}) +export type V2MoveProjectFileItemsBody = z.input + +export const v2RenameProjectFileContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/projects/[projectId]/files/[fileId]', + params: v2ProjectFileParamsSchema, + query: noInputSchema, + body: v2RenameProjectFileBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2ProjectFileSchema) }, +}) + +export const v2MoveProjectFileItemsContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/projects/[projectId]/files/move', + params: v2ProjectFilesParamsSchema, + query: noInputSchema, + body: v2MoveProjectFileItemsBodySchema, + response: { mode: 'json', schema: v2DataResponse(moveProjectFileItemsResponseSchema) }, +}) + +export const v2ArchiveProjectFileItemsContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/projects/[projectId]/files/archive', + params: v2ProjectFilesParamsSchema, + query: noInputSchema, + body: v2ArchiveProjectFileItemsBodySchema, + response: { mode: 'json', schema: v2DataResponse(archiveProjectFileItemsResponseSchema) }, +}) + +export const v2RestoreProjectFileContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/projects/[projectId]/files/[fileId]/restore', + params: v2ProjectFileParamsSchema, + query: noInputSchema, + body: noInputSchema, + response: { mode: 'json', schema: v2DataResponse(v2ProjectFileSchema) }, +}) diff --git a/apps/sim/lib/api/contracts/v2/project-file-search.ts b/apps/sim/lib/api/contracts/v2/project-file-search.ts new file mode 100644 index 00000000000..02ee7fdfb37 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/project-file-search.ts @@ -0,0 +1,25 @@ +import type { z } from 'zod' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v2FileSearchResultsSchema, + v2SearchFileContentQuerySchema, +} from '@/lib/api/contracts/v2/files' +import { v2ProjectFilesParamsSchema } from '@/lib/api/contracts/v2/project-files' +import { v2DataResponse } from '@/lib/api/contracts/v2/shared' + +export const v2SearchProjectFileContentQuerySchema = v2SearchFileContentQuerySchema + .omit({ workspaceId: true }) + .extend({ + folderPaths: v2SearchFileContentQuerySchema.shape.folderPaths.describe( + 'Comma-separated folder paths within the Project. Omit to search the entire Project; index coverage applies to the selected folders.' + ), + }) +export type V2SearchProjectFileContentQuery = z.output + +export const v2SearchProjectFileContentContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/projects/[projectId]/files/search', + params: v2ProjectFilesParamsSchema, + query: v2SearchProjectFileContentQuerySchema, + response: { mode: 'json', schema: v2DataResponse(v2FileSearchResultsSchema) }, +}) diff --git a/apps/sim/lib/api/contracts/v2/project-file-shares.ts b/apps/sim/lib/api/contracts/v2/project-file-shares.ts new file mode 100644 index 00000000000..d5dda56f280 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/project-file-shares.ts @@ -0,0 +1,39 @@ +import type { z } from 'zod' +import { noInputSchema } from '@/lib/api/contracts/primitives' +import { sharePasswordSchema } from '@/lib/api/contracts/public-shares' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v2FileShareSchema, + v2NullableFileShareSchema, + v2UpsertFileShareBodySchema, +} from '@/lib/api/contracts/v2/files' +import { v2ProjectFileParamsSchema } from '@/lib/api/contracts/v2/project-files' +import { v2DataResponse } from '@/lib/api/contracts/v2/shared' + +export const v2UpdateProjectFileShareBodySchema = v2UpsertFileShareBodySchema + .omit({ workspaceId: true }) + .extend({ + password: sharePasswordSchema + .optional() + .describe( + 'Literal password of 15 to 1024 characters. Kept when omitted; enabling password access without a supplied or stored password is rejected.' + ), + }) +export type V2UpdateProjectFileShareBody = z.input + +export const v2GetProjectFileShareContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/projects/[projectId]/files/[fileId]/share', + params: v2ProjectFileParamsSchema, + query: noInputSchema, + response: { mode: 'json', schema: v2DataResponse(v2NullableFileShareSchema) }, +}) + +export const v2UpdateProjectFileShareContract = defineRouteContract({ + method: 'PATCH', + path: '/api/v2/projects/[projectId]/files/[fileId]/share', + params: v2ProjectFileParamsSchema, + query: noInputSchema, + body: v2UpdateProjectFileShareBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2FileShareSchema) }, +}) diff --git a/apps/sim/lib/api/contracts/v2/project-file-uploads.ts b/apps/sim/lib/api/contracts/v2/project-file-uploads.ts new file mode 100644 index 00000000000..108c9403236 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/project-file-uploads.ts @@ -0,0 +1,98 @@ +import type { z } from 'zod' +import { noInputSchema, nonEmptyIdSchema } from '@/lib/api/contracts/primitives' +import { createProjectFileUploadBodySchema } from '@/lib/api/contracts/project-file-uploads' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { v2CreateFileUploadDataSchema, v2FileUploadSchema } from '@/lib/api/contracts/v2/files' +import { + v2ProjectFileSchema, + v2ProjectFilesParamsSchema, +} from '@/lib/api/contracts/v2/project-files' +import { v2DataResponse } from '@/lib/api/contracts/v2/shared' +import { + v2PartUrlsBodySchema, + v2PartUrlsDataSchema, + v2UploadTokenHeadersSchema, +} from '@/lib/api/contracts/v2/uploads' + +export const v2ProjectFileUploadParamsSchema = v2ProjectFilesParamsSchema.extend({ + uploadId: nonEmptyIdSchema.describe('Upload session identifier within the Project.'), +}) +export type V2ProjectFileUploadParams = z.input + +export const v2CreateProjectFileUploadBodySchema = createProjectFileUploadBodySchema.extend({ + folderId: createProjectFileUploadBodySchema.shape.folderId.describe( + 'Destination folder identifier; omit or use null for the Project root.' + ), + folderPath: createProjectFileUploadBodySchema.shape.folderPath.describe( + 'Canonical destination folder path. Specify either folderId or folderPath, not both.' + ), +}) +export type V2CreateProjectFileUploadBody = z.input + +export const v2ProjectFileUploadSchema = v2FileUploadSchema + .extend({ + file: v2ProjectFileSchema + .nullable() + .describe( + 'Registered Project file after finalization, or null before registration or after archival.' + ), + }) + .meta({ id: 'V2ProjectFileUpload', title: 'Project file upload session' }) +export type V2ProjectFileUpload = z.output + +export const v2CreateProjectFileUploadDataSchema = v2CreateFileUploadDataSchema + .extend({ + session: v2ProjectFileUploadSchema.describe('New Project upload session.'), + }) + .meta({ id: 'V2CreateProjectFileUploadData', title: 'Create Project file upload data' }) +export type V2CreateProjectFileUploadData = z.output + +export const v2CreateProjectFileUploadContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/projects/[projectId]/files/uploads', + params: v2ProjectFilesParamsSchema, + query: noInputSchema, + body: v2CreateProjectFileUploadBodySchema, + response: { + mode: 'json', + schema: v2DataResponse(v2CreateProjectFileUploadDataSchema), + status: 201, + }, +}) + +export const v2GetProjectFileUploadContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/projects/[projectId]/files/uploads/[uploadId]', + params: v2ProjectFileUploadParamsSchema, + query: noInputSchema, + headers: v2UploadTokenHeadersSchema, + response: { mode: 'json', schema: v2DataResponse(v2ProjectFileUploadSchema) }, +}) + +export const v2AbortProjectFileUploadContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/projects/[projectId]/files/uploads/[uploadId]', + params: v2ProjectFileUploadParamsSchema, + query: noInputSchema, + headers: v2UploadTokenHeadersSchema, + response: { mode: 'json', schema: v2DataResponse(v2ProjectFileUploadSchema) }, +}) + +export const v2CompleteProjectFileUploadContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/projects/[projectId]/files/uploads/[uploadId]/complete', + params: v2ProjectFileUploadParamsSchema, + query: noInputSchema, + headers: v2UploadTokenHeadersSchema, + response: { mode: 'json', schema: v2DataResponse(v2ProjectFileUploadSchema) }, +}) + +export const v2GetProjectFileUploadPartUrlsContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/projects/[projectId]/files/uploads/[uploadId]/parts', + params: v2ProjectFileUploadParamsSchema, + query: noInputSchema, + headers: v2UploadTokenHeadersSchema, + body: v2PartUrlsBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2PartUrlsDataSchema) }, +}) diff --git a/apps/sim/lib/api/contracts/v2/project-file-versions.ts b/apps/sim/lib/api/contracts/v2/project-file-versions.ts new file mode 100644 index 00000000000..5c6fb93d17c --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/project-file-versions.ts @@ -0,0 +1,66 @@ +import { z } from 'zod' +import { noInputSchema, versionNumberPathSchema } from '@/lib/api/contracts/primitives' +import { + deleteProjectFileVersionResponseSchema, + listProjectFileVersionsQuerySchema, + projectFileVersionSchema, + revertProjectFileVersionBodySchema, +} from '@/lib/api/contracts/project-file-versions' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { writtenFileRevisionSchema } from '@/lib/api/contracts/v2/files' +import { + v2ProjectFileParamsSchema, + v2ProjectFileSchema, +} from '@/lib/api/contracts/v2/project-files' +import { v2CursorListResponse, v2DataResponse } from '@/lib/api/contracts/v2/shared' + +export const v2ProjectFileVersionParamsSchema = v2ProjectFileParamsSchema.extend({ + version: versionNumberPathSchema.describe('Version number.'), +}) +export type V2ProjectFileVersionParams = z.input +export const v2RevertProjectFileVersionResultSchema = z.object({ + reverted: z.boolean().describe('False if the selected version is already current.'), + file: v2ProjectFileSchema, + version: projectFileVersionSchema.describe('The current version after the revert.'), + revision: writtenFileRevisionSchema, +}) +export type V2RevertProjectFileVersionResult = z.output< + typeof v2RevertProjectFileVersionResultSchema +> + +export const v2ListProjectFileVersionsContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/projects/[projectId]/files/[fileId]/versions', + params: v2ProjectFileParamsSchema, + query: listProjectFileVersionsQuerySchema, + response: { mode: 'json', schema: v2CursorListResponse(projectFileVersionSchema) }, +}) +export const v2GetProjectFileVersionContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/projects/[projectId]/files/[fileId]/versions/[version]', + params: v2ProjectFileVersionParamsSchema, + query: noInputSchema, + response: { mode: 'json', schema: v2DataResponse(projectFileVersionSchema) }, +}) +export const v2ReadProjectFileVersionContentContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/projects/[projectId]/files/[fileId]/versions/[version]/content', + params: v2ProjectFileVersionParamsSchema, + query: noInputSchema, + response: { mode: 'binary' }, +}) +export const v2RevertProjectFileVersionContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/projects/[projectId]/files/[fileId]/versions/[version]/revert', + params: v2ProjectFileVersionParamsSchema, + query: noInputSchema, + body: revertProjectFileVersionBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2RevertProjectFileVersionResultSchema) }, +}) +export const v2DeleteProjectFileVersionContract = defineRouteContract({ + method: 'DELETE', + path: '/api/v2/projects/[projectId]/files/[fileId]/versions/[version]', + params: v2ProjectFileVersionParamsSchema, + query: noInputSchema, + response: { mode: 'json', schema: v2DataResponse(deleteProjectFileVersionResponseSchema) }, +}) diff --git a/apps/sim/lib/api/contracts/v2/project-files.ts b/apps/sim/lib/api/contracts/v2/project-files.ts new file mode 100644 index 00000000000..3f7d7efc596 --- /dev/null +++ b/apps/sim/lib/api/contracts/v2/project-files.ts @@ -0,0 +1,124 @@ +import { z } from 'zod' +import { isCanonicalBase64, noInputSchema, nonEmptyIdSchema } from '@/lib/api/contracts/primitives' +import { projectFileRecordSchema } from '@/lib/api/contracts/project-files' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v2CreateFileBodySchema, + v2FileSchema, + v2ListFilesQuerySchema, + v2UpdateFileContentBodySchema, + writtenFileRevisionSchema, +} from '@/lib/api/contracts/v2/files' +import { + v2CursorListResponse, + v2DataResponse, + v2PaginationFields, +} from '@/lib/api/contracts/v2/shared' + +export const v2ProjectFilesParamsSchema = z.object({ + projectId: nonEmptyIdSchema.describe('Project identifier.'), +}) +export type V2ProjectFilesParams = z.input + +export const v2ProjectFileParamsSchema = v2ProjectFilesParamsSchema.extend({ + fileId: nonEmptyIdSchema.describe('File identifier within the Project.'), +}) +export type V2ProjectFileParams = z.input + +export const v2ProjectFileSchema = v2FileSchema + .omit({ uploadedByEmail: true, webUrl: true }) + .extend({ + owner: projectFileRecordSchema.shape.owner + .extend({ + entityType: z.literal('project').describe('The file owner is a Project.'), + entityId: nonEmptyIdSchema.describe('Identifier of the owning Project.'), + }) + .describe('Canonical owner of the shared file.'), + uploadedBy: nonEmptyIdSchema.describe('Creator or successor identifier.'), + revision: writtenFileRevisionSchema, + folderPath: v2FileSchema.shape.folderPath.describe( + 'Canonical containing-folder path. `/` is the Project root.' + ), + }) + .meta({ id: 'V2ProjectFile', title: 'Project file' }) +export type V2ProjectFile = z.output + +export const v2ListProjectFilesQuerySchema = v2ListFilesQuerySchema + .omit({ workspaceId: true }) + .extend(v2PaginationFields({ max: 1000, fallback: 100, description: 'Maximum files per page.' })) +export type V2ListProjectFilesQuery = z.output + +export const v2ListProjectFilesContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/projects/[projectId]/files', + params: v2ProjectFilesParamsSchema, + query: v2ListProjectFilesQuerySchema, + response: { mode: 'json', schema: v2CursorListResponse(v2ProjectFileSchema) }, +}) + +export const v2GetProjectFileMetadataContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/projects/[projectId]/files/[fileId]/metadata', + params: v2ProjectFileParamsSchema, + query: noInputSchema, + response: { mode: 'json', schema: v2DataResponse(v2ProjectFileSchema) }, +}) + +function validateContentEncoding( + value: { content: string; encoding: 'utf-8' | 'base64' }, + context: z.RefinementCtx +) { + if (value.encoding === 'base64' && !isCanonicalBase64(value.content)) { + context.addIssue({ code: 'custom', path: ['content'], message: 'content must be valid base64' }) + } +} + +export const v2CreateProjectFileBodySchema = z + .object({ + name: v2CreateFileBodySchema.shape.name, + contentType: v2CreateFileBodySchema.shape.contentType, + content: v2CreateFileBodySchema.shape.content, + encoding: v2CreateFileBodySchema.shape.encoding, + folderPath: v2CreateFileBodySchema.shape.folderPath.describe( + 'Canonical containing-folder path. Omit for the Project root.' + ), + }) + .strict() + .superRefine(validateContentEncoding) +export type V2CreateProjectFileBody = z.input + +export const v2UpdateProjectFileContentBodySchema = z + .object({ + content: v2UpdateFileContentBodySchema.shape.content, + encoding: v2UpdateFileContentBodySchema.shape.encoding, + expectedRevision: v2UpdateFileContentBodySchema.shape.expectedRevision, + }) + .strict() + .superRefine(validateContentEncoding) +export type V2UpdateProjectFileContentBody = z.input + +export const v2CreateProjectFileContract = defineRouteContract({ + method: 'POST', + path: '/api/v2/projects/[projectId]/files', + params: v2ProjectFilesParamsSchema, + query: noInputSchema, + body: v2CreateProjectFileBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2ProjectFileSchema), status: 201 }, +}) + +export const v2ReadProjectFileContentContract = defineRouteContract({ + method: 'GET', + path: '/api/v2/projects/[projectId]/files/[fileId]/content', + params: v2ProjectFileParamsSchema, + query: noInputSchema, + response: { mode: 'binary' }, +}) + +export const v2UpdateProjectFileContentContract = defineRouteContract({ + method: 'PUT', + path: '/api/v2/projects/[projectId]/files/[fileId]/content', + params: v2ProjectFileParamsSchema, + query: noInputSchema, + body: v2UpdateProjectFileContentBodySchema, + response: { mode: 'json', schema: v2DataResponse(v2ProjectFileSchema) }, +}) diff --git a/apps/sim/lib/api/contracts/workspace-file-versions.ts b/apps/sim/lib/api/contracts/workspace-file-versions.ts new file mode 100644 index 00000000000..98b1f258bd7 --- /dev/null +++ b/apps/sim/lib/api/contracts/workspace-file-versions.ts @@ -0,0 +1,52 @@ +import { z } from 'zod' +import { noInputSchema, versionNumberPathSchema } from '@/lib/api/contracts/primitives' +import { defineRouteContract } from '@/lib/api/contracts/types' +import { + v2FileVersionSchema, + v2ListFileVersionsQuerySchema, + v2RevertFileVersionBodySchema, +} from '@/lib/api/contracts/v2/file-versions' +import { writtenFileRevisionSchema } from '@/lib/api/contracts/v2/files' +import { workspaceFileParamsSchema } from '@/lib/api/contracts/workspace-files' + +const workspaceFileVersionParamsSchema = workspaceFileParamsSchema.extend({ + version: versionNumberPathSchema, +}) +const listWorkspaceFileVersionsQuerySchema = v2ListFileVersionsQuerySchema.omit({ + workspaceId: true, +}) +const listWorkspaceFileVersionsResponseSchema = z.object({ + versions: z.array(v2FileVersionSchema), + revision: writtenFileRevisionSchema, + nextCursor: z.string().nullable(), +}) +const revertWorkspaceFileVersionBodySchema = v2RevertFileVersionBodySchema + .omit({ workspaceId: true }) + .extend({ expectedRevision: z.string().min(1).max(1024).optional() }) +const revertWorkspaceFileVersionResponseSchema = z.object({ + reverted: z.boolean(), + revision: writtenFileRevisionSchema, +}) + +export const listWorkspaceFileVersionsContract = defineRouteContract({ + method: 'GET', + path: '/api/workspaces/[id]/files/[fileId]/versions', + params: workspaceFileParamsSchema, + query: listWorkspaceFileVersionsQuerySchema, + response: { mode: 'json', schema: listWorkspaceFileVersionsResponseSchema }, +}) +export const downloadWorkspaceFileVersionContract = defineRouteContract({ + method: 'GET', + path: '/api/workspaces/[id]/files/[fileId]/versions/[version]/content', + params: workspaceFileVersionParamsSchema, + query: noInputSchema, + response: { mode: 'binary' }, +}) +export const revertWorkspaceFileVersionContract = defineRouteContract({ + method: 'POST', + path: '/api/workspaces/[id]/files/[fileId]/versions/[version]/revert', + params: workspaceFileVersionParamsSchema, + query: noInputSchema, + body: revertWorkspaceFileVersionBodySchema, + response: { mode: 'json', schema: revertWorkspaceFileVersionResponseSchema }, +}) diff --git a/apps/sim/lib/api/contracts/workspace-files.ts b/apps/sim/lib/api/contracts/workspace-files.ts index 22453430e7e..5f5b3dc1aac 100644 --- a/apps/sim/lib/api/contracts/workspace-files.ts +++ b/apps/sim/lib/api/contracts/workspace-files.ts @@ -182,8 +182,6 @@ export const extractWorkspaceFileResponseSchema = workspaceFileSuccessSchema.ext skippedCount: z.number().int().nonnegative(), }) -export type ExtractWorkspaceFileResponse = z.output - export const listWorkspaceFilesContract = defineRouteContract({ method: 'GET', path: '/api/workspaces/[id]/files', diff --git a/apps/sim/lib/api/list-query.ts b/apps/sim/lib/api/list-query.ts index 91a35c8e1ec..e9c38ef9029 100644 --- a/apps/sim/lib/api/list-query.ts +++ b/apps/sim/lib/api/list-query.ts @@ -65,7 +65,7 @@ export interface KeysetKey { } /** A text key — names, titles, ids. */ -export function textKey(column: Column, read: (row: Row) => string): KeysetKey { +export function textKey(column: SQLWrapper, read: (row: Row) => string): KeysetKey { return { expr: column, encode: read, @@ -163,9 +163,15 @@ export function sortDirection(order: ListSortOrder): typeof asc { * On a paginated list these are the keyset's keys; on a single-page list they * are just the sort plus its tiebreaker. */ -export function listOrderBy(keys: readonly SQLWrapper[], order: ListSortOrder): SQL[] { - const direction = sortDirection(order) - return keys.map((key) => direction(key)) +export function listOrderBy( + keys: readonly SQLWrapper[], + order: ListSortOrder | readonly ListSortOrder[] +): SQL[] { + if (typeof order !== 'string' && order.length !== keys.length) + throw new Error('Sort directions must match key columns') + return keys.map((key, index) => + sortDirection(typeof order === 'string' ? order : order[index])(key) + ) } /** The `expr` of each keyset key, for `ORDER BY`. */ @@ -240,9 +246,10 @@ export function keysetPage( export function keysetAfter( keys: readonly KeysetKey[], values: CursorKey[], - order: ListSortOrder + order: ListSortOrder | readonly ListSortOrder[] ): SQL | null { - if (values.length !== keys.length) return null + if (values.length !== keys.length || (typeof order !== 'string' && order.length !== keys.length)) + return null const bound: SQL[] = [] for (const [i, key] of keys.entries()) { @@ -251,9 +258,13 @@ export function keysetAfter( bound.push(value) } - const beyond = order === 'asc' ? gt : lt - const clauses = keys.map((key, i) => - and(...keys.slice(0, i).map((prior, j) => eq(prior.expr, bound[j])), beyond(key.expr, bound[i])) - ) + const clauses = keys.map((key, i) => { + const direction = typeof order === 'string' ? order : order[i] + const beyond = direction === 'asc' ? gt : lt + return and( + ...keys.slice(0, i).map((prior, j) => eq(prior.expr, bound[j])), + beyond(key.expr, bound[i]) + ) + }) return or(...clauses) ?? null } diff --git a/apps/sim/lib/api/mcp/generated/v2-operations.ts b/apps/sim/lib/api/mcp/generated/v2-operations.ts index 20e50543c2e..845437ae82a 100644 --- a/apps/sim/lib/api/mcp/generated/v2-operations.ts +++ b/apps/sim/lib/api/mcp/generated/v2-operations.ts @@ -59,6 +59,7 @@ import { v2ListCustomToolsContract, v2UpdateCustomToolContract, } from '@/lib/api/contracts/v2/custom-tools' +import { v2CopyFileItemsContract } from '@/lib/api/contracts/v2/file-copy' import { v2DeleteFileVersionContract, v2GetFileVersionContract, @@ -185,6 +186,43 @@ import { v2RemovePermissionGroupMemberContract, v2UpdatePermissionGroupContract, } from '@/lib/api/contracts/v2/permission-groups' +import { v2UnzipProjectFileContract } from '@/lib/api/contracts/v2/project-file-extraction' +import { + v2CreateProjectFileFolderContract, + v2ListProjectFileFoldersContract, + v2RestoreProjectFileFolderContract, + v2UpdateProjectFileFolderContract, +} from '@/lib/api/contracts/v2/project-file-folders' +import { + v2ArchiveProjectFileItemsContract, + v2MoveProjectFileItemsContract, + v2RenameProjectFileContract, + v2RestoreProjectFileContract, +} from '@/lib/api/contracts/v2/project-file-lifecycle' +import { v2SearchProjectFileContentContract } from '@/lib/api/contracts/v2/project-file-search' +import { + v2GetProjectFileShareContract, + v2UpdateProjectFileShareContract, +} from '@/lib/api/contracts/v2/project-file-shares' +import { + v2AbortProjectFileUploadContract, + v2CompleteProjectFileUploadContract, + v2CreateProjectFileUploadContract, + v2GetProjectFileUploadContract, + v2GetProjectFileUploadPartUrlsContract, +} from '@/lib/api/contracts/v2/project-file-uploads' +import { + v2DeleteProjectFileVersionContract, + v2GetProjectFileVersionContract, + v2ListProjectFileVersionsContract, + v2RevertProjectFileVersionContract, +} from '@/lib/api/contracts/v2/project-file-versions' +import { + v2CreateProjectFileContract, + v2GetProjectFileMetadataContract, + v2ListProjectFilesContract, + v2UpdateProjectFileContentContract, +} from '@/lib/api/contracts/v2/project-files' import { v2CreateSandboxContract, v2DeleteSandboxContract, @@ -372,6 +410,17 @@ export const V2_MCP_OPERATIONS = { (route) => route.DELETE ), }, + abortProjectFileUpload: { + contract: v2AbortProjectFileUploadContract, + summary: 'Abort Project File Upload', + description: + 'Abort a pending Project upload and schedule its unregistered bytes for cleanup. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/projects/[projectId]/files/uploads/[uploadId]/route').then( + (route) => route.DELETE + ), + }, activateWorkflowVersion: { contract: v2ActivateWorkflowVersionContract, summary: 'Activate Workflow Version', @@ -448,6 +497,15 @@ export const V2_MCP_OPERATIONS = { handler: () => import('@/app/api/v2/workflows/[workflowId]/variables/route').then((route) => route.PATCH), }, + archiveProjectFileItems: { + contract: v2ArchiveProjectFileItemsContract, + summary: 'Archive Project File Items', + description: + 'Archive selected files and folders. Folder contents are archived recursively and remain recoverable until retention removes them. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/projects/[projectId]/files/archive/route').then((route) => route.POST), + }, bulkAddPermissionGroupMembers: { contract: v2BulkAddPermissionGroupMembersContract, summary: 'Bulk Add Permission Group Members', @@ -611,6 +669,17 @@ export const V2_MCP_OPERATIONS = { '@/app/api/v2/knowledge/[knowledgeBaseId]/documents/uploads/[uploadId]/complete/route' ).then((route) => route.POST), }, + completeProjectFileUpload: { + contract: v2CompleteProjectFileUploadContract, + summary: 'Complete Project File Upload', + description: + 'Finalize verified bytes and atomically register one Project file. Retrying completion returns the same file without billing twice. Current edit access is checked again. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/projects/[projectId]/files/uploads/[uploadId]/complete/route').then( + (route) => route.POST + ), + }, completeTableImport: { contract: v2CompleteTableImportContract, summary: 'Complete Table Import Upload', @@ -619,6 +688,14 @@ export const V2_MCP_OPERATIONS = { handler: () => import('@/app/api/v2/tables/imports/[importId]/complete/route').then((route) => route.POST), }, + copyFileItems: { + contract: v2CopyFileItemsContract, + summary: 'Copy File Items', + description: + 'Copy selected files and folder trees between workspace or Project owners. Source read and destination write access are checked independently. Copies receive new identities and retain source secret provenance. Registration is atomic, with destination names resolved by the existing copy rules. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => import('@/app/api/v2/files/copy/route').then((route) => route.POST), + }, createCredentialConnection: { contract: v2CreateCredentialConnectionContract, summary: 'Create Credential Connection', @@ -767,6 +844,33 @@ export const V2_MCP_OPERATIONS = { (route) => route.POST ), }, + createProjectFile: { + contract: v2CreateProjectFileContract, + summary: 'Create Project File', + description: + 'Create a shared Project file from inline text or base64 bytes. Names are exact; an existing sibling name returns a conflict. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/projects/[projectId]/files/route').then((route) => route.POST), + }, + createProjectFileFolder: { + contract: v2CreateProjectFileFolderContract, + summary: 'Create Project File Folder', + description: + 'Create a folder under an existing parent, or at the Project root when parentId is omitted. Sibling names must be unique. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/projects/[projectId]/files/folders/route').then((route) => route.POST), + }, + createProjectFileUpload: { + contract: v2CreateProjectFileUploadContract, + summary: 'Create Project File Upload', + description: + 'Create a resumable Project file upload. The file is registered only after the signed transfer and completion succeed. The original API credential and upload-token are required on every control request. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/projects/[projectId]/files/uploads/route').then((route) => route.POST), + }, createSandbox: { contract: v2CreateSandboxContract, summary: 'Create Sandbox', @@ -1017,6 +1121,17 @@ export const V2_MCP_OPERATIONS = { (route) => route.DELETE ), }, + deleteProjectFileVersion: { + contract: v2DeleteProjectFileVersionContract, + summary: 'Delete Project File Version', + description: + 'Permanently remove one superseded Project file version from history. Deleting the current version returns `409`; other versions and the current file remain available. Stored-object cleanup is retried asynchronously when needed. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/projects/[projectId]/files/[fileId]/versions/[version]/route').then( + (route) => route.DELETE + ), + }, deleteSandbox: { contract: v2DeleteSandboxContract, summary: 'Delete Sandbox', @@ -1443,6 +1558,61 @@ export const V2_MCP_OPERATIONS = { (route) => route.GET ), }, + getProjectFileMetadata: { + contract: v2GetProjectFileMetadataContract, + summary: 'Get Project File Metadata', + description: + "Get an active file's metadata and Project ownership. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/projects/[projectId]/files/[fileId]/metadata/route').then( + (route) => route.GET + ), + }, + getProjectFileShare: { + contract: v2GetProjectFileShareContract, + summary: 'Get Project File Share', + description: + "Get a Project file's public-share configuration. An unshared file returns data: null; a disabled share retains its configuration with isActive: false. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.", + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/projects/[projectId]/files/[fileId]/share/route').then( + (route) => route.GET + ), + }, + getProjectFileUpload: { + contract: v2GetProjectFileUploadContract, + summary: 'Get Project File Upload', + description: + 'Read the current state of a Project upload, including its file after completion. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/projects/[projectId]/files/uploads/[uploadId]/route').then( + (route) => route.GET + ), + }, + getProjectFileUploadPartUrls: { + contract: v2GetProjectFileUploadPartUrlsContract, + summary: 'Get Project File Upload Part URLs', + description: + 'Request signed multipart transfer URLs for an active Project upload. Send exactly the returned transfer headers when uploading each part. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/projects/[projectId]/files/uploads/[uploadId]/parts/route').then( + (route) => route.POST + ), + }, + getProjectFileVersion: { + contract: v2GetProjectFileVersionContract, + summary: 'Get Project File Version', + description: + 'Get metadata and author attribution for a recorded Project file version. Missing or permanently removed versions return `404`. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/projects/[projectId]/files/[fileId]/versions/[version]/route').then( + (route) => route.GET + ), + }, getRowEnrichment: { contract: v2GetRowEnrichmentContract, summary: 'Get Row Group Run', @@ -1999,6 +2169,35 @@ export const V2_MCP_OPERATIONS = { (route) => route.GET ), }, + listProjectFileFolders: { + contract: v2ListProjectFileFoldersContract, + summary: 'List Project File Folders', + description: + 'List the Project folder tree with stable identifiers. Use scope=archived to find folders eligible for restore. Returns the complete set in one page; `nextCursor` is always null. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/projects/[projectId]/files/folders/route').then((route) => route.GET), + }, + listProjectFiles: { + contract: v2ListProjectFilesContract, + summary: 'List Project Files', + description: + 'List shared Project files with cursor pagination. Use scope=archived to find archived files. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/projects/[projectId]/files/route').then((route) => route.GET), + }, + listProjectFileVersions: { + contract: v2ListProjectFileVersionsContract, + summary: 'List Project File Versions', + description: + 'List recorded versions of a shared Project file, newest first by default. Retention follows the Project payer and preserves the newest ten versions; removed versions leave gaps in numbering. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/projects/[projectId]/files/[fileId]/versions/route').then( + (route) => route.GET + ), + }, listSandboxes: { contract: v2ListSandboxesContract, summary: 'List Sandboxes', @@ -2196,6 +2395,15 @@ export const V2_MCP_OPERATIONS = { 'Move up to 1,000 files to a folder path or the workspace root.\n\nOAuth scope: `api:write`.', handler: () => import('@/app/api/v2/files/move/route').then((route) => route.POST), }, + moveProjectFileItems: { + contract: v2MoveProjectFileItemsContract, + summary: 'Move Project File Items', + description: + 'Move selected files and folders into an existing folder within the same Project. Folder contents move with their parent. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/projects/[projectId]/files/move/route').then((route) => route.POST), + }, moveTables: { contract: v2MoveTablesContract, summary: 'Move Tables and Folders', @@ -2391,6 +2599,15 @@ export const V2_MCP_OPERATIONS = { 'Rename a workspace file without changing its containing folder.\n\nOAuth scope: `api:write`.', handler: () => import('@/app/api/v2/files/[fileId]/route').then((route) => route.PATCH), }, + renameProjectFile: { + contract: v2RenameProjectFileContract, + summary: 'Rename Project File', + description: + 'Rename a shared Project file while retaining its identity and history. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/projects/[projectId]/files/[fileId]/route').then((route) => route.PATCH), + }, replaceWorkflowChatDeployment: { contract: v2ReplaceWorkflowChatDeploymentContract, summary: 'Create or Replace Workflow Chat Deployment', @@ -2455,6 +2672,28 @@ export const V2_MCP_OPERATIONS = { handler: () => import('@/app/api/v2/knowledge/[knowledgeBaseId]/restore/route').then((route) => route.POST), }, + restoreProjectFile: { + contract: v2RestoreProjectFileContract, + summary: 'Restore Project File', + description: + 'Restore an archived Project file. If its former folder is unavailable, restore it to the Project root with an available name. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/projects/[projectId]/files/[fileId]/restore/route').then( + (route) => route.POST + ), + }, + restoreProjectFileFolder: { + contract: v2RestoreProjectFileFolderContract, + summary: 'Restore Project File Folder', + description: + 'Restore an archived folder and the files and subfolders archived with it. Find identifiers with List Project File Folders using scope=archived. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/projects/[projectId]/files/folders/[folderId]/restore/route').then( + (route) => route.POST + ), + }, restoreTable: { contract: v2RestoreTableContract, summary: 'Restore Table', @@ -2498,6 +2737,17 @@ export const V2_MCP_OPERATIONS = { (route) => route.POST ), }, + revertProjectFileVersion: { + contract: v2RevertProjectFileVersionContract, + summary: 'Revert Project File Version', + description: + 'Make an earlier version current by recording its source bytes as a new revert version. Reverting to the current version is a no-op. Use expectedRevision to reject changes made since the last read; a stale revision or concurrent edit returns `409`. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import( + '@/app/api/v2/projects/[projectId]/files/[fileId]/versions/[version]/revert/route' + ).then((route) => route.POST), + }, revertWorkflowVersion: { contract: v2RevertWorkflowVersionContract, summary: 'Revert Workflow To Version', @@ -2584,6 +2834,15 @@ export const V2_MCP_OPERATIONS = { 'Search one or more knowledge bases with semantic vector retrieval, optional hybrid full-text retrieval, and structured tag filters. Every result names the `knowledgeBaseId` it came from. A request body over 2 MiB is a `413`. Reranking returns `409` when the stored results cannot pass secret-provenance enforcement.\n\nOAuth scope: `api:read`.', handler: () => import('@/app/api/v2/knowledge/search/route').then((route) => route.POST), }, + searchProjectFileContent: { + contract: v2SearchProjectFileContentContract, + summary: 'Search Project File Content', + description: + 'Search indexed text in active Project files, returning matching lines with file IDs and line numbers. Folder filters narrow both results and reported coverage. Missing matches are inconclusive when complete is false, or skippedFiles or partialFiles is nonzero. truncated means additional matches exist beyond maxResults. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:read`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/projects/[projectId]/files/search/route').then((route) => route.GET), + }, searchTableRows: { contract: v2SearchTableRowsContract, summary: 'Search Rows', @@ -2668,6 +2927,17 @@ export const V2_MCP_OPERATIONS = { 'Extract a ZIP archive into a new sibling folder and return counts and the destination path. Use List Files to inspect its contents. Large archives can take minutes; concurrent extraction of the same archive returns `409`. Size or processing-time limits return `413`.\n\nOAuth scope: `api:write`.', handler: () => import('@/app/api/v2/files/[fileId]/unzip/route').then((route) => route.POST), }, + unzipProjectFile: { + contract: v2UnzipProjectFileContract, + summary: 'Unzip Project File', + description: + 'Extract a ZIP archive into a new sibling folder in the same Project. Use List Project Files to inspect its contents. Concurrent extraction of the same archive returns `409`; size or processing-time limits return `413`. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/projects/[projectId]/files/[fileId]/unzip/route').then( + (route) => route.POST + ), + }, updateCredential: { contract: v2UpdateCredentialContract, summary: 'Update Credential', @@ -2807,6 +3077,39 @@ export const V2_MCP_OPERATIONS = { (route) => route.PATCH ), }, + updateProjectFileContent: { + contract: v2UpdateProjectFileContentContract, + summary: 'Replace Project File Content', + description: + 'Replace the complete content of a Project file. Supply expectedRevision to reject a stale edit with 409. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/projects/[projectId]/files/[fileId]/content/route').then( + (route) => route.PUT + ), + }, + updateProjectFileFolder: { + contract: v2UpdateProjectFileFolderContract, + summary: 'Update Project File Folder', + description: + 'Rename, move, or reorder a folder while retaining its identity and descendants. Omitted fields stay unchanged; parentId=null moves the folder to the root. Cross-owner parents and cycles are rejected. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.', + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/projects/[projectId]/files/folders/[folderId]/route').then( + (route) => route.PATCH + ), + }, + updateProjectFileShare: { + contract: v2UpdateProjectFileShareContract, + summary: 'Update Project File Share', + description: + "Create or update a Project file's public share. isActive is required; omitted settings retain their current values except credentials unused by the selected access mode, which are cleared. Disabling retains the token and access configuration. Publication requires Project edit access and the current sharing policy across accessible active environments. Workspace API keys return `403`; use a personal API key or scoped OAuth token.\n\nOAuth scope: `api:write`.", + workspaceKeyUnsupported: true, + handler: () => + import('@/app/api/v2/projects/[projectId]/files/[fileId]/share/route').then( + (route) => route.PATCH + ), + }, updateRowsByFilter: { contract: v2UpdateRowsByFilterContract, summary: 'Update Rows by Filter', diff --git a/apps/sim/lib/api/server/routes/internal-binary-route.test.ts b/apps/sim/lib/api/server/routes/internal-binary-route.test.ts new file mode 100644 index 00000000000..835a03b17c8 --- /dev/null +++ b/apps/sim/lib/api/server/routes/internal-binary-route.test.ts @@ -0,0 +1,51 @@ +import { createSessionPrincipal } from '@sim/testing/factories/principal.factory' +import { NextRequest } from 'next/server' +import { describe, expect, it } from 'vitest' +import { defineRouteContract } from '@/lib/api/contracts' +import { defineInternalBinaryRoute } from '@/lib/api/server/routes/internal-binary-route' +import { + internalOrchestrationErrorPolicy, + internalRateLimits, +} from '@/lib/api/server/routes/internal-json-route' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +const contract = defineRouteContract({ + method: 'GET', + path: '/api/test/download', + response: { mode: 'binary' }, +}) +const operation = { id: 'test.download' } as const + +describe('internal download HEAD authorization', () => { + it.each([true, false])( + 'checks access without starting a download when allowed=%s', + async (allowed) => { + const handler = defineInternalBinaryRoute({ + contract, + operation, + headSafe: false, + auth: { authenticate: async () => createSessionPrincipal() }, + rateLimit: internalRateLimits.none({ + reason: 'No network admission needed in this fixture', + }), + errorPolicy: internalOrchestrationErrorPolicy, + mapInput: () => undefined, + useCase: { + operation, + async authorize() { + if (!allowed) throw new OrchestrationError('forbidden', 'Access denied') + }, + async execute(): Promise { + throw new Error('HEAD must not reach download side effects') + }, + }, + present: (body) => ({ body, contentType: 'application/octet-stream' }), + }) + const response = await handler( + new NextRequest('http://localhost/api/test/download', { method: 'HEAD' }) + ) + expect(response.status).toBe(allowed ? 200 : 403) + if (allowed) expect(await response.text()).toBe('') + } + ) +}) diff --git a/apps/sim/lib/api/server/routes/internal-binary-route.ts b/apps/sim/lib/api/server/routes/internal-binary-route.ts index 44ed6713897..ed5729c45f1 100644 --- a/apps/sim/lib/api/server/routes/internal-binary-route.ts +++ b/apps/sim/lib/api/server/routes/internal-binary-route.ts @@ -20,7 +20,7 @@ import type { JsonNextRouteHandler, JsonRouteContext, } from '@/lib/api/server/routes/types' -import type { ParsedRequest } from '@/lib/api/server/validation' +import type { ParsedRequest, ParseRequestOptions } from '@/lib/api/server/validation' import { parseRequest } from '@/lib/api/server/validation' import type { ApplicationOperation, OperationUseCase } from '@/lib/core/application' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -53,6 +53,8 @@ interface InternalBinaryRouteOptions< auth: typeof internalSessionAuth rateLimit: InternalBinaryRateLimitPolicy errorPolicy: InternalErrorPolicy + parseOptions?: Pick + headSafe?: boolean onSuccess?(args: { principal: SessionPrincipal; input: I; result: R }): void | Promise } @@ -74,6 +76,10 @@ export function defineInternalBinaryRoute< options.useCase.operation ) + if (options.headSafe === false && typeof options.useCase.authorize !== 'function') { + throw new Error('A binary route with headSafe: false requires an authorize phase') + } + const wrapped = withRouteHandler( async (request, context) => { if (!methodMatchesContract(request.method, options.contract.method)) { @@ -94,11 +100,24 @@ export function defineInternalBinaryRoute< setRequestAuth(describePrincipalAuth(principal)) await options.rateLimit.enforce(request, principal) - const parsed = await parseRequest(options.contract, request, context ?? {}) + const parsed = await parseRequest( + options.contract, + request, + context ?? {}, + options.parseOptions + ) if (!parsed.success) return responseWithRequestId(parsed.response) try { const input = options.mapInput(parsed.data) + if (request.method === 'HEAD' && options.headSafe === false) { + if (!options.useCase.authorize) throw new Error('Missing HEAD authorization phase') + await options.useCase.authorize({ principal, input, request }) + return new NextResponse(null, { + status: successStatus, + headers: { 'Cache-Control': 'private, no-store', 'X-Content-Type-Options': 'nosniff' }, + }) + } const result = await options.useCase.execute({ principal, input, request }) const descriptor = await options.present(result) await options.onSuccess?.({ principal, input, result }) @@ -125,7 +144,14 @@ export function defineInternalBinaryRoute< } ) - return async (request, context) => wrapped(request, context) + return async (request, context) => { + const response = await wrapped(request, context) + if (response.status >= 400) { + response.headers.set('Cache-Control', 'private, no-store') + response.headers.set('X-Content-Type-Options', 'nosniff') + } + return response + } } function createJsonErrorResponse(descriptor: JsonErrorResponseDescriptor): NextResponse { diff --git a/apps/sim/lib/api/server/routes/internal-json-route.test.ts b/apps/sim/lib/api/server/routes/internal-json-route.test.ts index 8429d8f70e8..b3e01211135 100644 --- a/apps/sim/lib/api/server/routes/internal-json-route.test.ts +++ b/apps/sim/lib/api/server/routes/internal-json-route.test.ts @@ -8,6 +8,7 @@ vi.mock('@/lib/core/rate-limiter', () => rateLimiterMock) const mockEnforceUserRateLimit = rateLimiterMockFns.mockEnforceUserRateLimit import { defineRouteContract } from '@/lib/api/contracts' +import { completeProjectFileUploadContract } from '@/lib/api/contracts/project-file-uploads' import { defineInternalJsonRoute, InternalUnauthenticatedError, @@ -330,3 +331,27 @@ describe('defineInternalJsonRoute', () => { await expect(response.json()).resolves.toEqual({ error: 'Internal server error' }) }) }) + +describe('optional upload completion JSON', () => { + it.each([undefined, '{}'])('accepts an empty completion body: %s', async (body) => { + const handler = defineInternalJsonRoute({ + contract: defineRouteContract({ + method: 'POST', + path: '/api/test/complete', + body: completeProjectFileUploadContract.body, + response: { mode: 'json', schema: z.object({ completed: z.boolean() }) }, + }), + auth, + operation, + rateLimit: internalRateLimits.none({ reason: 'Parser regression' }), + errorPolicy: internalOrchestrationErrorPolicy, + parseOptions: { optionalJsonBody: true }, + mapInput: () => undefined, + useCase: { operation, execute: async () => ({ completed: true }) }, + }) + const response = await handler( + new NextRequest('http://localhost/api/test/complete', { method: 'POST', body }) + ) + expect(response.status).toBe(200) + }) +}) diff --git a/apps/sim/lib/api/server/routes/internal-json-route.ts b/apps/sim/lib/api/server/routes/internal-json-route.ts index a3890a53fde..d5703f3ad30 100644 --- a/apps/sim/lib/api/server/routes/internal-json-route.ts +++ b/apps/sim/lib/api/server/routes/internal-json-route.ts @@ -238,7 +238,7 @@ export interface InternalJsonResponseFinalization { type InternalJsonParseOptions = Pick< ParseRequestOptions, - 'maxBodyBytes' | 'validationErrorResponse' + 'maxBodyBytes' | 'validationErrorResponse' | 'optionalJsonBody' > /** diff --git a/apps/sim/lib/api/server/routes/v2-binary-route.test.ts b/apps/sim/lib/api/server/routes/v2-binary-route.test.ts index 4833cfc2cf3..04015a20d33 100644 --- a/apps/sim/lib/api/server/routes/v2-binary-route.test.ts +++ b/apps/sim/lib/api/server/routes/v2-binary-route.test.ts @@ -84,6 +84,35 @@ describe('defineV2BinaryRoute', () => { v2RouteMocks.operationRate.mockResolvedValue(allowedRate) }) + it('honors an explicit binary request limit and keeps the v2 rejection envelope', async () => { + const handler = defineV2BinaryRoute({ + contract: defineRouteContract({ + method: 'POST', + path: '/api/v2/snapshot', + query: z.object({}), + body: z.object({ content: z.string() }), + response: { mode: 'binary' }, + }), + auth: v2ApiKeyAuth, + operation, + rateLimit: v2RateLimits.publicApi, + errorPolicy: v2OrchestrationErrorPolicy, + parseOptions: { maxBodyBytes: 64 }, + mapInput: ({ body }) => body, + useCase: { operation, execute: async ({ input }) => input.content }, + present: (body) => ({ body, contentType: 'text/plain' }), + }) + const response = await handler( + new NextRequest('http://localhost/api/v2/snapshot', { + method: 'POST', + body: JSON.stringify({ content: 'x'.repeat(65) }), + headers: { 'content-type': 'application/json' }, + }) + ) + expect(response.status).toBe(413) + expect(await response.json()).toMatchObject({ error: { code: 'PAYLOAD_TOO_LARGE' } }) + }) + it('runs the use case for a HEAD when the route is head-safe', async () => { const execute = vi.fn(async () => ({ bytes: 'payload' })) const response = await createHandler({ execute })(request('HEAD'), context) diff --git a/apps/sim/lib/api/server/routes/v2-binary-route.ts b/apps/sim/lib/api/server/routes/v2-binary-route.ts index 82de0fccc84..c1eee5d1ca2 100644 --- a/apps/sim/lib/api/server/routes/v2-binary-route.ts +++ b/apps/sim/lib/api/server/routes/v2-binary-route.ts @@ -19,6 +19,7 @@ import { type v2ApiKeyAuth, v2HeadAuthorizationResponse, } from '@/lib/api/server/routes/v2-json-route' +import type { ParseRequestOptions } from '@/lib/api/server/validation' import { parseRequest } from '@/lib/api/server/validation' import type { ApplicationOperation } from '@/lib/core/application' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' @@ -33,6 +34,7 @@ interface V2BinaryRouteOptions< auth: typeof v2ApiKeyAuth rateLimit: V2RateLimitPolicy errorPolicy: V2ErrorPolicy + parseOptions?: Pick /** * As on {@link defineV2JsonRoute}, whose `headSafe` option carries the * rationale; the bodiless answer is {@link v2HeadNoEffect}. A binary `GET` is @@ -74,6 +76,7 @@ export function defineV2BinaryRoute< const parsed = await parseRequest(options.contract, request, context ?? {}, { ...V2_PARSE_DEFAULTS, + ...options.parseOptions, }) if (!parsed.success) return parsed.response diff --git a/apps/sim/lib/api/server/routes/v2-route-table.generated.ts b/apps/sim/lib/api/server/routes/v2-route-table.generated.ts index f48e826f28e..173f838e7fc 100644 --- a/apps/sim/lib/api/server/routes/v2-route-table.generated.ts +++ b/apps/sim/lib/api/server/routes/v2-route-table.generated.ts @@ -138,6 +138,10 @@ export const V2_ROUTES: readonly V2RouteEntry[] = [ pattern: '/api/v2/files/bulk-download', load: () => import('@/app/api/v2/files/bulk-download/route'), }, + { + pattern: '/api/v2/files/copy', + load: () => import('@/app/api/v2/files/copy/route'), + }, { pattern: '/api/v2/files/folders', load: () => import('@/app/api/v2/files/folders/route'), @@ -462,6 +466,100 @@ export const V2_ROUTES: readonly V2RouteEntry[] = [ pattern: '/api/v2/organizations/{organizationId}/workspaces', load: () => import('@/app/api/v2/organizations/[organizationId]/workspaces/route'), }, + { + pattern: '/api/v2/projects/{projectId}/files', + load: () => import('@/app/api/v2/projects/[projectId]/files/route'), + }, + { + pattern: '/api/v2/projects/{projectId}/files/{fileId}', + load: () => import('@/app/api/v2/projects/[projectId]/files/[fileId]/route'), + }, + { + pattern: '/api/v2/projects/{projectId}/files/{fileId}/content', + load: () => import('@/app/api/v2/projects/[projectId]/files/[fileId]/content/route'), + }, + { + pattern: '/api/v2/projects/{projectId}/files/{fileId}/export', + load: () => import('@/app/api/v2/projects/[projectId]/files/[fileId]/export/route'), + }, + { + pattern: '/api/v2/projects/{projectId}/files/{fileId}/metadata', + load: () => import('@/app/api/v2/projects/[projectId]/files/[fileId]/metadata/route'), + }, + { + pattern: '/api/v2/projects/{projectId}/files/{fileId}/restore', + load: () => import('@/app/api/v2/projects/[projectId]/files/[fileId]/restore/route'), + }, + { + pattern: '/api/v2/projects/{projectId}/files/{fileId}/share', + load: () => import('@/app/api/v2/projects/[projectId]/files/[fileId]/share/route'), + }, + { + pattern: '/api/v2/projects/{projectId}/files/{fileId}/unzip', + load: () => import('@/app/api/v2/projects/[projectId]/files/[fileId]/unzip/route'), + }, + { + pattern: '/api/v2/projects/{projectId}/files/{fileId}/versions', + load: () => import('@/app/api/v2/projects/[projectId]/files/[fileId]/versions/route'), + }, + { + pattern: '/api/v2/projects/{projectId}/files/{fileId}/versions/{version}', + load: () => import('@/app/api/v2/projects/[projectId]/files/[fileId]/versions/[version]/route'), + }, + { + pattern: '/api/v2/projects/{projectId}/files/{fileId}/versions/{version}/content', + load: () => + import('@/app/api/v2/projects/[projectId]/files/[fileId]/versions/[version]/content/route'), + }, + { + pattern: '/api/v2/projects/{projectId}/files/{fileId}/versions/{version}/revert', + load: () => + import('@/app/api/v2/projects/[projectId]/files/[fileId]/versions/[version]/revert/route'), + }, + { + pattern: '/api/v2/projects/{projectId}/files/archive', + load: () => import('@/app/api/v2/projects/[projectId]/files/archive/route'), + }, + { + pattern: '/api/v2/projects/{projectId}/files/bulk-download', + load: () => import('@/app/api/v2/projects/[projectId]/files/bulk-download/route'), + }, + { + pattern: '/api/v2/projects/{projectId}/files/folders', + load: () => import('@/app/api/v2/projects/[projectId]/files/folders/route'), + }, + { + pattern: '/api/v2/projects/{projectId}/files/folders/{folderId}', + load: () => import('@/app/api/v2/projects/[projectId]/files/folders/[folderId]/route'), + }, + { + pattern: '/api/v2/projects/{projectId}/files/folders/{folderId}/restore', + load: () => import('@/app/api/v2/projects/[projectId]/files/folders/[folderId]/restore/route'), + }, + { + pattern: '/api/v2/projects/{projectId}/files/move', + load: () => import('@/app/api/v2/projects/[projectId]/files/move/route'), + }, + { + pattern: '/api/v2/projects/{projectId}/files/search', + load: () => import('@/app/api/v2/projects/[projectId]/files/search/route'), + }, + { + pattern: '/api/v2/projects/{projectId}/files/uploads', + load: () => import('@/app/api/v2/projects/[projectId]/files/uploads/route'), + }, + { + pattern: '/api/v2/projects/{projectId}/files/uploads/{uploadId}', + load: () => import('@/app/api/v2/projects/[projectId]/files/uploads/[uploadId]/route'), + }, + { + pattern: '/api/v2/projects/{projectId}/files/uploads/{uploadId}/complete', + load: () => import('@/app/api/v2/projects/[projectId]/files/uploads/[uploadId]/complete/route'), + }, + { + pattern: '/api/v2/projects/{projectId}/files/uploads/{uploadId}/parts', + load: () => import('@/app/api/v2/projects/[projectId]/files/uploads/[uploadId]/parts/route'), + }, { pattern: '/api/v2/sandboxes', load: () => import('@/app/api/v2/sandboxes/route'), diff --git a/apps/sim/lib/api/server/validation.test.ts b/apps/sim/lib/api/server/validation.test.ts index 632b38e9488..86a619fad17 100644 --- a/apps/sim/lib/api/server/validation.test.ts +++ b/apps/sim/lib/api/server/validation.test.ts @@ -10,7 +10,7 @@ import { * Next.js truncates a proxied client body past `experimental.proxyClientMaxBodySize` * without signalling it, so this is the largest body a handler can actually receive. */ -const PROXY_CLIENT_MAX_BODY_BYTES = 10 * 1024 * 1024 +const PROXY_CLIENT_MAX_BODY_BYTES = 17 * 1024 * 1024 /** Mirrors `MAX_WORKSPACE_FILE_INLINE_BODY_BYTES` — an explicit override above the ceiling. */ const INLINE_FILE_BODY_BYTES = 70 * 1024 * 1024 @@ -40,9 +40,9 @@ describe('DEFAULT_MAX_JSON_BODY_BYTES', () => { }) describe('parseJsonBody default size boundary', () => { - it('accepts a body at the proxy cap', async () => { + it('accepts a body at the default cap', async () => { const result = await parseJsonBody( - requestDeclaring(PROXY_CLIENT_MAX_BODY_BYTES, JSON.stringify({ value: 'ok' })) + requestDeclaring(DEFAULT_MAX_JSON_BODY_BYTES, JSON.stringify({ value: 'ok' })) ) expect(result.success).toBe(true) @@ -75,6 +75,16 @@ describe('parseJsonBody default size boundary', () => { }) }) + it('accepts a document snapshot above the default cap with its explicit limit', async () => { + const body = JSON.stringify({ update: 'a'.repeat(12 * 1024 * 1024) }) + const result = await parseJsonBody( + requestDeclaring(Buffer.byteLength(body), body), + 'response', + 17 * 1024 * 1024 + ) + expect(result.success).toBe(true) + }) + it('leaves an override below the ceiling exactly as declared', async () => { const atLimit = await parseJsonBody( requestDeclaring(BELOW_CEILING_BODY_BYTES, JSON.stringify({ value: 'ok' })), diff --git a/apps/sim/lib/api/server/validation.ts b/apps/sim/lib/api/server/validation.ts index 096b1a63656..0221dbeeb05 100644 --- a/apps/sim/lib/api/server/validation.ts +++ b/apps/sim/lib/api/server/validation.ts @@ -14,55 +14,20 @@ import { } from '@/lib/api/server/blank-query-values' import { nulByteValidationError } from '@/lib/api/server/nul-bytes' import { env } from '@/lib/core/config/env' +import { PROXY_CLIENT_MAX_BODY_BYTES } from '@/lib/core/config/request-limits' import { assertContentLengthWithinLimit, isPayloadSizeLimitError, readStreamToBufferWithLimit, } from '@/lib/core/utils/stream-limits' -/** - * Next.js buffers the client body for the proxy and *silently truncates* anything - * past `experimental.proxyClientMaxBodySize` (default 10 MB), and `apps/sim/proxy.ts` - * matches `/api/:path*`. A larger body therefore reaches the handler as a truncated - * prefix, which fails JSON parsing — so an oversized request has to be rejected on - * its declared size before it is read, or the truncation gets misreported as a - * malformed body. - */ -const PROXY_CLIENT_MAX_BODY_BYTES = 10 * 1024 * 1024 - -/** - * Default upper bound on the JSON request body that contract routes will read - * and parse into memory. Without a cap an unauthenticated caller could buffer a - * large body before schema validation runs. Override per-route via - * `ParseRequestOptions.maxBodyBytes`. - * - * Falls back to 50 MB if the env value is missing or non-numeric so a misconfig - * can never silently disable the cap (a NaN limit would never reject), then - * clamps to {@link PROXY_CLIENT_MAX_BODY_BYTES} because the app can never - * actually receive more than the proxy forwards. - */ +/** Ordinary JSON keeps its existing cap; larger authorized routes opt in explicitly. */ export const DEFAULT_MAX_JSON_BODY_BYTES = Math.min( Number.parseInt(env.API_MAX_JSON_BODY_BYTES, 10) || 50 * 1024 * 1024, - PROXY_CLIENT_MAX_BODY_BYTES + 10 * 1024 * 1024 ) -/** - * Clamps a per-route body cap to {@link PROXY_CLIENT_MAX_BODY_BYTES}. - * - * A route that raises `maxBodyBytes` above the proxy ceiling cannot actually - * receive a body that large: the proxy truncates the stream, the handler parses - * a prefix, and the caller gets `400 "Request body must be valid JSON"` for a - * request whose only fault was its size. Clamping at the point of use turns that - * into an accurate `413`; nothing that succeeds today changes, because a body - * over the ceiling already fails — just less honestly. - * - * Consequence worth keeping in view: `MAX_WORKSPACE_FILE_INLINE_BODY_BYTES` - * (70 MB) exists so a 50 MiB file can be sent inline as base64, and that ceiling - * stays unreachable until `experimental.proxyClientMaxBodySize` is raised in - * `apps/sim/next.config.ts`. Raising it changes the memory profile of every - * `/api` route, so it is a separate decision — this clamp only makes the limit - * that is actually in force report itself correctly. - */ +/** Reject before Next's proxy truncates the body and turns an oversize request into malformed JSON. */ function clampToProxyLimit(maxBytes: number): number { return Math.min(maxBytes, PROXY_CLIENT_MAX_BODY_BYTES) } diff --git a/apps/sim/lib/auth/realtime-file-delegation.ts b/apps/sim/lib/auth/realtime-file-delegation.ts new file mode 100644 index 00000000000..51f4b5ffa56 --- /dev/null +++ b/apps/sim/lib/auth/realtime-file-delegation.ts @@ -0,0 +1,42 @@ +import type { ResourceDelegatedPrincipal } from '@sim/auth/principal' +import { FILE_DOC_INTERNAL_HEADERS } from '@sim/realtime-protocol/file-doc' +import { generateId } from '@sim/utils/id' +import { + type InternalAuthPolicy, + InternalUnauthenticatedError, +} from '@/lib/api/server/routes/internal-json-route' +import { checkInternalApiKey } from '@/lib/mothership/request/http' +import { PROJECT_FILE_DELEGATION_TTL_MS } from '@/lib/projects/files/application/operations' + +/** Internal relay requests carry the authenticated socket actor, bounded to exactly one Project file. */ +export const realtimeProjectFileAuth: InternalAuthPolicy = { + async authenticate(request, params) { + if (!checkInternalApiKey(request).success) throw new InternalUnauthenticatedError() + const subjectUserId = request.headers.get(FILE_DOC_INTERNAL_HEADERS.userId) + const connectionId = request.headers.get(FILE_DOC_INTERNAL_HEADERS.connectionId) + const projectId = params.projectId + const fileId = params.fileId + if ( + !subjectUserId?.trim() || + !connectionId?.trim() || + typeof projectId !== 'string' || + !projectId || + typeof fileId !== 'string' || + !fileId + ) { + throw new InternalUnauthenticatedError() + } + const now = Date.now() + return { + kind: 'resource_delegated', + serviceId: 'realtime', + subjectUserId, + invocation: { kind: 'realtime', connectionId }, + scope: { kind: 'entity', entityType: 'project', entityId: projectId, fileId }, + audience: 'sim:project-files', + delegationId: generateId(), + issuedAt: new Date(now), + expiresAt: new Date(now + PROJECT_FILE_DELEGATION_TTL_MS), + } + }, +} diff --git a/apps/sim/lib/auth/realtime-file-list-delegation.ts b/apps/sim/lib/auth/realtime-file-list-delegation.ts new file mode 100644 index 00000000000..f3ed48db9c2 --- /dev/null +++ b/apps/sim/lib/auth/realtime-file-list-delegation.ts @@ -0,0 +1,39 @@ +import type { ResourceDelegatedPrincipal } from '@sim/auth/principal' +import { FILE_DOC_INTERNAL_HEADERS } from '@sim/realtime-protocol/file-doc' +import { generateId } from '@sim/utils/id' +import { + type InternalAuthPolicy, + InternalUnauthenticatedError, +} from '@/lib/api/server/routes/internal-json-route' +import { checkInternalApiKey } from '@/lib/mothership/request/http' +import { PROJECT_FILE_DELEGATION_TTL_MS } from '@/lib/projects/files/application/operations' + +/** The relay attests the socket actor and connection, bounded to observing one Project collection. */ +export const realtimeProjectFileListAuth: InternalAuthPolicy = { + async authenticate(request, params) { + if (!checkInternalApiKey(request).success) throw new InternalUnauthenticatedError() + const subjectUserId = request.headers.get(FILE_DOC_INTERNAL_HEADERS.userId) + const connectionId = request.headers.get(FILE_DOC_INTERNAL_HEADERS.connectionId) + const projectId = params.projectId + if ( + !subjectUserId?.trim() || + !connectionId?.trim() || + typeof projectId !== 'string' || + !projectId + ) { + throw new InternalUnauthenticatedError() + } + const now = Date.now() + return { + kind: 'resource_delegated', + serviceId: 'realtime', + subjectUserId, + invocation: { kind: 'realtime', connectionId }, + scope: { kind: 'file_collection_observation', entityType: 'project', entityId: projectId }, + audience: 'sim:file-list-observation', + delegationId: generateId(), + issuedAt: new Date(now), + expiresAt: new Date(now + PROJECT_FILE_DELEGATION_TTL_MS), + } + }, +} diff --git a/apps/sim/lib/billing/storage/accounting.ts b/apps/sim/lib/billing/storage/accounting.ts new file mode 100644 index 00000000000..5e9b4846f90 --- /dev/null +++ b/apps/sim/lib/billing/storage/accounting.ts @@ -0,0 +1,59 @@ +import { project } from '@sim/db/schema' +import { eq } from 'drizzle-orm' +import { + type ProjectStorageBillingContext, + resolveProjectStorageBillingContext, + resolveStorageBillingContext, + type StorageBillingContext, +} from '@/lib/billing/storage/context' +import { prepareFileStorageMutationInTx } from '@/lib/billing/storage/tracking' +import type { DbTransaction } from '@/lib/db/types' +import { lockProject, lockWorkspaceProject } from '@/lib/projects/membership' +import type { EditableFileOwner } from '@/lib/workspace-files/ownership' + +interface PreparedFileAccounting { + billing: Billing + mutation: { applyDelta(deltaBytes: number): Promise } +} + +export function prepareFileAccountingInTx( + tx: DbTransaction, + owner: { entityType: 'workspace'; entityId: string } +): Promise> +export function prepareFileAccountingInTx( + tx: DbTransaction, + owner: { entityType: 'project'; entityId: string } +): Promise> +export function prepareFileAccountingInTx( + tx: DbTransaction, + owner: EditableFileOwner +): Promise +/** + * Prepares retained-head accounting in the caller's transaction, after authorization and before + * directory, file, history or cleanup locks. Locks lifecycle, owner row, then payer; multi-owner + * callers must acquire all lifecycle locks first. Resolve from the canonical owner, never actor + * or creator. Apply one signed delta at finalization and notify only after the caller commits. + */ +export async function prepareFileAccountingInTx( + tx: DbTransaction, + owner: EditableFileOwner +): Promise { + let billing: StorageBillingContext | ProjectStorageBillingContext + if (owner.entityType === 'workspace') { + await lockWorkspaceProject(tx, owner.entityId) + billing = await resolveStorageBillingContext(owner.entityId, tx) + } else { + await lockProject(tx, owner.entityId) + const [canonical] = await tx + .select({ ownerId: project.ownerId, organizationId: project.organizationId }) + .from(project) + .where(eq(project.id, owner.entityId)) + .limit(1) + if (!canonical) throw new Error(`Project ${owner.entityId} not found for storage accounting`) + billing = await resolveProjectStorageBillingContext( + { projectId: owner.entityId, ...canonical }, + tx + ) + } + return { billing, mutation: await prepareFileStorageMutationInTx(tx, billing) } +} diff --git a/apps/sim/lib/billing/storage/index.ts b/apps/sim/lib/billing/storage/index.ts index 9e4ad54898e..e973f3f82b4 100644 --- a/apps/sim/lib/billing/storage/index.ts +++ b/apps/sim/lib/billing/storage/index.ts @@ -1,3 +1,4 @@ +export { prepareFileAccountingInTx } from './accounting' export { resolveStorageBillingContext, type StorageBillingContext, @@ -18,7 +19,6 @@ export { incrementAdmittedStorageUsageForBillingContextInTx, incrementStorageUsageForBillingContextInTx, type LegacyStorageUsageDelta, - lockWorkspaceStorageForMutationInTx, maybeNotifyStorageLimitForBillingContext, type WorkspaceStorageUsageDelta, } from './tracking' diff --git a/apps/sim/lib/billing/storage/payer-transfer.ts b/apps/sim/lib/billing/storage/payer-transfer.ts index 61d004b21d3..ca7bd062535 100644 --- a/apps/sim/lib/billing/storage/payer-transfer.ts +++ b/apps/sim/lib/billing/storage/payer-transfer.ts @@ -49,7 +49,7 @@ export interface ChangeProjectStoragePayerParams extends ProjectStorageOwnerSnap expectedCurrentOwner: Pick } -interface ChangeProjectStoragePayerResult { +export interface ChangeProjectStoragePayerResult { projectId: string billableBytes: number oldPayer: BillingEntity diff --git a/apps/sim/lib/billing/storage/project-accounting.integration.ts b/apps/sim/lib/billing/storage/project-accounting.integration.ts index 02723eaa22d..2d1043eafe4 100644 --- a/apps/sim/lib/billing/storage/project-accounting.integration.ts +++ b/apps/sim/lib/billing/storage/project-accounting.integration.ts @@ -11,6 +11,7 @@ import { sql as query } from 'drizzle-orm' import { drizzle } from 'drizzle-orm/postgres-js' import postgres, { type Sql } from 'postgres' import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { prepareFileAccountingInTx } from '@/lib/billing/storage/accounting' import { type ProjectStorageBillingContext, resolveProjectStorageBillingContext, @@ -21,10 +22,7 @@ import { changeProjectAndWorkspaceStoragePayersInTx, changeProjectStoragePayersInTx, } from '@/lib/billing/storage/payer-transfer' -import { - incrementStorageUsageForBillingContextInTx, - prepareProjectStorageMutationInTx, -} from '@/lib/billing/storage/tracking' +import { prepareFileStorageMutationInTx } from '@/lib/billing/storage/tracking' import { prepareProjectsForAccountDeletion } from '@/lib/projects/account-deletion' import { transferWorkspaceProjects } from '@/lib/projects/membership' @@ -177,7 +175,7 @@ describe('Project storage admission, transfer, and reconciliation in PostgreSQL' plan: 'team_25000', customStorageLimitGB: 2, }) - const prepared = await prepareProjectStorageMutationInTx(tx, organizationContext) + const prepared = await prepareFileStorageMutationInTx(tx, organizationContext) await prepared.applyDelta(40) await tx.execute(query`UPDATE project SET organization_id = NULL WHERE id = 'project-b'`) const personalContext = await resolveProjectStorageBillingContext( @@ -190,7 +188,7 @@ describe('Project storage admission, transfer, and reconciliation in PostgreSQL' plan: 'pro_4000', customStorageLimitGB: null, }) - const personalPrepared = await prepareProjectStorageMutationInTx(tx, personalContext) + const personalPrepared = await prepareFileStorageMutationInTx(tx, personalContext) await personalPrepared.applyDelta(20) }) const [organizationPayer] = await sql`SELECT storage_used_bytes::integer AS bytes @@ -302,7 +300,7 @@ describe('Project storage admission, transfer, and reconciliation in PostgreSQL' const releaseWrite = createDeferred() const teardownPid = createDeferred() const write = database.transaction(async (tx) => { - const prepared = await prepareProjectStorageMutationInTx(tx, { + const prepared = await prepareFileStorageMutationInTx(tx, { ...context(), organizationId: null, billingEntity: { type: 'user', id: 'user-a' }, @@ -362,23 +360,30 @@ describe('Project storage admission, transfer, and reconciliation in PostgreSQL' check( 'two Projects and a workspace share one locked quota without committing rejected file rows', async () => { - const workspaceContext: StorageBillingContext = { - ...context('project-a', 60), - workspaceId: 'workspace-a', - } + await sql`INSERT INTO member (id, organization_id, user_id, role) + VALUES ('payer-owner', 'organization-a', 'user-b', 'owner')` + await sql`INSERT INTO subscription (id, plan, reference_id, status, metadata) + VALUES ('payer-plan', 'team', 'organization-a', 'active', ${JSON.stringify({ customStorageLimitGB: 60 / 1024 ** 3 })})` const attempts = await Promise.allSettled([ ...['project-a', 'project-b'].map((projectId) => database.transaction(async (tx) => { - const prepared = await prepareProjectStorageMutationInTx(tx, context(projectId, 60)) + const { mutation: prepared } = await prepareFileAccountingInTx(tx, { + entityType: 'project', + entityId: projectId, + }) await tx.execute(query`INSERT INTO workspace_files (id, project_id, context, size_bytes) VALUES (${projectId}, ${projectId}, 'project', 40)`) await prepared.applyDelta(40) }) ), database.transaction(async (tx) => { + const { mutation } = await prepareFileAccountingInTx(tx, { + entityType: 'workspace', + entityId: 'workspace-a', + }) await tx.execute(query`INSERT INTO workspace_files (id, workspace_id, context, size_bytes) VALUES ('workspace-file', 'workspace-a', 'workspace', 40)`) - await incrementStorageUsageForBillingContextInTx(tx, workspaceContext, 40) + await mutation.applyDelta(40) }), ]) expect(attempts.filter((result) => result.status === 'fulfilled')).toHaveLength(1) @@ -390,15 +395,90 @@ describe('Project storage admission, transfer, and reconciliation in PostgreSQL' await sql`SELECT storage_used_bytes::integer AS bytes FROM organization WHERE id = 'organization-a'` expect(payer.bytes).toBe(40) expect(await sql`SELECT id FROM workspace_files`).toHaveLength(1) + const [workspaceUsage] = + await sql`SELECT storage_used_bytes::integer AS bytes FROM workspace WHERE id = 'workspace-a'` + const [workspaceHeads] = + await sql`SELECT COALESCE(sum(size_bytes), 0)::integer AS bytes FROM workspace_files WHERE workspace_id = 'workspace-a'` + expect(workspaceUsage.bytes).toBe(workspaceHeads.bytes) } ) + for (const entityType of ['workspace', 'project'] as const) { + check( + `${entityType} admits exact limit, zero and shrinking over quota with consistent ledgers`, + async () => { + const entityId = entityType === 'workspace' ? 'workspace-a' : 'project-a' + await sql`INSERT INTO member (id, organization_id, user_id, role) + VALUES ('payer-owner', 'organization-a', 'user-b', 'owner')` + await sql`INSERT INTO subscription (id, plan, reference_id, status, metadata) + VALUES ('payer-plan', 'team', 'organization-a', 'active', ${JSON.stringify({ customStorageLimitGB: 60 / 1024 ** 3 })})` + await database.transaction(async (tx) => { + const { billing, mutation } = await prepareFileAccountingInTx(tx, { + entityType, + entityId, + }) + expect(billing.billingEntity).toEqual({ type: 'organization', id: 'organization-a' }) + expect(await mutation.applyDelta(60)).toBe(60) + }) + await expect( + database.transaction(async (tx) => { + const { mutation } = await prepareFileAccountingInTx(tx, { entityType, entityId }) + await tx.execute( + query`INSERT INTO workspace_files (id, context, size_bytes) VALUES ('rejected', ${entityType}, 1)` + ) + await mutation.applyDelta(1) + }) + ).rejects.toBeInstanceOf(StorageLimitExceededError) + expect(await sql`SELECT id FROM workspace_files`).toEqual([]) + await sql`UPDATE organization SET storage_used_bytes = 100 WHERE id = 'organization-a'` + for (const [delta, expected] of [ + [0, 100], + [-20, 80], + ] as const) { + await database.transaction(async (tx) => { + const { mutation } = await prepareFileAccountingInTx(tx, { entityType, entityId }) + expect(await mutation.applyDelta(delta)).toBe(expected) + }) + } + expect( + await sql`SELECT storage_used_bytes::integer AS bytes FROM organization WHERE id = 'organization-a'` + ).toEqual([{ bytes: 80 }]) + expect( + await sql`SELECT storage_used_bytes::integer AS bytes FROM workspace WHERE id = 'workspace-a'` + ).toEqual([{ bytes: entityType === 'workspace' ? 40 : 0 }]) + } + ) + } + + check('rejects a stale workspace payer snapshot without charging either ledger', async () => { + const snapshot: StorageBillingContext = { + workspaceId: 'workspace-a', + billedAccountUserId: 'user-a', + billingEntity: { type: 'organization', id: 'organization-a' }, + plan: 'team', + customStorageLimitGB: 1, + } + await sql`UPDATE workspace SET organization_id = 'organization-b' WHERE id = 'workspace-a'` + await expect( + database.transaction(async (tx) => { + const mutation = await prepareFileStorageMutationInTx(tx, snapshot) + await mutation.applyDelta(20) + }) + ).rejects.toThrow(/payer changed/) + expect( + await sql`SELECT storage_used_bytes::integer AS bytes FROM organization ORDER BY id` + ).toEqual([{ bytes: 0 }, { bytes: 0 }]) + expect(await sql`SELECT storage_used_bytes::integer AS bytes FROM workspace`).toEqual([ + { bytes: 0 }, + ]) + }) + check( 'rejects stale owner snapshots and invalid deltas before a charge can survive', async () => { await sql`UPDATE project SET owner_id = 'user-b' WHERE id = 'project-a'` await expect( - database.transaction((tx) => prepareProjectStorageMutationInTx(tx, context())) + database.transaction((tx) => prepareFileStorageMutationInTx(tx, context())) ).rejects.toThrow(/changed/) await sql`UPDATE project SET owner_id = 'user-a' WHERE id = 'project-a'` for (const delta of [ @@ -409,14 +489,14 @@ describe('Project storage admission, transfer, and reconciliation in PostgreSQL' ]) { await expect( database.transaction(async (tx) => { - const prepared = await prepareProjectStorageMutationInTx(tx, context()) + const prepared = await prepareFileStorageMutationInTx(tx, context()) await prepared.applyDelta(delta) }) ).rejects.toThrow(/Invalid/) } await expect( database.transaction(async (tx) => { - const prepared = await prepareProjectStorageMutationInTx(tx, context()) + const prepared = await prepareFileStorageMutationInTx(tx, context()) await prepared.applyDelta(1) await prepared.applyDelta(1) }) @@ -431,7 +511,7 @@ describe('Project storage admission, transfer, and reconciliation in PostgreSQL' 'keeps current-head deltas separate from retained history and rolls back failed finalization', async () => { await database.transaction(async (tx) => { - const prepared = await prepareProjectStorageMutationInTx(tx, context()) + const prepared = await prepareFileStorageMutationInTx(tx, context()) await tx.execute( query`INSERT INTO workspace_files (id, project_id, workspace_id, context, size_bytes, deleted_at) VALUES ('file', 'project-a', NULL, 'project', 100, NULL)` ) @@ -441,7 +521,7 @@ describe('Project storage admission, transfer, and reconciliation in PostgreSQL' await prepared.applyDelta(100) }) await database.transaction(async (tx) => { - const prepared = await prepareProjectStorageMutationInTx(tx, context()) + const prepared = await prepareFileStorageMutationInTx(tx, context()) await tx.execute( query`UPDATE workspace_files SET size_bytes = 40, deleted_at = now() WHERE id = 'file'` ) @@ -449,7 +529,7 @@ describe('Project storage admission, transfer, and reconciliation in PostgreSQL' }) await expect( database.transaction(async (tx) => { - const prepared = await prepareProjectStorageMutationInTx(tx, context()) + const prepared = await prepareFileStorageMutationInTx(tx, context()) await prepared.applyDelta(10) throw new Error('finalization failed') }) @@ -458,7 +538,7 @@ describe('Project storage admission, transfer, and reconciliation in PostgreSQL' await sql`SELECT storage_used_bytes::integer AS bytes FROM organization WHERE id = 'organization-a'` expect(payer.bytes).toBe(40) await database.transaction(async (tx) => { - const prepared = await prepareProjectStorageMutationInTx(tx, context()) + const prepared = await prepareFileStorageMutationInTx(tx, context()) await tx.execute(query`DELETE FROM workspace_files WHERE id = 'file'`) await prepared.applyDelta(-40) }) @@ -537,11 +617,66 @@ describe('Project storage admission, transfer, and reconciliation in PostgreSQL' } ) + check( + 'a workspace payer transfer waits for prepared finalization and moves its committed head once', + async () => { + await sql`INSERT INTO project_workspace (project_id, workspace_id) VALUES ('project-a', 'workspace-a')` + const written = createDeferred() + const release = createDeferred() + const waiting = createDeferred() + const write = database.transaction(async (tx) => { + const { mutation } = await prepareFileAccountingInTx(tx, { + entityType: 'workspace', + entityId: 'workspace-a', + }) + await tx.execute(query`INSERT INTO workspace_files (id, workspace_id, context, size_bytes) + VALUES ('workspace-file', 'workspace-a', 'workspace', 40)`) + await mutation.applyDelta(40) + written.resolve() + await release.promise + }) + await Promise.race([written.promise, write]) + const transfer = database.transaction(async (tx) => { + const [backend] = await tx.execute<{ pid: number }>(query`SELECT pg_backend_pid() AS pid`) + waiting.resolve(backend.pid) + return changeProjectAndWorkspaceStoragePayersInTx(tx, { + projectChanges: [], + workspaceChanges: [ + { + workspaceId: 'workspace-a', + billedAccountUserId: 'user-b', + organizationId: 'organization-b', + expectedCurrentPayer: { + billedAccountUserId: 'user-a', + organizationId: 'organization-a', + }, + }, + ], + }) + }) + try { + await waitForDatabaseLock(await waiting.promise) + } finally { + release.resolve() + } + await Promise.all([write, transfer]) + expect( + await sql`SELECT id, storage_used_bytes::integer AS bytes FROM organization ORDER BY id` + ).toEqual([ + { id: 'organization-a', bytes: 0 }, + { id: 'organization-b', bytes: 40 }, + ]) + expect( + await sql`SELECT storage_used_bytes::integer AS bytes FROM workspace WHERE id = 'workspace-a'` + ).toEqual([{ bytes: 40 }]) + } + ) + check('reconciliation waits for a Project write and keeps the committed charge', async () => { const changed = createDeferred() const release = createDeferred() const write = database.transaction(async (tx) => { - const prepared = await prepareProjectStorageMutationInTx(tx, context()) + const prepared = await prepareFileStorageMutationInTx(tx, context()) await tx.execute( query`INSERT INTO workspace_files (id, project_id, workspace_id, context, size_bytes, deleted_at) VALUES ('file', 'project-a', NULL, 'project', 40, NULL)` ) @@ -578,7 +713,7 @@ describe('Project storage admission, transfer, and reconciliation in PostgreSQL' const changed = createDeferred() const release = createDeferred() const write = database.transaction(async (tx) => { - const prepared = await prepareProjectStorageMutationInTx(tx, context()) + const prepared = await prepareFileStorageMutationInTx(tx, context()) await tx.execute( query`INSERT INTO workspace_files (id, project_id, workspace_id, context, size_bytes, deleted_at) VALUES ('file', 'project-a', NULL, 'project', 40, NULL)` ) diff --git a/apps/sim/lib/billing/storage/tracking.ts b/apps/sim/lib/billing/storage/tracking.ts index 1877c46144f..85942f0ddc7 100644 --- a/apps/sim/lib/billing/storage/tracking.ts +++ b/apps/sim/lib/billing/storage/tracking.ts @@ -219,36 +219,41 @@ function assertWorkspaceStorageContext( } /** - * Establishes Project then payer locks before directory/file/version mutation. The returned - * one-shot delta belongs to this transaction; it is finalization admission, not a reservation. - * Project contribution is derived from retained file heads, so only the payer counter changes. + * Locks the asserted owner and payer before directory/file/version mutation. The caller holds + * lifecycle locks and owns the transaction. The one-shot signed delta admits finalization, not + * a reservation. Workspace files update both ledgers; Projects update only their payer ledger. */ -export async function prepareProjectStorageMutationInTx( +export async function prepareFileStorageMutationInTx( tx: DbTransaction, - context: ProjectStorageBillingContext + context: ProjectStorageBillingContext | StorageBillingContext ): Promise<{ applyDelta(deltaBytes: number): Promise }> { - await lockProject(tx, context.projectId) - const [owner] = await tx - .select({ ownerId: project.ownerId, organizationId: project.organizationId }) - .from(project) - .where(eq(project.id, context.projectId)) - .for('update') - .limit(1) - if (!owner) throw new Error(`Project ${context.projectId} not found for storage accounting`) - const billingEntity: BillingEntity = owner.organizationId - ? { type: 'organization', id: owner.organizationId } - : { type: 'user', id: owner.ownerId } - if ( - owner.ownerId !== context.ownerId || - owner.organizationId !== context.organizationId || - billingEntity.type !== context.billingEntity.type || - billingEntity.id !== context.billingEntity.id || - (!owner.organizationId && context.billedAccountUserId !== owner.ownerId) - ) { - throw new Error( - `Storage payer changed for Project ${context.projectId}; resolve a fresh billing context` - ) + if ('workspaceId' in context) { + await mutateWorkspaceStorageUsage(tx, context.workspaceId, 0, 'decrement', undefined, context) + } else { + await lockProject(tx, context.projectId) + const [owner] = await tx + .select({ ownerId: project.ownerId, organizationId: project.organizationId }) + .from(project) + .where(eq(project.id, context.projectId)) + .for('update') + .limit(1) + if (!owner) throw new Error(`Project ${context.projectId} not found for storage accounting`) + const billingEntity: BillingEntity = owner.organizationId + ? { type: 'organization', id: owner.organizationId } + : { type: 'user', id: owner.ownerId } + if ( + owner.ownerId !== context.ownerId || + owner.organizationId !== context.organizationId || + billingEntity.type !== context.billingEntity.type || + billingEntity.id !== context.billingEntity.id || + (!owner.organizationId && context.billedAccountUserId !== owner.ownerId) + ) { + throw new Error( + `Storage payer changed for Project ${context.projectId}; resolve a fresh billing context` + ) + } } + const billingEntity = context.billingEntity const currentUsage = await lockStorageUsageForMutation(tx, billingEntity) if (!Number.isSafeInteger(currentUsage) || currentUsage < 0) { throw new Error(`Invalid storage usage for payer ${getPayerKey(billingEntity)}`) @@ -259,18 +264,29 @@ export async function prepareProjectStorageMutationInTx( let applied = false return { async applyDelta(deltaBytes) { - if (applied) throw new Error('Project storage delta was already applied') - if (!Number.isSafeInteger(deltaBytes)) throw new Error('Invalid Project storage delta') + if (applied) throw new Error('File storage delta was already applied') + if (!Number.isSafeInteger(deltaBytes)) throw new Error('Invalid file storage delta') applied = true const latestUsage = await lockStorageUsageForMutation(tx, billingEntity) const nextUsage = Math.max(0, latestUsage + deltaBytes) - if (!Number.isSafeInteger(nextUsage)) throw new Error('Invalid Project storage total') + if (!Number.isSafeInteger(nextUsage)) throw new Error('Invalid file storage total') if (deltaBytes > 0 && limit !== undefined && nextUsage > limit) { throw new StorageLimitExceededError( `Storage limit exceeded. Used: ${(nextUsage / 1024 ** 3).toFixed(2)}GB, Limit: ${(limit / 1024 ** 3).toFixed(0)}GB` ) } if (deltaBytes === 0) return latestUsage + if ('workspaceId' in context) { + const result = await mutateWorkspaceStorageUsage( + tx, + context.workspaceId, + Math.abs(deltaBytes), + deltaBytes > 0 ? 'increment' : 'decrement', + limit, + context + ) + return result.updatedUsage + } if (deltaBytes < 0 && latestUsage < -deltaBytes) { logger.error('Clamping Project storage payer ledger underflow', { projectId: context.projectId, @@ -529,7 +545,7 @@ export async function applyStorageUsageDeltasInTx( } /** Locks the canonical workspace before existing-file mutations that later change its storage ledger. */ -export async function lockWorkspaceStorageForMutationInTx( +async function lockWorkspaceStorageForMutationInTx( tx: DbOrTx, workspaceId: string ): Promise { diff --git a/apps/sim/lib/collab-doc/collab-state.ts b/apps/sim/lib/collab-doc/collab-state.ts index a1e065b9249..9af5c13e919 100644 --- a/apps/sim/lib/collab-doc/collab-state.ts +++ b/apps/sim/lib/collab-doc/collab-state.ts @@ -2,7 +2,7 @@ import { createHash } from 'crypto' import { db } from '@sim/db' import { workspaceFileCollabState, workspaceFiles } from '@sim/db/schema' import { and, eq, isNull, sql } from 'drizzle-orm' -import type { DbTransaction } from '@/lib/db/types' +import type { DbOrTx, DbTransaction } from '@/lib/db/types' /** Matches the decoded size of the persist endpoint's 16 MiB base64 snapshot limit. */ export const MAX_COLLAB_DOC_STATE_BYTES = 12 * 1024 * 1024 @@ -51,7 +51,8 @@ export class CollabDocStateConflictError extends Error { */ export async function loadCollabDocState( fileId: string, - options?: { maxBytes: number } + options?: { maxBytes: number }, + executor: DbOrTx = db ): Promise { const maxBytes = Math.min( options?.maxBytes ?? MAX_COLLAB_DOC_STATE_BYTES, @@ -61,7 +62,7 @@ export async function loadCollabDocState( throw new RangeError('Collaborative document state byte limit must be a non-negative integer') } const byteCount = sql`octet_length(${workspaceFileCollabState.docState})` - const [row] = await db + const [row] = await executor .select({ byteCount, docState: sql`CASE WHEN ${byteCount} <= ${maxBytes} THEN ${workspaceFileCollabState.docState} END`, diff --git a/apps/sim/lib/collab-doc/persist.ts b/apps/sim/lib/collab-doc/persist.ts index eec7af8dc97..62440403799 100644 --- a/apps/sim/lib/collab-doc/persist.ts +++ b/apps/sim/lib/collab-doc/persist.ts @@ -132,7 +132,7 @@ export async function persistFileDoc( * never sent to that relay, so they cannot be merged into saved state. For stale content writes, * use a throwaway merge only to prove the candidate does not omit durable content. */ -function preparePersistedState( +export function preparePersistedState( docState: Uint8Array, cached: CachedCollabDocState | null, markdown: Buffer, diff --git a/apps/sim/lib/collab-doc/seed.ts b/apps/sim/lib/collab-doc/seed.ts index 00456bb5918..9b450670415 100644 --- a/apps/sim/lib/collab-doc/seed.ts +++ b/apps/sim/lib/collab-doc/seed.ts @@ -5,6 +5,7 @@ import { generateId } from '@sim/utils/id' import * as Y from 'yjs' import { assertCollabDocStateSize, + type CachedCollabDocState, CollabDocStateConflictError, commitCollabDocState, hashMarkdown, @@ -86,23 +87,7 @@ export async function buildFileDocSeed( /** An unavailable cache is not an absent document: retry without minting a new history. */ const stored = await loadCollabDocState(fileId) seedSignal.throwIfAborted() - let update: Uint8Array - if (stored?.sourceHash === sourceHash) { - update = prepareCachedSeed(stored.docState) - } else { - const { frontmatter, body } = splitFrontmatter(buffer.toString('utf-8')) - const ydoc = resumeDocument(fileId, stored?.docState, body) - try { - const config = ydoc.getMap(FILE_DOC_SEED.configMap) - config.set(FILE_DOC_SEED.flag, true) - config.set(FILE_DOC_SEED.frontmatterKey, frontmatter) - ensureDocumentIdentity(ydoc) - update = Y.encodeStateAsUpdate(ydoc) - } finally { - ydoc.destroy() - } - } - assertCollabDocStateSize(update) + const update = prepareFileDocSeed(fileId, buffer, stored) seedSignal.throwIfAborted() const result = await commitCollabDocState(workspaceId, fileId, version, { @@ -117,6 +102,33 @@ export async function buildFileDocSeed( throw new CollabDocStateConflictError(fileId) } +/** Build a bounded seed while preserving the cached document identity across durable edits. */ +export function prepareFileDocSeed( + fileId: string, + buffer: Buffer, + stored: CachedCollabDocState | null +): Uint8Array { + const sourceHash = hashMarkdown(buffer) + let update: Uint8Array + if (stored?.sourceHash === sourceHash) { + update = prepareCachedSeed(stored.docState) + } else { + const { frontmatter, body } = splitFrontmatter(buffer.toString('utf-8')) + const ydoc = resumeDocument(fileId, stored?.docState, body) + try { + const config = ydoc.getMap(FILE_DOC_SEED.configMap) + config.set(FILE_DOC_SEED.flag, true) + config.set(FILE_DOC_SEED.frontmatterKey, frontmatter) + ensureDocumentIdentity(ydoc) + update = Y.encodeStateAsUpdate(ydoc) + } finally { + ydoc.destroy() + } + } + assertCollabDocStateSize(update) + return update +} + /** * Reconcile external content into the existing history. Recreating equivalent Markdown in a new * Y.Doc would assign unrelated item identities and duplicate content when old clients reconnect. diff --git a/apps/sim/lib/core/application/operation.ts b/apps/sim/lib/core/application/operation.ts index ba2721ca1ae..2e16d05ec3d 100644 --- a/apps/sim/lib/core/application/operation.ts +++ b/apps/sim/lib/core/application/operation.ts @@ -115,12 +115,12 @@ export type PrincipalKind = Exclude< * A principal kind a non-workspace operation may name. `delegated` is excluded * on purpose: a delegated principal is only meaningful alongside a * `delegatedServices` policy and the workspace, audience, and expiry re-checks - * that {@link defineWorkspaceOperation} exists to carry. An operation that needs - * delegation is a workspace operation. + * that {@link defineWorkspaceOperation} exists to carry. Organization and resource + * delegation likewise require their own domain authorization policies. */ export type UndelegatedPrincipalKind = Exclude< PrincipalKind, - 'delegated' | 'organization_delegated' + 'delegated' | 'organization_delegated' | 'resource_delegated' > /** diff --git a/apps/sim/lib/core/application/organization-authorization.ts b/apps/sim/lib/core/application/organization-authorization.ts index 30b115b5096..d3469969343 100644 --- a/apps/sim/lib/core/application/organization-authorization.ts +++ b/apps/sim/lib/core/application/organization-authorization.ts @@ -63,7 +63,8 @@ export async function requireOrganizationMembership( ) } -async function requireOrganizationSubjectMembership( +/** Rechecks membership for a subject whose principal and delegation were already validated. */ +export async function requireOrganizationSubjectMembership( userId: string, organizationId: string, minimumRole: 'member' | 'admin', diff --git a/apps/sim/lib/core/application/resource-delegation.test.ts b/apps/sim/lib/core/application/resource-delegation.test.ts new file mode 100644 index 00000000000..1f8714464ff --- /dev/null +++ b/apps/sim/lib/core/application/resource-delegation.test.ts @@ -0,0 +1,304 @@ +import { + parsePrincipal, + resolvePrincipalAttribution, + resolvePrincipalAuditAttribution, + resolvePrincipalSubjectUserId, + serializePrincipal, +} from '@sim/auth/principal' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { requireResourceDelegation } from '@/lib/core/application/resource-delegation' +import { defineWorkspaceOperation } from '@/lib/core/application/workspace-operation' + +const NOW = new Date('2026-10-03T12:00:00Z') +const principal = { + kind: 'resource_delegated', + serviceId: 'copilot', + subjectUserId: 'acting-user', + delegationId: 'tool-call', + audience: 'sim:files', + issuedAt: NOW, + expiresAt: new Date('2026-10-03T12:01:00Z'), + invocation: { kind: 'chat', chatId: 'chat' }, + scope: { kind: 'entity', entityType: 'project', entityId: 'owner' }, +} as const +const policy = { + audience: 'sim:files', + services: ['copilot', 'realtime'], + maxTtlMs: 60_000, + scope: principal.scope, +} as const + +describe('resource delegation authorization', () => { + beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(NOW) + }) + afterEach(() => vi.useRealTimers()) + + it('allows owner authority to narrow to a file and preserves the actual subject', () => { + expect( + requireResourceDelegation(principal, { + ...policy, + scope: { ...principal.scope, fileId: 'file' }, + }) + ).toBe('acting-user') + expect(resolvePrincipalSubjectUserId(principal)).toBe('acting-user') + expect( + resolvePrincipalAttribution(principal, { workspaceBillingOwnerUserId: 'billing-owner' }) + .attributedUserId + ).toBe('acting-user') + expect(resolvePrincipalAuditAttribution(principal)).toMatchObject({ + actorId: 'acting-user', + actor: { kind: 'resource_delegated', serviceId: 'copilot', delegationId: 'tool-call' }, + }) + }) + + it('allows discovery without a selected workspace', () => { + expect( + requireResourceDelegation( + { ...principal, scope: { kind: 'project_discovery' } }, + { ...policy, scope: { kind: 'project_discovery' } } + ) + ).toBe('acting-user') + }) + + it.each([ + ['another principal kind', { kind: 'session', userId: 'acting-user', sessionId: 'session' }], + ['wrong audience', { ...principal, audience: 'sim:workflows' }], + ['wrong service', { ...principal, serviceId: 'executor' }], + ['missing subject', { ...principal, subjectUserId: '' }], + ['missing delegation ID', { ...principal, delegationId: ' ' }], + ['invalid issuance', { ...principal, issuedAt: new Date('invalid') }], + ['invalid expiry', { ...principal, expiresAt: new Date('invalid') }], + ['future issuance', { ...principal, issuedAt: new Date('2026-10-03T12:00:01Z') }], + ['expired grant', { ...principal, expiresAt: NOW }], + ['excessive lifetime', { ...principal, expiresAt: new Date('2026-10-03T12:01:01Z') }], + ['missing chat binding', { ...principal, invocation: { kind: 'chat', chatId: '' } }], + [ + 'missing workspace binding', + { ...principal, invocation: { kind: 'workspace', workspaceId: '' } }, + ], + [ + 'wrong invocation service', + { ...principal, invocation: { kind: 'realtime', connectionId: 'connection' } }, + ], + [ + 'another owner type', + { ...principal, scope: { ...principal.scope, entityType: 'workspace' } }, + ], + ['another owner ID', { ...principal, scope: { ...principal.scope, entityId: 'other' } }], + ['missing owner ID', { ...principal, scope: { ...principal.scope, entityId: '' } }], + ['empty file bound', { ...principal, scope: { ...principal.scope, fileId: '' } }], + ['discovery used for files', { ...principal, scope: { kind: 'project_discovery' } }], + [ + 'file bound widened to owner', + { ...principal, scope: { ...principal.scope, fileId: 'file' } }, + ], + ])('refuses %s before granting a subject', (_name, candidate) => { + expect(() => requireResourceDelegation(candidate as never, policy)).toThrow( + 'Resource delegation is no longer valid' + ) + }) + + it('refuses a service that the operation does not admit', () => { + expect(() => + requireResourceDelegation(principal, { ...policy, services: ['realtime'] }) + ).toThrow('Resource delegation is no longer valid') + }) + + it('does not let a file grant move to a sibling file', () => { + expect(() => + requireResourceDelegation( + { ...principal, scope: { ...principal.scope, fileId: 'first' } }, + { ...policy, scope: { ...principal.scope, fileId: 'second' } } + ) + ).toThrow('Resource delegation is no longer valid') + }) + + it('does not treat matching unknown owner types as authority', () => { + const scope = { kind: 'entity', entityType: 'unknown', entityId: 'owner' } + expect(() => + requireResourceDelegation({ ...principal, scope } as never, { ...policy, scope } as never) + ).toThrow() + }) + + it('does not let entity authority perform project discovery', () => { + expect(() => + requireResourceDelegation(principal, { ...policy, scope: { kind: 'project_discovery' } }) + ).toThrow('Resource delegation is no longer valid') + }) + + it('requires realtime authority to identify a connection and one file', () => { + const realtime = { + ...principal, + serviceId: 'realtime', + invocation: { kind: 'realtime', connectionId: 'connection' }, + scope: { ...principal.scope, fileId: 'file' }, + } as const + expect(requireResourceDelegation(realtime, { ...policy, scope: realtime.scope })).toBe( + 'acting-user' + ) + for (const candidate of [ + { ...realtime, scope: principal.scope }, + { ...realtime, scope: { kind: 'project_discovery' } }, + { ...realtime, invocation: principal.invocation }, + { ...realtime, invocation: { kind: 'realtime', connectionId: '' } }, + ]) { + expect(() => + requireResourceDelegation( + candidate as never, + { ...policy, scope: candidate.scope } as never + ) + ).toThrow('Resource delegation is no longer valid') + } + }) + + it('keeps realtime collection observation separate from entity and file authority', () => { + const scope = { + kind: 'file_collection_observation', + entityType: 'project', + entityId: 'owner', + } as const + const observer = { + ...principal, + audience: 'sim:file-list-observation', + serviceId: 'realtime', + invocation: { kind: 'realtime', connectionId: 'socket' }, + scope, + } as const + const observation = { + ...policy, + audience: observer.audience, + services: ['realtime'], + scope, + } as const + expect(requireResourceDelegation(observer, observation)).toBe('acting-user') + for (const required of [ + { kind: 'entity', entityType: 'project', entityId: 'owner' }, + { kind: 'entity', entityType: 'project', entityId: 'owner', fileId: 'file' }, + { ...scope, entityId: 'another-owner' }, + { kind: 'entity', entityType: 'workspace', entityId: 'owner' }, + ]) { + expect(() => + requireResourceDelegation(observer, { ...observation, scope: required } as never) + ).toThrow('Resource delegation is no longer valid') + } + expect(() => + requireResourceDelegation( + { + ...observer, + scope: { kind: 'entity', entityType: 'project', entityId: 'owner' }, + } as never, + observation + ) + ).toThrow('Resource delegation is no longer valid') + expect(() => + requireResourceDelegation( + { ...observer, scope: { ...scope, entityType: 'workspace' } } as never, + observation + ) + ).toThrow('Resource delegation is no longer valid') + expect(() => + requireResourceDelegation( + { ...observer, scope: { ...scope, fileId: 'invented-file' } } as never, + observation + ) + ).toThrow('Resource delegation is no longer valid') + expect(() => + requireResourceDelegation( + { ...observer, serviceId: 'copilot', invocation: principal.invocation }, + { ...observation, services: ['copilot'] } + ) + ).toThrow('Resource delegation is no longer valid') + }) + + it('cannot be admitted to workspace operations or persisted as workflow authority', () => { + expect(() => + defineWorkspaceOperation({ + id: 'files.read', + minimumRole: 'read', + workspaceApiKey: 'deny', + principalKinds: ['resource_delegated'], + capability: 'none', + }) + ).toThrow('cannot accept resource delegation') + expect(() => serializePrincipal(principal as never)).toThrow( + 'Principal cannot be persisted for workflow execution' + ) + expect(() => parsePrincipal({ version: 1, principal })).toThrow( + 'Resource delegation cannot be persisted for workflow execution' + ) + }) + + it('binds copy to both owners, the exact requested selection, and one destination folder', () => { + const scope = { + kind: 'file_copy', + source: { + owner: { entityType: 'workspace', entityId: 'source-workspace' }, + fileIds: ['one', 'two'], + folderIds: ['folder'], + }, + destination: { + owner: { entityType: 'project', entityId: 'destination-project' }, + folderId: null, + }, + } as const + const copy = { ...principal, audience: 'sim:files:copy', scope } + const required = { ...policy, audience: copy.audience, services: ['copilot'], scope } as const + expect(requireResourceDelegation(copy, required)).toBe('acting-user') + expect( + requireResourceDelegation(copy, { + ...required, + scope: { ...scope, source: { ...scope.source, fileIds: ['two', 'one'] } }, + }) + ).toBe('acting-user') + for (const changed of [ + { + ...scope, + source: { ...scope.source, owner: { ...scope.source.owner, entityId: 'other' } }, + }, + { ...scope, source: { ...scope.source, fileIds: ['one'] } }, + { ...scope, source: { ...scope.source, fileIds: ['one', 'two', 'extra'] } }, + { ...scope, source: { ...scope.source, folderIds: ['other-folder'] } }, + { + ...scope, + destination: { + ...scope.destination, + owner: { ...scope.destination.owner, entityId: 'other' }, + }, + }, + { ...scope, destination: { ...scope.destination, folderId: 'other-folder' } }, + ]) + expect(() => requireResourceDelegation(copy, { ...required, scope: changed })).toThrow( + 'Resource delegation is no longer valid' + ) + for (const malformed of [ + { ...scope, source: { ...scope.source, fileIds: [], folderIds: [] } }, + { ...scope, source: { ...scope.source, fileIds: ['one', 'one'] } }, + { ...scope, source: { ...scope.source, folderIds: [''] } }, + { + ...scope, + source: { ...scope.source, owner: { entityType: 'organization', entityId: 'org' } }, + }, + { ...scope, source: { ...scope.source, extra: true } }, + { ...scope, destination: { owner: scope.destination.owner } }, + { ...scope, destination: { ...scope.destination, folderPath: 'unresolved' } }, + ]) + expect(() => + requireResourceDelegation({ ...copy, scope: malformed } as never, required) + ).toThrow('Resource delegation is no longer valid') + for (const candidate of [ + { ...principal, audience: copy.audience }, + { ...copy, serviceId: 'realtime', invocation: { kind: 'realtime', connectionId: 'socket' } }, + ]) + expect(() => requireResourceDelegation(candidate as never, required)).toThrow( + 'Resource delegation is no longer valid' + ) + expect(() => requireResourceDelegation(copy, { ...policy, audience: copy.audience })).toThrow( + 'Resource delegation is no longer valid' + ) + expect(() => + requireResourceDelegation(copy, { ...required, scope: { kind: 'project_discovery' } }) + ).toThrow('Resource delegation is no longer valid') + }) +}) diff --git a/apps/sim/lib/core/application/resource-delegation.ts b/apps/sim/lib/core/application/resource-delegation.ts new file mode 100644 index 00000000000..4956229fda6 --- /dev/null +++ b/apps/sim/lib/core/application/resource-delegation.ts @@ -0,0 +1,179 @@ +import type { + Principal, + ResourceDelegatedPrincipal, + ResourceDelegationScope, + ResourceFileCopyScope, +} from '@sim/auth/principal' +import { isRecordLike } from '@sim/utils/object' +import { OrchestrationError } from '@/lib/core/orchestration/types' + +export interface ResourceDelegationPolicy { + audience: string + services: readonly ResourceDelegatedPrincipal['serviceId'][] + scope: ResourceDelegationScope + maxTtlMs: number +} + +function isIdentity(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 && value === value.trim() +} + +function isCopyOwner(value: unknown): boolean { + return ( + isRecordLike(value) && + (value.entityType === 'workspace' || value.entityType === 'project') && + isIdentity(value.entityId) && + Object.keys(value).length === 2 && + Object.keys(value).every((key) => key === 'entityType' || key === 'entityId') + ) +} + +function isSelectionIds(value: unknown): value is string[] { + return ( + Array.isArray(value) && + value.length <= 1_000 && + Array.from(value).every(isIdentity) && + new Set(value).size === value.length + ) +} + +/** Validates the paired scope shared by delegation admission and the copy application boundary. */ +export function isResourceFileCopyScope(scope: unknown): scope is ResourceFileCopyScope { + if (!isRecordLike(scope) || scope.kind !== 'file_copy') return false + const { source, destination } = scope + return ( + Object.keys(scope).length === 3 && + Object.keys(scope).every((key) => ['kind', 'source', 'destination'].includes(key)) && + isRecordLike(source) && + Object.keys(source).length === 3 && + Object.keys(source).every((key) => ['owner', 'fileIds', 'folderIds'].includes(key)) && + isCopyOwner(source.owner) && + isSelectionIds(source.fileIds) && + isSelectionIds(source.folderIds) && + source.fileIds.length + source.folderIds.length > 0 && + isRecordLike(destination) && + Object.keys(destination).length === 2 && + Object.keys(destination).every((key) => key === 'owner' || key === 'folderId') && + isCopyOwner(destination.owner) && + (destination.folderId === null || isIdentity(destination.folderId)) + ) +} + +function isValidScope(scope: unknown): scope is ResourceDelegationScope { + if (!isRecordLike(scope)) return false + if (scope.kind === 'project_discovery') return Object.keys(scope).length === 1 + if (scope.kind === 'file_copy') return isResourceFileCopyScope(scope) + if (scope.kind === 'file_collection_observation') { + return ( + scope.entityType === 'project' && + isIdentity(scope.entityId) && + Object.keys(scope).length === 3 && + Object.keys(scope).every((key) => ['kind', 'entityType', 'entityId'].includes(key)) + ) + } + return ( + scope.kind === 'entity' && + (scope.entityType === 'workspace' || + scope.entityType === 'project' || + scope.entityType === 'organization' || + scope.entityType === 'user') && + isIdentity(scope.entityId) && + (scope.fileId === undefined || isIdentity(scope.fileId)) && + Object.keys(scope).every((key) => ['kind', 'entityType', 'entityId', 'fileId'].includes(key)) + ) +} + +function isValidInvocation(principal: ResourceDelegatedPrincipal): boolean { + const invocation = principal.invocation + if (!isRecordLike(invocation) || Object.keys(invocation).length !== 2) return false + if (principal.serviceId === 'copilot') { + return ( + principal.scope.kind !== 'file_collection_observation' && + ((invocation.kind === 'chat' && isIdentity(invocation.chatId)) || + (invocation.kind === 'workspace' && isIdentity(invocation.workspaceId))) + ) + } + return ( + principal.serviceId === 'realtime' && + invocation.kind === 'realtime' && + isIdentity(invocation.connectionId) && + ((principal.scope.kind === 'entity' && isIdentity(principal.scope.fileId)) || + principal.scope.kind === 'file_collection_observation') + ) +} + +function matchesScope( + granted: ResourceDelegationScope, + required: ResourceDelegationScope +): boolean { + if (granted.kind !== required.kind) return false + if (granted.kind === 'project_discovery') return true + if (granted.kind === 'file_collection_observation') { + return ( + required.kind === 'file_collection_observation' && + granted.entityType === required.entityType && + granted.entityId === required.entityId + ) + } + if (granted.kind === 'file_copy') { + if (required.kind !== 'file_copy') return false + const requestedFiles = new Set(required.source.fileIds) + const requestedFolders = new Set(required.source.folderIds) + return ( + granted.source.owner.entityType === required.source.owner.entityType && + granted.source.owner.entityId === required.source.owner.entityId && + granted.destination.owner.entityType === required.destination.owner.entityType && + granted.destination.owner.entityId === required.destination.owner.entityId && + granted.destination.folderId === required.destination.folderId && + granted.source.fileIds.length === requestedFiles.size && + granted.source.fileIds.every((id) => requestedFiles.has(id)) && + granted.source.folderIds.length === requestedFolders.size && + granted.source.folderIds.every((id) => requestedFolders.has(id)) + ) + } + return ( + required.kind === 'entity' && + granted.entityType === required.entityType && + granted.entityId === required.entityId && + (granted.fileId === undefined || granted.fileId === required.fileId) + ) +} + +/** Checks delegation bounds; the domain must still authorize the invocation and current user access. */ +export function requireResourceDelegation( + principal: Principal, + policy: ResourceDelegationPolicy +): string { + if ( + !isIdentity(policy.audience) || + !Number.isSafeInteger(policy.maxTtlMs) || + policy.maxTtlMs <= 0 || + policy.services.length === 0 || + !policy.services.every((service) => service === 'copilot' || service === 'realtime') || + !isValidScope(policy.scope) + ) { + throw new Error('Resource delegation requires a bounded operation policy') + } + + const now = Date.now() + if ( + principal.kind !== 'resource_delegated' || + !policy.services.includes(principal.serviceId) || + principal.audience !== policy.audience || + !isIdentity(principal.subjectUserId) || + !isIdentity(principal.delegationId) || + !(principal.issuedAt instanceof Date) || + !(principal.expiresAt instanceof Date) || + !Number.isFinite(principal.issuedAt.getTime()) || + !Number.isFinite(principal.expiresAt.getTime()) || + principal.issuedAt.getTime() > now || + principal.expiresAt.getTime() <= now || + principal.expiresAt.getTime() - principal.issuedAt.getTime() > policy.maxTtlMs || + !isValidScope(principal.scope) || + !isValidInvocation(principal) || + !matchesScope(principal.scope, policy.scope) + ) { + throw new OrchestrationError('forbidden', 'Resource delegation is no longer valid') + } + return principal.subjectUserId +} diff --git a/apps/sim/lib/core/application/workspace-authorization.ts b/apps/sim/lib/core/application/workspace-authorization.ts index e6cd71faeda..721c59d7007 100644 --- a/apps/sim/lib/core/application/workspace-authorization.ts +++ b/apps/sim/lib/core/application/workspace-authorization.ts @@ -55,6 +55,7 @@ export function capabilityGovernedPrincipalUserId(principal: Principal): string case 'slack_installation': case 'slack_app': return null + case 'resource_delegated': case 'organization_delegated': return principal.subjectUserId case 'delegated': { @@ -425,6 +426,7 @@ export async function authorizeWorkspaceOperation { - const { workspaceFileLiveDocOutboxHandlers } = await import( - '@/lib/uploads/contexts/workspace/workspace-file-live-doc-outbox' - ) - return workspaceFileLiveDocOutboxHandlers + const { fileLiveDocOutboxHandlers } = await import('@/lib/uploads/server/live-doc-outbox') + return fileLiveDocOutboxHandlers }, }, { diff --git a/apps/sim/lib/core/security/csp.ts b/apps/sim/lib/core/security/csp.ts index f2d3c558573..fbc78071c4f 100644 --- a/apps/sim/lib/core/security/csp.ts +++ b/apps/sim/lib/core/security/csp.ts @@ -11,6 +11,10 @@ import { isDev, isHosted, isReactGrabEnabled } from '../config/env-flags' * relative import that itself pulls in no `@/` paths (../../consent/constants). */ +/** File responses select CSP at runtime; JSON and error responses retain the main policy. */ +export const FILE_DELIVERY_CSP_PATH_PATTERN = + '/(?:api/files/serve/.+|api/files/public/[^/]+/(?:content|inline)|api/projects/[^/]+/files/(?:inline|download|[^/]+/(?:content|artifact|export|versions/[^/]+/content))|api/workspaces/[^/]+/files/(?:inline|download|[^/]+/versions/[^/]+/content)|api/v2/projects/[^/]+/files/(?:bulk-download|[^/]+/(?:content|export|versions/[^/]+/content))|api/v2/files/(?:bulk-download|[^/]+(?:/versions/[^/]+/content)?))' + const DEFAULT_SOCKET_URL = 'http://localhost:3002' const DEFAULT_OLLAMA_URL = 'http://localhost:11434' diff --git a/apps/sim/lib/core/security/deployment-auth.ts b/apps/sim/lib/core/security/deployment-auth.ts index 23e94fdcdb1..d178946c0ba 100644 --- a/apps/sim/lib/core/security/deployment-auth.ts +++ b/apps/sim/lib/core/security/deployment-auth.ts @@ -1,76 +1,20 @@ import { createLogger } from '@sim/logger' -import { safeCompare } from '@sim/security/compare' -import { normalizeEmail } from '@sim/utils/string' import type { NextRequest } from 'next/server' -import type { TokenBucketConfig } from '@/lib/core/rate-limiter' -import { RateLimiter } from '@/lib/core/rate-limiter' import { type DeploymentAuthKind, type DeploymentAuthResource, deploymentAuthCookieName, - isEmailAllowed, - readDeploymentAuthToken, } from '@/lib/core/security/deployment' -import { decryptSecret } from '@/lib/core/security/encryption' +import { + type DeploymentAuthBody, + type DeploymentAuthResult, + validateDeploymentCredentials, +} from '@/lib/core/security/deployment-credentials' import { getClientIp } from '@/lib/core/utils/request' const logger = createLogger('DeploymentAuth') -const rateLimiter = new RateLimiter() - -/** - * Throttles unauthenticated password guesses per client IP against a single - * deployment, mirroring the OTP/SSO IP limits. - */ -const PASSWORD_IP_RATE_LIMIT: TokenBucketConfig = { - maxTokens: 10, - refillRate: 10, - refillIntervalMs: 15 * 60_000, -} - -/** - * Caps guesses against one resource independently of client identity. This is - * the backstop for distributed attempts and for requests whose proxy chain - * cannot be resolved safely. - */ -const PASSWORD_RESOURCE_RATE_LIMIT: TokenBucketConfig = { - maxTokens: 100, - refillRate: 100, - refillIntervalMs: 15 * 60_000, -} - -function passwordRateLimitResult( - retryAfterMs: number | undefined, - fallbackMs: number -): DeploymentAuthResult { - return { - authorized: false, - error: 'Too many attempts. Please try again later.', - status: 429, - retryAfterMs: retryAfterMs ?? fallbackMs, - } -} - -export interface DeploymentAuthBody { - password?: string - email?: string - input?: unknown -} - -export interface DeploymentAuthResult { - authorized: boolean - authenticatedEmail?: string - error?: string - status?: number - retryAfterMs?: number -} - -/** - * Shared password/email/SSO gate for deployed resources. The `cookiePrefix` - * selects the auth cookie (`${cookiePrefix}_auth_${id}`) and the rate-limit - * namespace so chat deployments and public file shares share one code path. Both - * support all four modes: `'public'`, `'password'`, `'email'`, and `'sso'`. - */ +/** Adapts authenticated session identity and HTTP credentials to the common deployment gate. */ export async function validateDeploymentAuth( requestId: string, resource: DeploymentAuthResource, @@ -78,153 +22,30 @@ export async function validateDeploymentAuth( parsedBody: DeploymentAuthBody | null | undefined, cookiePrefix: DeploymentAuthKind ): Promise { - const authType = resource.authType || 'public' - - if (authType === 'public') { - return { authorized: true } - } - - if (authType === 'password' || authType === 'email') { - const authCookie = request.cookies.get(deploymentAuthCookieName(cookiePrefix, resource.id)) - - if (authCookie) { - const claims = await readDeploymentAuthToken({ token: authCookie.value, resource }) - if (claims) return { authorized: true, ...claims } - } - } - - if (authType === 'password') { - if (request.method === 'GET') { - return { authorized: false, error: 'auth_required_password' } - } - + let sessionEmail: string | null | undefined + let sessionPresent = false + if (resource.authType === 'sso' && (request.method === 'GET' || parsedBody)) { try { - if (!parsedBody) { - return { authorized: false, error: 'Password is required' } - } - - const { password, input } = parsedBody - - if (input && !password) { - return { authorized: false, error: 'auth_required_password' } - } - - if (!password) { - return { authorized: false, error: 'Password is required' } - } - - if (!resource.password) { - logger.error(`[${requestId}] No password set for password-protected ${resource.id}`) - return { authorized: false, error: 'Authentication configuration error' } - } - - const ip = getClientIp(request) - if (ip) { - const ipRateLimit = await rateLimiter.checkRateLimitDirect( - `${cookiePrefix}-password:ip:${resource.id}:${ip}`, - PASSWORD_IP_RATE_LIMIT, - { failClosed: true } - ) - if (!ipRateLimit.allowed) { - logger.warn(`[${requestId}] Password attempt IP rate limit exceeded`, { - resourceId: resource.id, - cookiePrefix, - ip, - }) - return passwordRateLimitResult( - ipRateLimit.retryAfterMs, - PASSWORD_IP_RATE_LIMIT.refillIntervalMs - ) - } - } - - const resourceRateLimit = await rateLimiter.checkRateLimitDirect( - `${cookiePrefix}-password:resource:${resource.id}`, - PASSWORD_RESOURCE_RATE_LIMIT, - { failClosed: true } - ) - if (!resourceRateLimit.allowed) { - logger.warn(`[${requestId}] Password attempt resource rate limit exceeded`, { - resourceId: resource.id, - cookiePrefix, - }) - return passwordRateLimitResult( - resourceRateLimit.retryAfterMs, - PASSWORD_RESOURCE_RATE_LIMIT.refillIntervalMs - ) - } - - const { decrypted } = await decryptSecret(resource.password) - if (!safeCompare(password, decrypted)) { - return { authorized: false, error: 'Invalid password' } - } - - return { authorized: true } - } catch (error) { - logger.error(`[${requestId}] Error validating password:`, error) - return { authorized: false, error: 'Authentication error' } - } - } - - if (authType === 'email') { - if (request.method === 'GET') { - return { authorized: false, error: 'auth_required_email' } - } - - try { - if (!parsedBody) { - return { authorized: false, error: 'Email is required' } - } - - const { email, input } = parsedBody - - if (input && !email) { - return { authorized: false, error: 'auth_required_email' } - } - - if (!email) { - return { authorized: false, error: 'Email is required' } - } - - if (isEmailAllowed(email, resource.allowedEmails)) { - return { authorized: false, error: 'otp_required' } - } - - return { authorized: false, error: 'Email not authorized' } - } catch (error) { - logger.error(`[${requestId}] Error validating email:`, error) - return { authorized: false, error: 'Authentication error' } - } - } - - if (authType === 'sso') { - try { - if (request.method !== 'GET' && !parsedBody) { - return { authorized: false, error: 'SSO authentication is required' } - } - const { getSession } = await import('@/lib/auth') const session = await getSession() - - if (!session || !session.user) { - return { authorized: false, error: 'auth_required_sso' } - } - - const userEmail = session.user.email - if (!userEmail) { - return { authorized: false, error: 'SSO session does not contain email' } - } - - if (isEmailAllowed(userEmail, resource.allowedEmails)) { - return { authorized: true, authenticatedEmail: normalizeEmail(userEmail) } - } - - return { authorized: false, error: 'Your email is not authorized to access this resource' } + sessionPresent = Boolean(session?.user) + sessionEmail = session?.user?.email } catch (error) { logger.error(`[${requestId}] Error validating SSO:`, error) return { authorized: false, error: 'SSO authentication error' } } } - - return { authorized: false, error: 'Unsupported authentication type' } + return validateDeploymentCredentials( + requestId, + resource, + { + method: request.method, + authToken: request.cookies.get(deploymentAuthCookieName(cookiePrefix, resource.id))?.value, + clientIp: getClientIp(request), + sessionPresent, + sessionEmail, + }, + parsedBody, + cookiePrefix + ) } diff --git a/apps/sim/lib/core/security/deployment-credentials.ts b/apps/sim/lib/core/security/deployment-credentials.ts new file mode 100644 index 00000000000..1faa9f9858e --- /dev/null +++ b/apps/sim/lib/core/security/deployment-credentials.ts @@ -0,0 +1,233 @@ +import { createLogger } from '@sim/logger' +import { safeCompare } from '@sim/security/compare' +import { normalizeEmail } from '@sim/utils/string' +import type { TokenBucketConfig } from '@/lib/core/rate-limiter' +import { RateLimiter } from '@/lib/core/rate-limiter' +import { + type DeploymentAuthKind, + type DeploymentAuthResource, + isEmailAllowed, + readDeploymentAuthToken, +} from '@/lib/core/security/deployment' +import { decryptSecret } from '@/lib/core/security/encryption' + +const logger = createLogger('DeploymentAuth') + +const rateLimiter = new RateLimiter() + +/** + * Throttles unauthenticated password guesses per client IP against a single + * deployment, mirroring the OTP/SSO IP limits. + */ +const PASSWORD_IP_RATE_LIMIT: TokenBucketConfig = { + maxTokens: 10, + refillRate: 10, + refillIntervalMs: 15 * 60_000, +} + +/** + * Caps guesses against one resource independently of client identity. This is + * the backstop for distributed attempts and for requests whose proxy chain + * cannot be resolved safely. + */ +const PASSWORD_RESOURCE_RATE_LIMIT: TokenBucketConfig = { + maxTokens: 100, + refillRate: 100, + refillIntervalMs: 15 * 60_000, +} + +function passwordRateLimitResult( + retryAfterMs: number | undefined, + fallbackMs: number +): DeploymentAuthResult { + return { + authorized: false, + error: 'Too many attempts. Please try again later.', + status: 429, + retryAfterMs: retryAfterMs ?? fallbackMs, + } +} + +export interface DeploymentAuthBody { + password?: string + email?: string + input?: unknown +} + +export interface DeploymentAuthResult { + authorized: boolean + authenticatedEmail?: string + error?: string + status?: number + retryAfterMs?: number +} + +/** Verified session context and raw request credentials; callers obtain session identity from authentication. */ +export interface DeploymentCredentialContext { + method: string + authToken?: string + clientIp?: string | null + sessionPresent?: boolean + sessionEmail?: string | null +} + +/** + * Shared password/email/SSO gate for deployed resources. The `cookiePrefix` + * selects the auth cookie (`${cookiePrefix}_auth_${id}`) and the rate-limit + * namespace so chat deployments and public file shares share one code path. Both + * support all four modes: `'public'`, `'password'`, `'email'`, and `'sso'`. + */ +export async function validateDeploymentCredentials( + requestId: string, + resource: DeploymentAuthResource, + request: DeploymentCredentialContext, + parsedBody: DeploymentAuthBody | null | undefined, + cookiePrefix: DeploymentAuthKind +): Promise { + const authType = resource.authType || 'public' + + if (authType === 'public') { + return { authorized: true } + } + + if (authType === 'password' || authType === 'email') { + const authCookie = request.authToken + + if (authCookie) { + const claims = await readDeploymentAuthToken({ token: authCookie, resource }) + if (claims) return { authorized: true, ...claims } + } + } + + if (authType === 'password') { + if (request.method === 'GET') { + return { authorized: false, error: 'auth_required_password' } + } + + try { + if (!parsedBody) { + return { authorized: false, error: 'Password is required' } + } + + const { password, input } = parsedBody + + if (input && !password) { + return { authorized: false, error: 'auth_required_password' } + } + + if (!password) { + return { authorized: false, error: 'Password is required' } + } + + if (!resource.password) { + logger.error(`[${requestId}] No password set for password-protected ${resource.id}`) + return { authorized: false, error: 'Authentication configuration error' } + } + + const ip = request.clientIp + if (ip) { + const ipRateLimit = await rateLimiter.checkRateLimitDirect( + `${cookiePrefix}-password:ip:${resource.id}:${ip}`, + PASSWORD_IP_RATE_LIMIT, + { failClosed: true } + ) + if (!ipRateLimit.allowed) { + logger.warn(`[${requestId}] Password attempt IP rate limit exceeded`, { + resourceId: resource.id, + cookiePrefix, + ip, + }) + return passwordRateLimitResult( + ipRateLimit.retryAfterMs, + PASSWORD_IP_RATE_LIMIT.refillIntervalMs + ) + } + } + + const resourceRateLimit = await rateLimiter.checkRateLimitDirect( + `${cookiePrefix}-password:resource:${resource.id}`, + PASSWORD_RESOURCE_RATE_LIMIT, + { failClosed: true } + ) + if (!resourceRateLimit.allowed) { + logger.warn(`[${requestId}] Password attempt resource rate limit exceeded`, { + resourceId: resource.id, + cookiePrefix, + }) + return passwordRateLimitResult( + resourceRateLimit.retryAfterMs, + PASSWORD_RESOURCE_RATE_LIMIT.refillIntervalMs + ) + } + + const { decrypted } = await decryptSecret(resource.password) + if (!safeCompare(password, decrypted)) { + return { authorized: false, error: 'Invalid password' } + } + + return { authorized: true } + } catch (error) { + logger.error(`[${requestId}] Error validating password:`, error) + return { authorized: false, error: 'Authentication error' } + } + } + + if (authType === 'email') { + if (request.method === 'GET') { + return { authorized: false, error: 'auth_required_email' } + } + + try { + if (!parsedBody) { + return { authorized: false, error: 'Email is required' } + } + + const { email, input } = parsedBody + + if (input && !email) { + return { authorized: false, error: 'auth_required_email' } + } + + if (!email) { + return { authorized: false, error: 'Email is required' } + } + + if (isEmailAllowed(email, resource.allowedEmails)) { + return { authorized: false, error: 'otp_required' } + } + + return { authorized: false, error: 'Email not authorized' } + } catch (error) { + logger.error(`[${requestId}] Error validating email:`, error) + return { authorized: false, error: 'Authentication error' } + } + } + + if (authType === 'sso') { + try { + if (request.method !== 'GET' && !parsedBody) { + return { authorized: false, error: 'SSO authentication is required' } + } + + if (!request.sessionPresent) { + return { authorized: false, error: 'auth_required_sso' } + } + + const userEmail = request.sessionEmail + if (!userEmail) { + return { authorized: false, error: 'SSO session does not contain email' } + } + + if (isEmailAllowed(userEmail, resource.allowedEmails)) { + return { authorized: true, authenticatedEmail: normalizeEmail(userEmail) } + } + + return { authorized: false, error: 'Your email is not authorized to access this resource' } + } catch (error) { + logger.error(`[${requestId}] Error validating SSO:`, error) + return { authorized: false, error: 'SSO authentication error' } + } + } + + return { authorized: false, error: 'Unsupported authentication type' } +} diff --git a/apps/sim/lib/core/security/deployment.ts b/apps/sim/lib/core/security/deployment.ts index 55c75aaf841..8ea840966cf 100644 --- a/apps/sim/lib/core/security/deployment.ts +++ b/apps/sim/lib/core/security/deployment.ts @@ -259,22 +259,29 @@ export function deploymentAuthCookieName(cookiePrefix: DeploymentAuthKind, id: s return `${cookiePrefix}_auth_${id}` } -/** Sets a signed, resource-bound authentication cookie for a deployment. */ -export async function setDeploymentAuthCookie({ - response, +/** Builds the existing deployment cookie without coupling domain authorization to an HTTP response. */ +export async function createDeploymentAuthCookie({ cookiePrefix, resource, verifiedEmail, -}: SetDeploymentAuthCookieParams): Promise { - response.cookies.set({ +}: Omit) { + return { name: deploymentAuthCookieName(cookiePrefix, resource.id), value: await generateAuthToken(resource, verifiedEmail), - httpOnly: true, + httpOnly: true as const, secure: !isDev, - sameSite: 'lax', + sameSite: 'lax' as const, path: '/', maxAge: DEPLOYMENT_AUTH_TOKEN_TTL_MS / 1000, - }) + } +} + +/** Sets a signed, resource-bound authentication cookie for a deployment. */ +export async function setDeploymentAuthCookie({ + response, + ...input +}: SetDeploymentAuthCookieParams): Promise { + response.cookies.set(await createDeploymentAuthCookie(input)) } /** diff --git a/apps/sim/lib/core/security/otp.ts b/apps/sim/lib/core/security/otp.ts index e8ac4636c9f..47435216274 100644 --- a/apps/sim/lib/core/security/otp.ts +++ b/apps/sim/lib/core/security/otp.ts @@ -152,7 +152,7 @@ export async function getOTP( const ATOMIC_INCREMENT_SCRIPT = ` local val = redis.call('GET', KEYS[1]) if not val then return nil end -local colon = val:find(':([^:]*$)') +local colon = val:find(':([^:]*)$') local otp, attempts if colon then otp = val:sub(1, colon - 1) diff --git a/apps/sim/lib/core/utils/with-route-handler.ts b/apps/sim/lib/core/utils/with-route-handler.ts index e25de9a486a..47a35f4be7c 100644 --- a/apps/sim/lib/core/utils/with-route-handler.ts +++ b/apps/sim/lib/core/utils/with-route-handler.ts @@ -5,11 +5,14 @@ import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { hasExternalApiCredentials } from '@/lib/api/server/credential-headers' import { getRateLimitHeaders } from '@/lib/api/server/rate-limit-context' +import { FILE_DELIVERY_CSP_PATH_PATTERN, getMainCSPPolicy } from '@/lib/core/security/csp' import { HttpError } from '@/lib/core/utils/http-error' import { generateRequestId } from '@/lib/core/utils/request' import { MAX_CALL_CHAIN_DEPTH, parseCallChain, SIM_VIA_HEADER } from '@/lib/execution/call-chain' import { withPermissionGroupScope } from '@/lib/permission-groups/request-scope.server' +const fileDeliveryPath = new RegExp(`^${FILE_DELIVERY_CSP_PATH_PATTERN}$`) + const logger = createLogger('RouteHandler') type RouteHandler = ( @@ -70,6 +73,12 @@ function applyResponseHeaders( ): void { if (!response?.headers) return response.headers.set('x-request-id', requestId) + if ( + fileDeliveryPath.test(request.nextUrl?.pathname ?? '') && + !response.headers.has('Content-Security-Policy') + ) { + response.headers.set('Content-Security-Policy', getMainCSPPolicy()) + } const rateLimit = getRateLimitHeaders(request) if (!rateLimit) return for (const [name, value] of Object.entries(rateLimit)) { diff --git a/apps/sim/lib/execution/sandbox/brokers/workspace-file.ts b/apps/sim/lib/execution/sandbox/brokers/workspace-file.ts index 32277c4419b..b2fbcc2e5f9 100644 --- a/apps/sim/lib/execution/sandbox/brokers/workspace-file.ts +++ b/apps/sim/lib/execution/sandbox/brokers/workspace-file.ts @@ -31,6 +31,19 @@ export const workspaceFileBroker: SandboxBroker MAX_SANDBOX_IMAGE_DATA_URI_CHARS || + JSON.stringify({ dataUri: prefix }).length + encodedLength > + MAX_ISOLATED_VM_BROKER_RESULT_JSON_CHARS + ) { + throw new Error('Prepared file exceeds the document broker byte limit') + } + return { dataUri: `${prefix}${file.content.toString('base64')}` } + } if (!ctx.workspaceId) { throw new Error('workspaceFile broker requires a workspaceId') } diff --git a/apps/sim/lib/execution/sandbox/run-task.ts b/apps/sim/lib/execution/sandbox/run-task.ts index 8e0e533c5d5..572ac1545a4 100644 --- a/apps/sim/lib/execution/sandbox/run-task.ts +++ b/apps/sim/lib/execution/sandbox/run-task.ts @@ -11,6 +11,8 @@ import { getSandboxTask, type SandboxTaskId } from '@/sandbox-tasks/registry' const logger = createLogger('SandboxRunTask') export interface RunSandboxTaskOptions { + /** Private document compiles expose only their already-authorized, fixed input manifest. */ + resolvePreparedFile?: SandboxBrokerContext['resolvePreparedFile'] /** * Owner key used by the isolated-vm pool for fairness + distributed leases. * Typically `user:` or `workspace:`. @@ -56,6 +58,7 @@ export async function runSandboxTask( const brokerContext: SandboxBrokerContext = { workspaceId: input.workspaceId, requestId, + resolvePreparedFile: options.resolvePreparedFile, onWorkspaceFileAccess: options.onWorkspaceFileAccess, } const brokers: Record = {} diff --git a/apps/sim/lib/execution/sandbox/types.ts b/apps/sim/lib/execution/sandbox/types.ts index 08f7e1ff7fb..403213052de 100644 --- a/apps/sim/lib/execution/sandbox/types.ts +++ b/apps/sim/lib/execution/sandbox/types.ts @@ -22,8 +22,9 @@ export interface SandboxBroker { } export interface SandboxBrokerContext { - workspaceId: string + workspaceId?: string requestId: string + resolvePreparedFile?: (fileId: string) => { content: Buffer; contentType: string } onWorkspaceFileAccess?: (identity: { fileId: string key: string @@ -33,7 +34,7 @@ export interface SandboxBrokerContext { } export interface SandboxTaskInput { - workspaceId: string + workspaceId?: string code: string } diff --git a/apps/sim/lib/file-retention/folders.ts b/apps/sim/lib/file-retention/folders.ts new file mode 100644 index 00000000000..6be32a4cdb7 --- /dev/null +++ b/apps/sim/lib/file-retention/folders.ts @@ -0,0 +1,124 @@ +import { dbFor } from '@sim/db' +import { folder, workspaceFiles } from '@sim/db/schema' +import { and, asc, eq, isNotNull, isNull, lt } from 'drizzle-orm' +import { + consumeRowBudget, + DEFAULT_MAX_BATCHES_PER_TABLE, + type RowBudget, +} from '@/lib/cleanup/batch-delete' +import { generateRestoreName } from '@/lib/core/utils/restore-name' +import type { DbTransaction } from '@/lib/db/types' +import { FILES_PER_QUERY } from '@/lib/file-retention/versions' +import { deduplicateFolderNameInScope } from '@/lib/folders/naming' +import { lockWorkspaceProject } from '@/lib/projects/membership' +import { workspaceFileNameFolderCondition } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' +import { lockFileDirectories } from '@/lib/workspace-files/locks' +import type { EditableFileOwner } from '@/lib/workspace-files/ownership' +import { fileFolderOwnerCondition, fileOwnerCondition } from '@/lib/workspace-files/ownership-query' + +const cleanupDb = dbFor('cleanup') + +/** Recheck expiry and keep child re-rooting atomic with the parent deletion. */ +export async function cleanupExpiredFileFolderInTx( + tx: DbTransaction, + owner: EditableFileOwner, + cutoff: Date, + budget?: RowBudget +): Promise { + const folderScope = fileFolderOwnerCondition(owner) + await lockFileDirectories(tx, [owner]) + const [expired] = await tx + .select({ id: folder.id }) + .from(folder) + .where(and(folderScope, isNotNull(folder.deletedAt), lt(folder.deletedAt, cutoff))) + .orderBy(asc(folder.id)) + .limit(1) + .for('update') + if (!expired) return 0 + consumeRowBudget(budget, 1) + for (;;) { + const children = await tx + .select({ id: folder.id, name: folder.name }) + .from(folder) + .where(and(folderScope, eq(folder.parentId, expired.id), isNull(folder.deletedAt))) + .orderBy(asc(folder.id)) + .limit(FILES_PER_QUERY) + .for('update') + if (!children.length) break + for (const child of children) { + const name = await deduplicateFolderNameInScope(tx, folderScope, null, child.name) + await tx + .update(folder) + .set({ parentId: null, name, updatedAt: new Date() }) + .where(and(folderScope, eq(folder.id, child.id))) + } + } + for (;;) { + const children = await tx + .select({ id: workspaceFiles.id, name: workspaceFiles.originalName }) + .from(workspaceFiles) + .where( + and( + fileOwnerCondition(owner), + eq(workspaceFiles.folderId, expired.id), + isNull(workspaceFiles.deletedAt) + ) + ) + .orderBy(asc(workspaceFiles.id)) + .limit(FILES_PER_QUERY) + .for('update') + if (!children.length) break + for (const child of children) { + const originalName = await generateRestoreName( + child.name, + async (name) => { + const [existing] = await tx + .select({ id: workspaceFiles.id }) + .from(workspaceFiles) + .where( + and( + fileOwnerCondition(owner), + eq(workspaceFiles.originalName, name), + workspaceFileNameFolderCondition(null), + isNull(workspaceFiles.deletedAt) + ) + ) + .limit(1) + return Boolean(existing) + }, + { hasExtension: true } + ) + await tx + .update(workspaceFiles) + .set({ folderId: null, originalName, updatedAt: new Date() }) + .where(and(fileOwnerCondition(owner), eq(workspaceFiles.id, child.id))) + } + } + await tx.delete(folder).where(and(folderScope, eq(folder.id, expired.id))) + return 1 +} + +/** Workspace file folders share the directory transaction used by Project folders. */ +export async function cleanupArchivedWorkspaceFileFolders( + workspaceIds: string[], + cutoff: Date, + budget?: RowBudget +): Promise { + let total = 0 + for (const workspaceId of workspaceIds) { + for (let batch = 0; batch < DEFAULT_MAX_BATCHES_PER_TABLE && budget?.remaining !== 0; batch++) { + const removed = await cleanupDb.transaction(async (tx) => { + await lockWorkspaceProject(tx, workspaceId) + return cleanupExpiredFileFolderInTx( + tx, + { entityType: 'workspace', entityId: workspaceId }, + cutoff, + budget + ) + }) + total += removed + if (!removed) break + } + } + return total +} diff --git a/apps/sim/lib/file-retention/index.ts b/apps/sim/lib/file-retention/index.ts new file mode 100644 index 00000000000..d3bac9b0f93 --- /dev/null +++ b/apps/sim/lib/file-retention/index.ts @@ -0,0 +1 @@ +export { beginFileArchiveCleanup, cleanupFileVersions, fileRetentionOwners } from './service' diff --git a/apps/sim/lib/file-retention/legacy-archive.ts b/apps/sim/lib/file-retention/legacy-archive.ts new file mode 100644 index 00000000000..9472d7f947d --- /dev/null +++ b/apps/sim/lib/file-retention/legacy-archive.ts @@ -0,0 +1,359 @@ +import { db, dbFor } from '@sim/db' +import { workspaceFile, workspaceFiles } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { chunkArray } from '@sim/utils/helpers' +import { and, eq, inArray, isNotNull, lt } from 'drizzle-orm' +import { prepareFileAccountingInTx } from '@/lib/billing/storage/accounting' +import { DEFAULT_DELETE_CHUNK_SIZE, selectRowsByIdChunks } from '@/lib/cleanup/batch-delete' +import type { CleanupBudgets } from '@/lib/cleanup/limits' +import { type CleanupOwnerScope, cleanupOwnerCondition } from '@/lib/cleanup/resource-scope' +import { cleanupArchivedWorkspaceFileFolders } from '@/lib/file-retention/folders' +import type { FileArchiveCleanup, FileRetentionOptions } from '@/lib/file-retention/types' +import { isUsingCloudStorage, type StorageContext, StorageService } from '@/lib/uploads' +import { enqueueWorkspaceFileStorageCleanups } from '@/lib/uploads/contexts/workspace/workspace-file-storage-cleanup-outbox' +import { releaseWorkspaceFileVersionsForPurgeInTx } from '@/lib/uploads/contexts/workspace/workspace-file-versions' +import { getWorkspaceFileSize } from '@/lib/uploads/shared/types' + +const logger = createLogger('FileArchiveCleanup') +/** Billing deletion retains the default pool; selection and unbilled cleanup use the cleanup pool. */ +const cleanupDb = dbFor('cleanup') + +interface WorkspaceFileScope { + /** Rows from `workspace_file` (singular, legacy workspace-context only). */ + legacyRows: Array<{ id: string; key: string; workspaceId: string }> + /** Rows from `workspace_files` (plural, multi-context). */ + multiContextRows: Array<{ + id: string + key: string + workspaceId: string | null + context: StorageContext + size: number + }> +} + +interface WorkspaceFileStorageCleanupResult { + filesDeleted: number + filesFailed: number + legacyRows: WorkspaceFileScope['legacyRows'] + multiContextRows: WorkspaceFileScope['multiContextRows'] +} + +/** + * Select every soft-deleted file row that's eligible for permanent removal. + * Workspace objects are released through the purge transaction; other legacy + * contexts retain the selected rows for their storage-first cleanup. + */ +async function selectExpiredWorkspaceFiles( + scope: CleanupOwnerScope, + retentionDate: Date, + budgets?: CleanupBudgets +): Promise { + const [legacyRows, multiContextRows] = await Promise.all([ + selectRowsByIdChunks( + scope.kind === 'workspace' ? scope.ids : [], + (chunkIds, chunkLimit) => + cleanupDb + .select({ + id: workspaceFile.id, + key: workspaceFile.key, + workspaceId: workspaceFile.workspaceId, + }) + .from(workspaceFile) + .where( + and( + inArray(workspaceFile.workspaceId, chunkIds), + isNotNull(workspaceFile.deletedAt), + lt(workspaceFile.deletedAt, retentionDate) + ) + ) + .limit(chunkLimit), + { budget: budgets?.legacyFiles } + ), + selectRowsByIdChunks( + scope.ids, + (chunkIds, chunkLimit) => + cleanupDb + .select({ + id: workspaceFiles.id, + key: workspaceFiles.key, + workspaceId: workspaceFiles.workspaceId, + context: workspaceFiles.context, + sizeBytes: workspaceFiles.sizeBytes, + }) + .from(workspaceFiles) + .where( + and( + cleanupOwnerCondition(workspaceFiles, scope, chunkIds), + scope.kind === 'organization' + ? eq(workspaceFiles.context, 'knowledge-base') + : undefined, + isNotNull(workspaceFiles.deletedAt), + lt(workspaceFiles.deletedAt, retentionDate) + ) + ) + .limit(chunkLimit), + { budget: budgets?.files } + ), + ]) + + return { + legacyRows, + multiContextRows: multiContextRows.map((r) => ({ + id: r.id, + key: r.key, + workspaceId: r.workspaceId, + context: r.context as StorageContext, + size: getWorkspaceFileSize(r), + })), + } +} + +async function cleanupWorkspaceFileStorage( + scope: WorkspaceFileScope +): Promise { + const result: WorkspaceFileStorageCleanupResult = { + filesDeleted: 0, + filesFailed: 0, + legacyRows: [], + multiContextRows: [], + } + if (!isUsingCloudStorage()) { + return { + ...result, + legacyRows: scope.legacyRows, + multiContextRows: scope.multiContextRows, + } + } + + const candidatesByContext = new Map() + result.legacyRows = scope.legacyRows + for (const row of scope.multiContextRows) { + if (row.context === 'workspace') { + result.multiContextRows.push(row) + continue + } + const bucket = candidatesByContext.get(row.context) + if (bucket) bucket.push(row) + else candidatesByContext.set(row.context, [row]) + } + + for (const [context, candidates] of candidatesByContext) { + for (const batch of chunkArray(candidates, DEFAULT_DELETE_CHUNK_SIZE)) { + const deletion = await StorageService.deleteFiles( + batch.map((row) => row.key), + context + ) + const failedKeys = new Set(deletion.failed.map(({ key }) => key)) + result.filesDeleted += batch.filter((row) => !failedKeys.has(row.key)).length + result.filesFailed += deletion.failed.length + + for (const row of batch) { + if (!failedKeys.has(row.key)) result.multiContextRows.push(row) + } + for (const { key, error } of deletion.failed) { + logger.error(`Failed to delete storage file ${key} (context: ${context}):`, { error }) + } + } + } + + return result +} + +async function deleteExpiredLegacyWorkspaceFileRows( + rows: WorkspaceFileScope['legacyRows'], + retentionDate: Date, + label: string +): Promise<{ deleted: number; failed: number }> { + const result = { deleted: 0, failed: 0 } + for (const batch of chunkArray(rows, DEFAULT_DELETE_CHUNK_SIZE)) { + try { + const deleted = await cleanupDb.transaction(async (tx) => { + const rows = await tx + .delete(workspaceFile) + .where( + and( + inArray( + workspaceFile.id, + batch.map(({ id }) => id) + ), + isNotNull(workspaceFile.deletedAt), + lt(workspaceFile.deletedAt, retentionDate) + ) + ) + .returning({ id: workspaceFile.id, key: workspaceFile.key }) + await enqueueWorkspaceFileStorageCleanups( + tx, + rows.map(({ key }) => key) + ) + return rows + }) + result.deleted += deleted.length + result.failed += batch.length - deleted.length + } catch (error) { + result.failed += batch.length + logger.error(`[${label}/workspaceFile] Exact-row delete failed`, { error }) + } + } + return result +} + +async function deleteExpiredUnbilledWorkspaceFileRows( + rows: WorkspaceFileScope['multiContextRows'], + retentionDate: Date, + label: string +): Promise<{ deleted: number; failed: number }> { + const result = { deleted: 0, failed: 0 } + const rowsByContext = new Map() + for (const row of rows) { + if (row.context === 'workspace') continue + const bucket = rowsByContext.get(row.context) + if (bucket) bucket.push(row) + else rowsByContext.set(row.context, [row]) + } + + for (const [context, contextRows] of rowsByContext) { + for (const batch of chunkArray(contextRows, DEFAULT_DELETE_CHUNK_SIZE)) { + try { + const deleted = await cleanupDb + .delete(workspaceFiles) + .where( + and( + inArray( + workspaceFiles.id, + batch.map(({ id }) => id) + ), + eq(workspaceFiles.context, context), + isNotNull(workspaceFiles.deletedAt), + lt(workspaceFiles.deletedAt, retentionDate) + ) + ) + .returning({ id: workspaceFiles.id }) + result.deleted += deleted.length + result.failed += batch.length - deleted.length + } catch (error) { + result.failed += batch.length + logger.error(`[${label}/workspaceFiles] Exact-row ${context} delete failed`, { error }) + } + } + } + return result +} + +async function deleteExpiredBillableWorkspaceFileRows( + rows: WorkspaceFileScope['multiContextRows'], + retentionDate: Date, + label: string +): Promise<{ deleted: number; failed: number }> { + const result = { deleted: 0, failed: 0 } + const rowsByWorkspace = new Map() + for (const row of rows) { + if (row.context !== 'workspace') continue + if (!row.workspaceId) { + result.failed++ + logger.error(`[${label}/workspaceFiles] Billable row has no workspace attribution`, { + fileId: row.id, + }) + continue + } + const bucket = rowsByWorkspace.get(row.workspaceId) + if (bucket) bucket.push(row) + else rowsByWorkspace.set(row.workspaceId, [row]) + } + + for (const [workspaceId, workspaceRows] of rowsByWorkspace) { + for (const batch of chunkArray(workspaceRows, DEFAULT_DELETE_CHUNK_SIZE)) { + try { + const deletedCount = await db.transaction(async (tx) => { + const accounting = await prepareFileAccountingInTx(tx, { + entityType: 'workspace', + entityId: workspaceId, + }) + await releaseWorkspaceFileVersionsForPurgeInTx( + tx, + batch.map(({ id }) => id), + retentionDate + ) + const deletedRows = await tx + .delete(workspaceFiles) + .where( + and( + inArray( + workspaceFiles.id, + batch.map(({ id }) => id) + ), + eq(workspaceFiles.workspaceId, workspaceId), + eq(workspaceFiles.context, 'workspace'), + isNotNull(workspaceFiles.deletedAt), + lt(workspaceFiles.deletedAt, retentionDate) + ) + ) + .returning({ + id: workspaceFiles.id, + key: workspaceFiles.key, + sizeBytes: workspaceFiles.sizeBytes, + }) + await enqueueWorkspaceFileStorageCleanups( + tx, + deletedRows.map(({ key }) => key) + ) + const deletedBytes = deletedRows.reduce( + (total, row) => total + getWorkspaceFileSize(row), + 0 + ) + await accounting.mutation.applyDelta(-deletedBytes) + return deletedRows.length + }) + result.deleted += deletedCount + result.failed += batch.length - deletedCount + } catch (error) { + result.failed += batch.length + logger.error(`[${label}/workspaceFiles] Atomic delete and decrement failed`, { + error, + workspaceId, + }) + } + } + } + return result +} + +/** Preserve legacy workspace/multi-context cleanup while selecting each owner batch once. */ +export async function prepareLegacyFileArchiveCleanup( + scope: CleanupOwnerScope, + { cutoff, budgets, label }: FileRetentionOptions +): Promise { + const selected = await selectExpiredWorkspaceFiles(scope, cutoff, budgets) + return { + async cleanupStorage() { + const storage = await cleanupWorkspaceFileStorage(selected) + if (budgets && storage.filesFailed) throw new Error('File storage cleanup failed') + return { + filesDeleted: storage.filesDeleted, + async deleteRows() { + const legacy = await deleteExpiredLegacyWorkspaceFileRows( + storage.legacyRows, + cutoff, + label + ) + const billable = await deleteExpiredBillableWorkspaceFileRows( + storage.multiContextRows, + cutoff, + label + ) + const unbilled = await deleteExpiredUnbilledWorkspaceFileRows( + storage.multiContextRows, + cutoff, + label + ) + if (budgets && (legacy.failed || billable.failed || unbilled.failed)) + throw new Error('File row cleanup failed') + const foldersDeleted = + scope.kind === 'workspace' + ? await cleanupArchivedWorkspaceFileFolders(scope.ids, cutoff, budgets?.folders) + : 0 + return legacy.deleted + billable.deleted + unbilled.deleted + foldersDeleted + }, + } + }, + } +} diff --git a/apps/sim/lib/file-retention/service.ts b/apps/sim/lib/file-retention/service.ts new file mode 100644 index 00000000000..51225fb6d72 --- /dev/null +++ b/apps/sim/lib/file-retention/service.ts @@ -0,0 +1,147 @@ +import type { CleanupJobPayload } from '@/lib/billing/cleanup-dispatcher' +import { + DEFAULT_DELETE_CHUNK_SIZE, + DEFAULT_MAX_BATCHES_PER_TABLE, +} from '@/lib/cleanup/batch-delete' +import { prepareLegacyFileArchiveCleanup } from '@/lib/file-retention/legacy-archive' +import type { + FileArchiveCleanup, + FileArchiveDeletion, + FileRetentionOptions, + FileVersionCleanupResult, +} from '@/lib/file-retention/types' +import { cleanupWorkspaceFileVersions } from '@/lib/file-retention/workspace-versions' +import { + cleanupArchivedProjectFileFolders, + cleanupArchivedProjectFiles, + cleanupProjectFileVersions, +} from '@/lib/projects/files/retention' +import { + type FileOwnerAdapters, + requireFileOwnerAdapter, +} from '@/lib/workspace-files/owner-adapters' +import type { FileOwner } from '@/lib/workspace-files/ownership' + +interface RetentionAdapter { + versions?( + ids: string[], + options: FileRetentionOptions, + limit: number + ): Promise + archive(ids: string[], options: FileRetentionOptions): Promise +} + +const OWNER_ADAPTERS: FileOwnerAdapters = { + workspace: { + versions: cleanupWorkspaceFileVersions, + archive: (ids, options) => prepareLegacyFileArchiveCleanup({ kind: 'workspace', ids }, options), + }, + organization: { + archive: (ids, options) => + prepareLegacyFileArchiveCleanup({ kind: 'organization', ids }, options), + }, + project: { + async versions(ids, _options, limit) { + let deleted = 0 + for (const id of ids) { + if (deleted >= limit) break + deleted += await cleanupProjectFileVersions(id, limit - deleted) + } + return { deleted, attempted: deleted } + }, + async archive(ids, { budgets }) { + const files = budgets?.files ?? { + remaining: DEFAULT_DELETE_CHUNK_SIZE * DEFAULT_MAX_BATCHES_PER_TABLE, + } + const folders = budgets?.folders ?? { + remaining: DEFAULT_DELETE_CHUNK_SIZE * DEFAULT_MAX_BATCHES_PER_TABLE, + } + let deleted = 0 + for (const id of ids) { + if (files.remaining <= 0 && folders.remaining <= 0) break + deleted += await cleanupArchivedProjectFiles(id, files) + deleted += await cleanupArchivedProjectFileFolders(id, folders) + } + return { + cleanupStorage: async () => ({ filesDeleted: 0, deleteRows: async () => deleted }), + } + }, + }, +} + +/** Keep queued payloads compatible while file operations consume one owner interface. */ +export function fileRetentionOwners( + payload: Pick +): FileOwner[] { + return [ + ...(payload.projectIds ?? []).map( + (entityId): FileOwner => ({ entityType: 'project', entityId }) + ), + ...payload.workspaceIds.map((entityId): FileOwner => ({ entityType: 'workspace', entityId })), + ...(payload.organizationIds ?? []).map( + (entityId): FileOwner => ({ entityType: 'organization', entityId }) + ), + ] +} + +function ownerBatches(owners: FileOwner[], operation: 'versions' | 'archive') { + const batches = new Map< + FileOwner['entityType'], + { adapter: RetentionAdapter; ids: Set } + >() + for (const owner of owners) { + const adapter = requireFileOwnerAdapter(OWNER_ADAPTERS, owner) + if (!adapter[operation]) + throw new Error(`File ${operation} retention does not support this owner`) + const batch = batches.get(owner.entityType) + if (batch) batch.ids.add(owner.entityId) + else batches.set(owner.entityType, { adapter, ids: new Set([owner.entityId]) }) + } + return [...batches.entries()].map(([type, { adapter, ids }]) => ({ + adapter, + ids: type === 'project' ? [...ids].sort() : [...ids], + })) +} + +/** Validate every owner before destructive work and share one attempt cap across batches. */ +export async function cleanupFileVersions( + owners: FileOwner[], + options: FileRetentionOptions, + limit: number +): Promise { + const batches = ownerBatches(owners, 'versions') + let deleted = 0 + let attempted = 0 + for (const { adapter, ids } of batches) { + if (attempted >= limit) break + if (!adapter.versions) throw new Error('File version retention is unavailable') + const result = await adapter.versions(ids, options, limit - attempted) + deleted += result.deleted + attempted += result.attempted + } + return deleted +} + +/** Begin owner cleanup; Project commits immediately, while legacy storage and row phases stay ordered. */ +export async function beginFileArchiveCleanup( + owners: FileOwner[], + options: FileRetentionOptions +): Promise { + const batches = ownerBatches(owners, 'archive') + const cleanups: FileArchiveCleanup[] = [] + for (const { adapter, ids } of batches) cleanups.push(await adapter.archive(ids, options)) + return { + async cleanupStorage() { + const deletions: FileArchiveDeletion[] = [] + for (const cleanup of cleanups) deletions.push(await cleanup.cleanupStorage()) + return { + filesDeleted: deletions.reduce((sum, deletion) => sum + deletion.filesDeleted, 0), + async deleteRows() { + let deleted = 0 + for (const deletion of deletions) deleted += await deletion.deleteRows() + return deleted + }, + } + }, + } +} diff --git a/apps/sim/lib/file-retention/types.ts b/apps/sim/lib/file-retention/types.ts new file mode 100644 index 00000000000..2ac368c3007 --- /dev/null +++ b/apps/sim/lib/file-retention/types.ts @@ -0,0 +1,24 @@ +import type { PlanCategory } from '@/lib/billing/plan-helpers' +import type { CleanupBudgets } from '@/lib/cleanup/limits' + +export interface FileRetentionOptions { + plan: PlanCategory + cutoff: Date + label: string + budgets?: CleanupBudgets +} + +export interface FileVersionCleanupResult { + deleted: number + attempted: number +} + +/** Exact selected rows survive the storage phase; failed objects never enter row deletion. */ +export interface FileArchiveCleanup { + cleanupStorage(): Promise +} + +export interface FileArchiveDeletion { + filesDeleted: number + deleteRows(): Promise +} diff --git a/apps/sim/lib/file-retention/versions.ts b/apps/sim/lib/file-retention/versions.ts new file mode 100644 index 00000000000..b9c72ace8a7 --- /dev/null +++ b/apps/sim/lib/file-retention/versions.ts @@ -0,0 +1,72 @@ +import type { db } from '@sim/db' +import { workspaceFileVersion } from '@sim/db/schema' +import { and, gt, inArray, isNotNull, lt, or, sql } from 'drizzle-orm' +import type { DbTransaction } from '@/lib/db/types' +import { enqueueWorkspaceFileStorageCleanups } from '@/lib/uploads/contexts/workspace/workspace-file-storage-cleanup-outbox' + +/** Always retain the current head and its newest nine predecessors. */ +export const KEEP_SUPERSEDED = 9 +export const FILES_PER_QUERY = 500 + +/** Rank each file independently; expiry never removes its protected recent history. */ +export function selectExpiredFileVersions( + executor: Pick, + fileIds: string[], + cutoff: Date, + maxSuperseded: number, + limit: number +) { + const ranked = executor + .select({ + id: workspaceFileVersion.id, + supersededAt: workspaceFileVersion.supersededAt, + rank: sql`row_number() over (partition by ${workspaceFileVersion.fileId} order by ${workspaceFileVersion.version} desc)`.as( + 'rank' + ), + }) + .from(workspaceFileVersion) + .where( + and( + inArray(workspaceFileVersion.fileId, fileIds), + isNotNull(workspaceFileVersion.supersededAt) + ) + ) + .as('ranked') + return executor + .select({ id: ranked.id }) + .from(ranked) + .where( + and( + gt(ranked.rank, KEEP_SUPERSEDED), + or(lt(ranked.supersededAt, cutoff), gt(ranked.rank, maxSuperseded)) + ) + ) + .limit(limit) +} + +/** Release history and its storage-cleanup intent in the caller's existing transaction. */ +export async function releaseExpiredFileVersions( + tx: DbTransaction, + rows: Array<{ id: string }>, + context: 'workspace' | 'project' +): Promise { + if (!rows.length) return 0 + const removed = await tx + .delete(workspaceFileVersion) + .where( + and( + inArray( + workspaceFileVersion.id, + rows.map(({ id }) => id) + ), + isNotNull(workspaceFileVersion.supersededAt) + ) + ) + .returning({ key: workspaceFileVersion.key }) + await enqueueWorkspaceFileStorageCleanups( + tx, + removed.map(({ key }) => key), + context === 'workspace' ? undefined : context + ) + return removed.length +} diff --git a/apps/sim/lib/file-retention/workspace-versions.ts b/apps/sim/lib/file-retention/workspace-versions.ts new file mode 100644 index 00000000000..9b801531c88 --- /dev/null +++ b/apps/sim/lib/file-retention/workspace-versions.ts @@ -0,0 +1,100 @@ +import { dbFor } from '@sim/db' +import { workspaceFileVersion } from '@sim/db/schema' +import { chunkArray } from '@sim/utils/helpers' +import { and, asc, count, gt, inArray, isNotNull, lt, min, or, sql } from 'drizzle-orm' +import { + DEFAULT_DELETE_CHUNK_SIZE, + DEFAULT_MAX_BATCHES_PER_TABLE, + DEFAULT_WORKSPACE_CHUNK_SIZE, +} from '@/lib/cleanup/batch-delete' +import type { FileRetentionOptions, FileVersionCleanupResult } from '@/lib/file-retention/types' +import { + FILES_PER_QUERY, + KEEP_SUPERSEDED, + releaseExpiredFileVersions, + selectExpiredFileVersions, +} from '@/lib/file-retention/versions' +import { MAX_SUPERSEDED_FILE_VERSIONS } from '@/lib/uploads/contexts/workspace/workspace-file-versions' + +const cleanupDb = dbFor('cleanup') +const FREE_MAX_SUPERSEDED_VERSIONS = 99 + +async function selectCandidateFileIds( + workspaceIds: string[], + cutoff: Date, + maxSuperseded: number, + afterId: string +): Promise { + const rows = await cleanupDb + .select({ fileId: workspaceFileVersion.fileId }) + .from(workspaceFileVersion) + .where( + and( + inArray(workspaceFileVersion.workspaceId, workspaceIds), + isNotNull(workspaceFileVersion.supersededAt), + gt(workspaceFileVersion.fileId, afterId) + ) + ) + .groupBy(workspaceFileVersion.fileId) + .having( + and( + gt(count(), KEEP_SUPERSEDED), + or( + lt( + min(workspaceFileVersion.supersededAt), + sql.param(cutoff, workspaceFileVersion.supersededAt) + ), + gt(count(), maxSuperseded) + ) + ) + ) + .orderBy(asc(workspaceFileVersion.fileId)) + .limit(FILES_PER_QUERY) + return rows.map((row) => row.fileId) +} + +/** Preserve workspace batching and the queued workspace retention policy. */ +export async function cleanupWorkspaceFileVersions( + workspaceIds: string[], + { cutoff, plan }: FileRetentionOptions, + limit: number +): Promise { + const maxSuperseded = + plan === 'free' ? FREE_MAX_SUPERSEDED_VERSIONS : MAX_SUPERSEDED_FILE_VERSIONS + let deleted = 0 + let attempted = 0 + for (const group of chunkArray(workspaceIds, DEFAULT_WORKSPACE_CHUNK_SIZE)) { + if (attempted >= limit) break + let batches = 0 + let afterId = '' + while (batches < DEFAULT_MAX_BATCHES_PER_TABLE && attempted < limit) { + const fileIds = await selectCandidateFileIds(group, cutoff, maxSuperseded, afterId) + if (fileIds.length === 0) break + afterId = fileIds[fileIds.length - 1] + let exhausted = false + while (!exhausted && batches < DEFAULT_MAX_BATCHES_PER_TABLE && attempted < limit) { + batches++ + const batchSize = Math.min(DEFAULT_DELETE_CHUNK_SIZE, limit - attempted) + const expired = await selectExpiredFileVersions( + cleanupDb, + fileIds, + cutoff, + maxSuperseded, + batchSize + ) + attempted += expired.length + const removed = + expired.length > 0 + ? await cleanupDb.transaction((tx) => + releaseExpiredFileVersions(tx, expired, 'workspace') + ) + : 0 + deleted += removed + exhausted = expired.length < batchSize || removed === 0 + } + if (!exhausted) break + } + } + + return { deleted, attempted } +} diff --git a/apps/sim/lib/mothership/agent-cli/file-provenance.test.ts b/apps/sim/lib/mothership/agent-cli/file-provenance.test.ts index 509d3239e9b..ac5b1c21b84 100644 --- a/apps/sim/lib/mothership/agent-cli/file-provenance.test.ts +++ b/apps/sim/lib/mothership/agent-cli/file-provenance.test.ts @@ -53,6 +53,7 @@ vi.mock('@/lib/mothership/agent-cli/scoped-transport', () => ({ import { createFileReadTransport } from '@/lib/mothership/agent-cli/file-read-transport' import { inspectToolResultForCopilot } from '@/lib/mothership/request/tools/resolved-secret-result' import { executeSimCli } from '@/lib/mothership/tools/handlers/sim-cli' +import type { WorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' import { readWorkspaceFileArtifact } from '@/lib/workspace-files/application/read-workspace-file-artifact' import { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' @@ -98,17 +99,29 @@ function registry() { return new ResolvedSecretTraceRegistry([], { userId: 'reader', workspaceId: 'workspace' }) } -function classify(status: 'exact' | 'unknown', fileRevision = revision) { +function classify( + status: 'exact' | 'unknown', + fileRevision = revision, + entries: Extract['entries'] = [], + includePublicationRows = true +) { resetDbChainMock() + const sidecar = { + id: file.id, + key: file.key, + context: file.storageContext, + fileContentUpdatedAt: fileRevision, + secretProvenanceVersion: 1, + provenanceContentUpdatedAt: fileRevision, + status, + entries, + } + queueTableRows(workspaceFiles, [sidecar]) + if (!includePublicationRows) return queueTableRows(workspaceFiles, [ - { - fileContentUpdatedAt: fileRevision, - secretProvenanceVersion: 1, - provenanceContentUpdatedAt: fileRevision, - status, - entries: [], - }, + { ...file, context: file.storageContext, secretProvenanceVersion: 1 }, ]) + queueTableRows(workspaceFiles, [sidecar]) } function readTransport(trace = registry()) { @@ -149,15 +162,7 @@ describe('file provenance at the actual CLI and model-result boundary', () => { mocks.stream.mockImplementation(async () => Readable.from(Buffer.from(content))) mocks.buffer.mockResolvedValue(Buffer.from(content)) mocks.decrypt.mockResolvedValue({ decrypted: content }) - queueTableRows(workspaceFiles, [ - { - fileContentUpdatedAt: revision, - secretProvenanceVersion: 1, - provenanceContentUpdatedAt: revision, - status: 'unknown', - entries: [], - }, - ]) + classify('unknown') }) it('uses current Copilot file authority with personal keys disabled and rejects a foreign ID-only owner', async () => { @@ -294,17 +299,8 @@ describe('file provenance at the actual CLI and model-result boundary', () => { }) it('imports encrypted file provenance into a fresh registry before model projection', async () => { - resetDbChainMock() - queueTableRows(workspaceFiles, [ - { - fileContentUpdatedAt: revision, - secretProvenanceVersion: 1, - provenanceContentUpdatedAt: revision, - status: 'exact', - entries: [ - { name: 'FILE_SECRET', encryptedValue: 'fixture-ciphertext', sourceUserId: 'reader' }, - ], - }, + classify('exact', revision, [ + { name: 'FILE_SECRET', encryptedValue: 'fixture-ciphertext', sourceUserId: 'reader' }, ]) const trace = registry() const response = await readTransport(trace)(fileRequest()) @@ -363,7 +359,7 @@ describe('file provenance at the actual CLI and model-result boundary', () => { nextCursor: null, }) }) - classify('unknown') + classify('unknown', revision, [], false) const grep = async () => { const trace = registry() const result = await executeSimCli( diff --git a/apps/sim/lib/mothership/agent-cli/file-read-transport.ts b/apps/sim/lib/mothership/agent-cli/file-read-transport.ts index cfccc827b0e..92cd269d33e 100644 --- a/apps/sim/lib/mothership/agent-cli/file-read-transport.ts +++ b/apps/sim/lib/mothership/agent-cli/file-read-transport.ts @@ -5,6 +5,7 @@ import { v2DownloadFileContract, v2ListFileFoldersContract, v2ReadFileTextContract, + v2SearchFileContentContract, } from '@/lib/api/contracts/v2/files' import { authenticateV2ApiKey, @@ -51,6 +52,7 @@ export function createFileReadTransport(context: { const collectionPaths = new Set([ `${basePath}${v2ListFileFoldersContract.path}`, `${basePath}${v2BulkDownloadFilesContract.path}`, + `${basePath}${v2SearchFileContentContract.path}`, ]) const observe = async (workspaceId: string, provenance?: WorkspaceFileSecretProvenance) => { const imported = diff --git a/apps/sim/lib/mothership/agent-cli/file-round-trip-provenance.test.ts b/apps/sim/lib/mothership/agent-cli/file-round-trip-provenance.test.ts index bb248c8b12b..e83b0c0c4bf 100644 --- a/apps/sim/lib/mothership/agent-cli/file-round-trip-provenance.test.ts +++ b/apps/sim/lib/mothership/agent-cli/file-round-trip-provenance.test.ts @@ -191,15 +191,29 @@ function queueRead(file: typeof SOURCE, provenance: WorkspaceFileSecretProvenanc }, ]) queueTableRows(workspaceFiles, [file]) + const sidecar = { + id: file.id, + key: file.key, + context: file.context, + fileContentUpdatedAt: REVISION, + secretProvenanceVersion: 1, + provenanceContentUpdatedAt: REVISION, + ...provenance, + entries: provenance.status === 'exact' ? provenance.entries : [], + } + queueTableRows(workspaceFiles, [sidecar]) queueTableRows(workspaceFiles, [ { - fileContentUpdatedAt: REVISION, - secretProvenanceVersion: 1, - provenanceContentUpdatedAt: REVISION, - ...provenance, - entries: provenance.status === 'exact' ? provenance.entries : [], + workspaceId: WORKSPACE, + fileId: file.id, + ownership: file, + workspaceOrganizationId: null, + allowPersonalApiKeys: true, + billedAccountUserId: 'owner', }, ]) + queueTableRows(workspaceFiles, [file]) + queueTableRows(workspaceFiles, [sidecar]) } function execute(argv: string[], trace: ResolvedSecretTraceRegistry, sink?: string) { diff --git a/apps/sim/lib/mothership/chat/post.ts b/apps/sim/lib/mothership/chat/post.ts index d7bb135fc03..ce17deca925 100644 --- a/apps/sim/lib/mothership/chat/post.ts +++ b/apps/sim/lib/mothership/chat/post.ts @@ -626,7 +626,10 @@ async function resolveAgentContexts(params: { { userId, workspaceId, organizationId, chatId }, resource.workspaceId ) - : { workspaceId: workspaceId! } + : workspaceId + ? { workspaceId } + : null + if (!target) return null const ctx = await withWorkspaceInvocationScope( { workspaceId: target.workspaceId, organizationId }, () => diff --git a/apps/sim/lib/mothership/generated/docs-manifest.ts b/apps/sim/lib/mothership/generated/docs-manifest.ts index 938f56ff846..221a97ec908 100644 --- a/apps/sim/lib/mothership/generated/docs-manifest.ts +++ b/apps/sim/lib/mothership/generated/docs-manifest.ts @@ -43,6 +43,7 @@ export const DOCS_MANIFEST: readonly string[] = [ 'cli/output.mdx', 'cli/permission-groups.mdx', 'cli/profiles.mdx', + 'cli/projects.mdx', 'cli/reference.mdx', 'cli/sandboxes.mdx', 'cli/scripting.mdx', diff --git a/apps/sim/lib/projects/__integration__/foundation.integration.ts b/apps/sim/lib/projects/__integration__/foundation.integration.ts index fe00c26c307..7cd20e2b0dc 100644 --- a/apps/sim/lib/projects/__integration__/foundation.integration.ts +++ b/apps/sim/lib/projects/__integration__/foundation.integration.ts @@ -1,7 +1,9 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises' import { dirname, resolve } from 'node:path' +import type { ResourceDelegatedPrincipal } from '@sim/auth/principal' import { db } from '@sim/db' import { + copilotChats, member, organization, permissionGroup, @@ -167,6 +169,24 @@ function check(name: string, run: () => Promise, legacy = false) { }) } +/** Failure modes: foreign workspace discovery, expired/wrong-audience authority, and CRUD escalation. */ +function discoveryPrincipal( + userId: string, + workspaceId: string +): Extract { + return { + kind: 'resource_delegated', + serviceId: 'copilot', + subjectUserId: userId, + delegationId: generateId(), + audience: 'sim:projects:discovery', + issuedAt: new Date(), + expiresAt: new Date(Date.now() + 30_000), + invocation: { kind: 'workspace', workspaceId }, + scope: { kind: 'project_discovery' }, + } +} + async function fixture(org = true, count = 2) { const ownerId = generateId() const teammateId = generateId() @@ -1405,3 +1425,124 @@ describe('Project foundation at the database and application boundary', () => { } ) }) + +describe('Project discovery delegation', () => { + beforeEach(() => { + vi.stubEnv('PROJECT_FILES_ENABLED', 'true') + }) + check( + 'binds organization discovery to the current owned chat and rechecks membership', + async () => { + const first = await fixture() + const second = await fixture() + const chatId = generateId() + await db.insert(copilotChats).values({ + id: chatId, + userId: first.ownerId, + organizationId: first.organizationId, + type: 'mothership', + config: { conversationMode: 'agent' }, + }) + await db.insert(permissions).values({ + id: generateId(), + entityType: 'workspace', + entityId: second.ids[0], + userId: first.ownerId, + permissionType: 'admin', + }) + const principal: ResourceDelegatedPrincipal = { + ...discoveryPrincipal(first.ownerId, first.ids[0]), + serviceId: 'copilot', + invocation: { kind: 'chat', chatId }, + } + const result = await listProjects.execute({ principal, input: { limit: 100 } }) + expect(result.projects.map((entry) => entry.id)).toEqual([first.projectId]) + await expect( + listProjects.execute({ + principal, + input: { limit: 100, organizationId: second.organizationId ?? undefined }, + }) + ).rejects.toThrow() + await expect( + listProjects.execute({ + principal: { ...principal, subjectUserId: first.outsiderId }, + input: { limit: 100 }, + }) + ).rejects.toThrow() + await db + .update(copilotChats) + .set({ config: { conversationMode: 'assistant' } }) + .where(eq(copilotChats.id, chatId)) + await expect(listProjects.execute({ principal, input: { limit: 100 } })).rejects.toThrow() + await db + .update(copilotChats) + .set({ config: { conversationMode: 'agent' } }) + .where(eq(copilotChats.id, chatId)) + if (first.organizationId) + await db + .delete(member) + .where( + and(eq(member.organizationId, first.organizationId), eq(member.userId, first.ownerId)) + ) + await expect(listProjects.execute({ principal, input: { limit: 100 } })).rejects.toThrow() + } + ) + + check( + 'keeps discovery inside the origin organization without granting lifecycle access', + async () => { + const first = await fixture() + const second = await fixture() + await db.insert(permissions).values({ + id: generateId(), + entityType: 'workspace', + entityId: second.ids[0], + userId: first.ownerId, + permissionType: 'admin', + }) + const principal = discoveryPrincipal(first.ownerId, first.ids[0]) + const result = await listProjects.execute({ principal, input: { limit: 100 } }) + expect(result.projects.map((entry) => entry.id)).toEqual([first.projectId]) + await expect( + renameProject.execute({ principal, input: { projectId: first.projectId, name: 'Denied' } }) + ).rejects.toThrow() + await expect( + archiveProject.execute({ principal, input: { projectId: first.projectId } }) + ).rejects.toThrow() + await db + .delete(permissions) + .where(and(eq(permissions.userId, first.ownerId), inArray(permissions.entityId, first.ids))) + if (first.organizationId) + await db + .delete(member) + .where( + and(eq(member.organizationId, first.organizationId), eq(member.userId, first.ownerId)) + ) + await expect(listProjects.execute({ principal, input: { limit: 100 } })).rejects.toThrow() + } + ) + + check( + 'rejects expired, malformed, wrong-audience and entity-scoped discovery delegation', + async () => { + const state = await fixture() + const principal = discoveryPrincipal(state.ownerId, state.ids[0]) + for (const patch of [ + { expiresAt: new Date(0) }, + { issuedAt: new Date('invalid') }, + { audience: 'sim:workspace-files' }, + { + scope: { + kind: 'entity' as const, + entityType: 'project' as const, + entityId: state.projectId, + }, + }, + ]) { + await expect( + listProjects.execute({ principal: { ...principal, ...patch }, input: { limit: 10 } }) + ).rejects.toThrow() + } + } + ) +}) diff --git a/apps/sim/lib/projects/application/authorization.ts b/apps/sim/lib/projects/application/authorization.ts index eab358ab472..175770f9a15 100644 --- a/apps/sim/lib/projects/application/authorization.ts +++ b/apps/sim/lib/projects/application/authorization.ts @@ -1,4 +1,9 @@ -import type { Principal, SessionPrincipal } from '@sim/auth/principal' +import { + type Principal, + type ResourceDelegatedPrincipal, + requirePrincipalSubjectUserId, + type SessionPrincipal, +} from '@sim/auth/principal' import { member, permissionGroup, @@ -10,6 +15,7 @@ import { import { createLogger } from '@sim/logger' import { isOrgAdminRole } from '@sim/platform-authz/workspace' import { and, asc, eq, inArray, sql } from 'drizzle-orm' +import { requireResourceDelegation } from '@/lib/core/application/resource-delegation' import { PrincipalKindAuthorizationError } from '@/lib/core/application/workspace-authorization' import { OrchestrationError } from '@/lib/core/orchestration/types' import { textArrayLiteral } from '@/lib/db/arrays' @@ -17,17 +23,33 @@ import type { DbTransaction } from '@/lib/db/types' import { CAPABILITY_RULES, refuseCapability } from '@/lib/permission-groups/capabilities' import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks' import { resolveVerifiedUserAccessControlContext } from '@/lib/permission-groups/resolve.server' -import { type ProjectOperation, projectOperations } from '@/lib/projects/application/operations' +import { + PROJECT_DISCOVERY_DELEGATION_TTL_MS, + type ProjectOperation, + projectOperations, +} from '@/lib/projects/application/operations' import { lockProject } from '@/lib/projects/membership' const logger = createLogger('ProjectAuthorization') -export function requireProjectPrincipal( +export function requireProjectPrincipal< + O extends Pick, +>( principal: Principal, - operation: Pick -): asserts principal is SessionPrincipal { - if (principal.kind !== 'session') + operation: O +): asserts principal is Extract { + if (!operation.principalKinds.some((kind) => kind === principal.kind)) throw new PrincipalKindAuthorizationError(principal.kind, operation.id) + if (principal.kind === 'resource_delegated') { + if (operation.id !== 'projects.list' || !operation.delegationAudience) + throw new PrincipalKindAuthorizationError(principal.kind, operation.id) + requireResourceDelegation(principal, { + audience: operation.delegationAudience, + services: ['copilot'], + scope: { kind: 'project_discovery' }, + maxTtlMs: PROJECT_DISCOVERY_DELEGATION_TTL_MS, + }) + } } type ProjectRecord = typeof project.$inferSelect @@ -38,6 +60,7 @@ interface ProjectEnvironmentAccess { organizationId: string | null archivedAt: Date | null parentId: string | null + allowPersonalApiKeys: boolean permission: string | null } @@ -52,14 +75,14 @@ export interface ProjectAuthorizationInput { */ type ProjectAccessMode = 'hold' | 'snapshot' -type ProjectAccess = Awaited> +type ProjectAccess = Awaited> /** * Loads the caller's org role and every environment with its grant for `records` in three * queries, whatever their count. A snapshot read also learns, in one more query, which * records any permission group restricts, so unrestricted ones skip per-environment policy. */ -async function loadProjectAccess( +async function loadProjectMemberships( tx: DbTransaction, userId: string, records: ProjectRecord[], @@ -74,7 +97,7 @@ async function loadProjectAccess( const [membership] = records.some((record) => record.organizationId) ? await (lock ? memberQuery.for('share') : memberQuery) : [] - const environments = await tx + const environmentQuery = tx .select({ projectId: projectWorkspace.projectId, id: workspace.id, @@ -82,6 +105,7 @@ async function loadProjectAccess( organizationId: workspace.organizationId, archivedAt: workspace.archivedAt, parentId: workspace.forkedFromWorkspaceId, + allowPersonalApiKeys: workspace.allowPersonalApiKeys, }) .from(projectWorkspace) .innerJoin(workspace, eq(workspace.id, projectWorkspace.workspaceId)) @@ -92,6 +116,9 @@ async function loadProjectAccess( ) ) .orderBy(asc(workspace.id)) + const environments = await (lock + ? environmentQuery.for('share', { of: workspace }) + : environmentQuery) const grantQuery = tx .select({ id: permissions.entityId, permission: permissions.permissionType }) .from(permissions) @@ -148,22 +175,15 @@ async function loadProjectAccess( } } -/** Applies the access rules to one loaded Project; hidden environments never enter the result. */ -async function evaluateProjectAccess( - tx: DbTransaction, - principal: SessionPrincipal, - operation: ProjectOperation, +function projectVisibility( record: ProjectRecord, access: ProjectAccess, - input: ProjectAuthorizationInput, - mode: ProjectAccessMode + input: ProjectAuthorizationInput ) { const orgAdmin = access.isOrgAdmin(record.organizationId) const rows = access.environmentsFor(record.id) if (rows.some((row) => row.organizationId !== record.organizationId)) throw new OrchestrationError('conflict', 'Project ownership needs reconciliation') - if (mode === 'hold' && operation.access === 'issues' && record.organizationId) - await acquirePermissionGroupOrgLock(tx, record.organizationId) const active = rows.filter((row) => !row.archivedAt) const visible = active.filter((row) => orgAdmin || row.permission !== null) const canAdminister = @@ -172,6 +192,42 @@ async function evaluateProjectAccess( throw new OrchestrationError('not_found', 'Project not found') if (input.workspaceId && !visible.some((row) => row.id === input.workspaceId)) throw new OrchestrationError('not_found', 'Project not found') + return { record, rows, active, visible, orgAdmin, canAdminister } +} + +/** Loads and holds canonical Project membership for a resource-specific access policy. */ +export async function loadProjectAccess( + tx: DbTransaction, + userId: string, + input: ProjectAuthorizationInput & { projectId: string } +) { + await lockProject(tx, input.projectId) + const [record] = await tx.select().from(project).where(eq(project.id, input.projectId)).limit(1) + if ( + !record || + (input.organizationId !== undefined && input.organizationId !== record.organizationId) + ) + throw new OrchestrationError('not_found', 'Project not found') + return projectVisibility( + record, + await loadProjectMemberships(tx, userId, [record], 'hold'), + input + ) +} + +/** Applies the access rules to one loaded Project; hidden environments never enter the result. */ +async function evaluateProjectAccess( + tx: DbTransaction, + principal: SessionPrincipal | ResourceDelegatedPrincipal, + operation: ProjectOperation, + record: ProjectRecord, + access: ProjectAccess, + input: ProjectAuthorizationInput, + mode: ProjectAccessMode +) { + const { active, visible, canAdminister } = projectVisibility(record, access, input) + if (mode === 'hold' && operation.access === 'issues' && record.organizationId) + await acquirePermissionGroupOrgLock(tx, record.organizationId) if (operation.access === 'admin' && !canAdminister) throw new OrchestrationError( 'forbidden', @@ -186,7 +242,7 @@ async function evaluateProjectAccess( ) { for (const environment of visible) { const { config } = await resolveVerifiedUserAccessControlContext( - principal.userId, + requirePrincipalSubjectUserId(principal), environment.id, record.organizationId, tx @@ -220,11 +276,12 @@ export type AuthorizedProject = Awaited /** Authorizes one Project for `operation`; see {@link ProjectAccessMode} for locking. */ export async function authorizeProject( tx: DbTransaction, - principal: SessionPrincipal, + principal: SessionPrincipal | ResourceDelegatedPrincipal, operation: ProjectOperation, input: ProjectAuthorizationInput & { projectId: string }, mode: ProjectAccessMode ): Promise { + requireProjectPrincipal(principal, operation) if (mode === 'hold') await lockProject(tx, input.projectId) const [record] = await tx.select().from(project).where(eq(project.id, input.projectId)).limit(1) if ( @@ -233,23 +290,34 @@ export async function authorizeProject( ) { throw new OrchestrationError('not_found', 'Project not found') } - const access = await loadProjectAccess(tx, principal.userId, [record], mode) + const access = await loadProjectMemberships( + tx, + requirePrincipalSubjectUserId(principal), + [record], + mode + ) return evaluateProjectAccess(tx, principal, operation, record, access, input, mode) } /** Authorizes a listed page of Projects in id order inside the caller's read-only snapshot. */ export async function authorizeProjectsForRead( tx: DbTransaction, - principal: SessionPrincipal, + principal: SessionPrincipal | ResourceDelegatedPrincipal, projectIds: string[] ): Promise { + requireProjectPrincipal(principal, projectOperations.list) if (!projectIds.length) return [] const records = await tx .select() .from(project) .where(inArray(project.id, projectIds)) .orderBy(asc(project.id)) - const access = await loadProjectAccess(tx, principal.userId, records, 'snapshot') + const access = await loadProjectMemberships( + tx, + requirePrincipalSubjectUserId(principal), + records, + 'snapshot' + ) const authorized: AuthorizedProject[] = [] for (const record of records) { /** One inconsistent Project must not hide the rest of the caller's page. */ diff --git a/apps/sim/lib/projects/application/discovery.ts b/apps/sim/lib/projects/application/discovery.ts new file mode 100644 index 00000000000..d8ec0a1773d --- /dev/null +++ b/apps/sim/lib/projects/application/discovery.ts @@ -0,0 +1,68 @@ +import type { ResourceDelegatedPrincipal } from '@sim/auth/principal' +import { db } from '@sim/db' +import { workspace } from '@sim/db/schema' +import { and, eq, isNull } from 'drizzle-orm' +import { getActivelyBannedUserIds } from '@/lib/auth/ban' +import { requireOrganizationSubjectMembership } from '@/lib/core/application/organization-authorization' +import { requireCurrentHumanRole } from '@/lib/core/application/workspace-authorization' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { resolveOwnedChatContext } from '@/lib/mothership/chat/application/context' +import { assertWorkspaceCapability } from '@/lib/permission-groups/capability-assertions' + +/** Project access stays inside the current, server-resolved conversation owner. */ +export async function resolveCopilotProjectScope( + principal: ResourceDelegatedPrincipal, + assertedOrganizationId?: string +): Promise<{ organizationId: string | null; workspaceId?: string }> { + if (principal.serviceId !== 'copilot') + throw new OrchestrationError('forbidden', 'Project access requires Copilot delegation') + const userId = principal.subjectUserId + let workspaceId: string + if (principal.invocation.kind === 'chat') { + const chat = await resolveOwnedChatContext(principal, principal.invocation.chatId) + if (chat.organizationId) { + if (chat.mode !== 'agent' && chat.mode !== 'plan') + throw new OrchestrationError('forbidden', 'Project access requires organization agent mode') + if (assertedOrganizationId && assertedOrganizationId !== chat.organizationId) + throw new OrchestrationError('not_found', 'Organization not found in this conversation') + await requireOrganizationSubjectMembership( + userId, + chat.organizationId, + 'member', + 'copilot.use', + undefined, + { executor: db } + ) + return { organizationId: chat.organizationId } + } + if (!chat.workspaceId) throw new OrchestrationError('not_found', 'Workspace not found') + workspaceId = chat.workspaceId + } else { + workspaceId = principal.invocation.workspaceId + if ((await getActivelyBannedUserIds([userId])).length > 0) + throw new OrchestrationError('forbidden', 'User account is suspended') + } + const [context] = await db + .select({ + workspaceId: workspace.id, + workspaceOrganizationId: workspace.organizationId, + allowPersonalApiKeys: workspace.allowPersonalApiKeys, + }) + .from(workspace) + .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) + .limit(1) + if ( + !context || + (assertedOrganizationId && assertedOrganizationId !== context.workspaceOrganizationId) + ) + throw new OrchestrationError('not_found', 'Workspace not found in this conversation') + await requireCurrentHumanRole(userId, context, 'read') + await assertWorkspaceCapability( + userId, + workspaceId, + 'copilot.use', + context.workspaceOrganizationId, + db + ) + return { organizationId: context.workspaceOrganizationId, workspaceId } +} diff --git a/apps/sim/lib/projects/application/operations.ts b/apps/sim/lib/projects/application/operations.ts index 4158cd61008..ba73e90e5e7 100644 --- a/apps/sim/lib/projects/application/operations.ts +++ b/apps/sim/lib/projects/application/operations.ts @@ -2,14 +2,26 @@ import type { ApplicationOperation } from '@/lib/core/application/operation' import { assertOperationCapability, defineOperation } from '@/lib/core/application/operation' export interface ProjectOperation extends ApplicationOperation { - readonly principalKinds: readonly ['session'] + readonly principalKinds: readonly ('session' | 'resource_delegated')[] readonly access: 'read' | 'admin' | 'issues' + readonly delegationAudience?: string + readonly delegatedServices?: readonly ['copilot'] } +export const PROJECT_DISCOVERY_DELEGATION_TTL_MS = 60_000 + /** Project operations cannot borrow authority from workspace keys or an arbitrary root. */ function defineProjectOperation(operation: O): O { assertOperationCapability(operation) + if ( + operation.principalKinds.includes('resource_delegated') && + (operation.id !== 'projects.list' || + operation.access !== 'read' || + !operation.delegationAudience) + ) + throw new Error('Resource delegation is available only for bounded Project discovery') Object.freeze(operation.principalKinds) + if (operation.delegatedServices) Object.freeze(operation.delegatedServices) return Object.freeze(operation) } @@ -22,9 +34,11 @@ export const projectOperations = { // permission-group-exempt: navigation exposes only Projects containing accessible environments. list: defineProjectOperation({ id: 'projects.list', - principalKinds: ['session'], + principalKinds: ['session', 'resource_delegated'], access: 'read', capability: 'none', + delegationAudience: 'sim:projects:discovery', + delegatedServices: ['copilot'], }), // permission-group-exempt: Project metadata does not grant access to its Issues or environments. get: defineProjectOperation({ diff --git a/apps/sim/lib/projects/application/use-cases.ts b/apps/sim/lib/projects/application/use-cases.ts index 98cfb3495fc..14d5ee74efa 100644 --- a/apps/sim/lib/projects/application/use-cases.ts +++ b/apps/sim/lib/projects/application/use-cases.ts @@ -1,4 +1,5 @@ import { AuditAction, AuditResourceType } from '@sim/audit' +import { requirePrincipalSubjectUserId } from '@sim/auth/principal' import { db } from '@sim/db' import { member, permissions, project, projectWorkspace, workspace } from '@sim/db/schema' import { ORG_ADMIN_ROLES } from '@sim/platform-authz/workspace' @@ -13,6 +14,7 @@ import { type ProjectAuthorizationInput, requireProjectPrincipal, } from '@/lib/projects/application/authorization' +import { resolveCopilotProjectScope } from '@/lib/projects/application/discovery' import { projectOperations } from '@/lib/projects/application/operations' import { archiveProjectInTransaction, finishProjectArchive } from '@/lib/projects/lifecycle' import { requireProjectApiEnabled } from '@/lib/projects/rollout.server' @@ -84,17 +86,24 @@ export const listProjects: OperationUseCase< async execute({ principal, input }) { requireProjectPrincipal(principal, projectOperations.list) await requireProjectApiEnabled() + if (!Number.isInteger(input.limit) || input.limit < 1 || input.limit > 100) + throw new OrchestrationError('validation', 'Limit must be between 1 and 100') + const userId = requirePrincipalSubjectUserId(principal) + const scope = + principal.kind === 'resource_delegated' + ? await resolveCopilotProjectScope(principal, input.organizationId) + : { organizationId: input.organizationId } return db.transaction(async (tx) => { /** Driven from the caller's grants and admin organization, so cost tracks their reach. */ const candidates = await tx.execute<{ id: string }>(sql` WITH accessible AS ( SELECT ${permissions.entityId} AS workspace_id FROM ${permissions} - WHERE ${permissions.userId} = ${principal.userId} + WHERE ${permissions.userId} = ${userId} AND ${permissions.entityType} = 'workspace' UNION SELECT ${workspace.id} FROM ${member} JOIN ${workspace} ON ${workspace.organizationId} = ${member.organizationId} - WHERE ${member.userId} = ${principal.userId} AND ${inArray(member.role, ORG_ADMIN_ROLES)} + WHERE ${member.userId} = ${userId} AND ${inArray(member.role, ORG_ADMIN_ROLES)} ) SELECT DISTINCT ${projectWorkspace.projectId} AS id FROM accessible @@ -104,7 +113,7 @@ export const listProjects: OperationUseCase< JOIN ${project} ON ${project.id} = ${projectWorkspace.projectId} AND ${project.archivedAt} IS NULL WHERE TRUE - ${input.organizationId ? sql`AND ${project.organizationId} = ${input.organizationId}` : sql``} + ${scope.organizationId !== undefined ? sql`AND ${project.organizationId} IS NOT DISTINCT FROM ${scope.organizationId}` : sql``} ${input.cursor ? sql`AND ${projectWorkspace.projectId} > ${input.cursor}` : sql``} ORDER BY 1 LIMIT ${input.limit + 1} diff --git a/apps/sim/lib/projects/files/__integration__/authorization.integration.ts b/apps/sim/lib/projects/files/__integration__/authorization.integration.ts new file mode 100644 index 00000000000..be1f5491242 --- /dev/null +++ b/apps/sim/lib/projects/files/__integration__/authorization.integration.ts @@ -0,0 +1,1528 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import type { + OAuthAccessTokenPrincipal, + ResourceDelegatedPrincipal, + ResourceFileCopyScope, +} from '@sim/auth/principal' +import { db } from '@sim/db' +import { + copilotChats, + folder, + member, + organization, + permissionGroup, + permissionGroupWorkspace, + permissions, + project, + projectWorkspace, + user, + workspace, + workspaceFiles, +} from '@sim/db/schema' +import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures' +import { + createExecutorPrincipal, + createPersonalApiKeyPrincipal, + createSessionPrincipal, + createWorkspaceApiKeyPrincipal, +} from '@sim/testing/factories/principal.factory' +import { createDeferred } from '@sim/testing/helpers/deferred' +import { featureFlagsMock, featureFlagsMockFns } from '@sim/testing/mocks/feature-flags.mock' +import { getErrorMessage, getPostgresErrorCode } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' +import { generateId } from '@sim/utils/id' +import { and, eq, inArray, sql } from 'drizzle-orm' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import type { DbTransaction } from '@/lib/db/types' +import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks' +import { renameProject } from '@/lib/projects/application' +import { + createProjectFileFolder, + getProjectFileMetadata, + listProjectFileFolders, + listProjectFiles, + resolveProjectFileReference, + updateProjectFileFolder, +} from '@/lib/projects/files/application' +import { defineAuthorizedProjectFileUseCase } from '@/lib/projects/files/application/authorized-use-case' +import { projectFileOperations } from '@/lib/projects/files/application/operations' +import { resolveFileFolderTarget } from '@/lib/uploads/contexts/workspace' +import { createFileCopyAuthorizer } from '@/lib/workspace-files/application/copy-authorization' +import { copyFileItems } from '@/lib/workspace-files/application/copy-file-items' +import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' + +vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock) + +const readAccess = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.list, + async execute({ context }) { + return { projectId: context.projectId, canWrite: context.canWrite } + }, +}) +const writeAccess = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.create, + async execute({ context }) { + return { projectId: context.projectId } + }, +}) +const readFile = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.readContent, + async execute({ context }) { + return { fileId: context.file?.id } + }, +}) +const resolveFolderTarget = defineAuthorizedProjectFileUseCase< + typeof projectFileOperations.create, + { projectId: string; folderId?: string | null; folderPath?: string }, + Awaited> +>({ + operation: projectFileOperations.create, + async execute({ + input, + context, + tx, + }: { + input: { projectId: string; folderId?: string | null; folderPath?: string } + context: { owner: { entityType: 'project'; entityId: string } } + tx: DbTransaction + }) { + return resolveFileFolderTarget(context.owner, input, tx) + }, +}) + +const fixtures: { + users: string[] + organizationId: string + projectId: string + workspaces: string[] +}[] = [] +const checks: { name: string; status: 'passed' | 'failed'; durationMs: number; error?: string }[] = + [] + +beforeEach(() => { + featureFlagsMockFns.mockIsFeatureEnabled.mockImplementation(async (flag) => flag === 'projects') + vi.stubEnv('PROJECT_FILES_ENABLED', 'true') +}) + +function check(name: string, run: () => Promise) { + it(name, async () => { + const started = performance.now() + try { + await run() + checks.push({ name, status: 'passed', durationMs: performance.now() - started }) + } catch (error) { + checks.push({ + name, + status: 'failed', + durationMs: performance.now() - started, + error: getErrorMessage(error), + }) + throw error + } + }) +} + +async function fixture() { + const ownerId = generateId() + const readerId = generateId() + const organizationId = generateId() + const projectId = generateId() + const workspaces = [generateId(), generateId()] + fixtures.push({ users: [ownerId, readerId], organizationId, projectId, workspaces }) + await db.insert(user).values( + [ownerId, readerId].map((id) => ({ + id, + name: 'Project file fixture', + email: `${id}@files.invalid`, + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + })) + ) + await db.insert(organization).values({ + id: organizationId, + name: 'Project files', + slug: organizationId, + createdAt: new Date(), + }) + await db.insert(member).values([ + { id: generateId(), organizationId, userId: ownerId, role: 'owner', createdAt: new Date() }, + { id: generateId(), organizationId, userId: readerId, role: 'member', createdAt: new Date() }, + ]) + await db.transaction(async (tx) => { + await tx + .insert(project) + .values({ id: projectId, organizationId, ownerId, name: 'Project files' }) + await tx.insert(workspace).values( + workspaces.map((id, index) => ({ + id, + organizationId, + ownerId, + billedAccountUserId: ownerId, + workspaceMode: 'organization' as const, + name: 'Environment', + forkedFromWorkspaceId: index ? workspaces[0] : null, + })) + ) + await tx + .insert(projectWorkspace) + .values(workspaces.map((workspaceId) => ({ projectId, workspaceId }))) + }) + await db.insert(permissions).values({ + id: generateId(), + entityType: 'workspace', + entityId: workspaces[0], + userId: readerId, + permissionType: 'read', + }) + return { + ownerId, + readerId, + organizationId, + projectId, + workspaces, + reader: createSessionPrincipal({ userId: readerId }), + } +} + +async function grant( + userId: string, + workspaceId: string, + permissionType: 'read' | 'write' | 'admin' +) { + await db + .delete(permissions) + .where( + and( + eq(permissions.userId, userId), + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workspaceId) + ) + ) + await db.insert(permissions).values({ + id: generateId(), + userId, + entityType: 'workspace', + entityId: workspaceId, + permissionType, + }) +} + +function delegated( + userId: string, + projectId: string, + workspaceId: string +): Extract { + return { + kind: 'resource_delegated', + serviceId: 'copilot', + subjectUserId: userId, + audience: 'sim:project-files', + delegationId: generateId(), + issuedAt: new Date(), + expiresAt: new Date(Date.now() + 30_000), + scope: { kind: 'entity', entityType: 'project', entityId: projectId }, + invocation: { kind: 'workspace', workspaceId }, + } +} + +afterAll(async () => { + const reportPath = + process.env.PROJECT_FILE_AUTHORIZATION_REPORT_PATH ?? + resolve('test-results/project-file-authorization.json') + await mkdir(dirname(reportPath), { recursive: true }) + await writeFile(reportPath, JSON.stringify({ checks }, null, 2)) + for (const f of fixtures) { + const memberships = await db + .select({ projectId: projectWorkspace.projectId }) + .from(projectWorkspace) + .where(inArray(projectWorkspace.workspaceId, f.workspaces)) + const projectIds = [f.projectId, ...memberships.map((row) => row.projectId)] + await db.delete(workspaceFiles).where(inArray(workspaceFiles.projectId, projectIds)) + await db.delete(folder).where(inArray(folder.projectId, projectIds)) + await deleteWorkspaceFixture(db, inArray(workspace.id, f.workspaces)) + await db.delete(organization).where(eq(organization.id, f.organizationId)) + await db.delete(user).where(inArray(user.id, f.users)) + } +}) + +describe('Project file authority at the database boundary', () => { + check( + 'folder writes preserve hierarchy and attribution while enforcing writer authority', + async () => { + const f = await fixture() + const args = { principal: f.reader, input: { projectId: f.projectId, name: 'Docs' } } + await expect(createProjectFileFolder.execute(args)).rejects.toMatchObject({ + code: 'forbidden', + }) + await grant(f.readerId, f.workspaces[0], 'admin') + const { folder: root } = await createProjectFileFolder.execute(args) + const { folder: child } = await createProjectFileFolder.execute({ + ...args, + input: { ...args.input, name: 'Before', parentId: root.id }, + }) + const { folder: destination } = await createProjectFileFolder.execute({ + ...args, + input: { ...args.input, name: 'Resources' }, + }) + const result = await updateProjectFileFolder.execute({ + ...args, + input: { + projectId: f.projectId, + folderId: child.id, + name: 'Architecture', + parentId: destination.id, + }, + }) + expect(result.folder).toMatchObject({ + id: child.id, + owner: { entityType: 'project', entityId: f.projectId }, + userId: f.readerId, + parentId: destination.id, + path: 'Resources/Architecture', + }) + const listing = await listProjectFileFolders.execute({ + principal: f.reader, + input: { projectId: f.projectId }, + }) + expect(listing.folders.map((folder) => folder.id).sort()).toEqual( + [root.id, child.id, destination.id].sort() + ) + expect( + await resolveFolderTarget.execute({ + principal: f.reader, + input: { projectId: f.projectId, folderPath: '/Resources/Architecture' }, + }) + ).toMatchObject({ id: child.id }) + expect( + await resolveFolderTarget.execute({ + principal: f.reader, + input: { projectId: f.projectId, folderId: child.id }, + }) + ).toMatchObject({ id: child.id }) + expect( + await resolveFolderTarget.execute({ + principal: f.reader, + input: { projectId: f.projectId, folderPath: '/' }, + }) + ).toBeNull() + } + ) + + check( + 'folder mutations refuse foreign owners, duplicate siblings, cycles and ambiguous targets', + async () => { + const f = await fixture() + const other = await fixture() + await grant(f.readerId, f.workspaces[0], 'admin') + const principal = f.reader + const { folder: root } = await createProjectFileFolder.execute({ + principal, + input: { projectId: f.projectId, name: 'Docs' }, + }) + const { folder: child } = await createProjectFileFolder.execute({ + principal, + input: { projectId: f.projectId, name: 'Nested', parentId: root.id }, + }) + const { folder: foreign } = await createProjectFileFolder.execute({ + principal: createSessionPrincipal({ userId: other.ownerId }), + input: { projectId: other.projectId, name: 'Docs' }, + }) + await expect( + createProjectFileFolder.execute({ + principal, + input: { projectId: f.projectId, name: 'Docs' }, + }) + ).rejects.toMatchObject({ code: 'conflict' }) + await expect( + createProjectFileFolder.execute({ + principal, + input: { projectId: f.projectId, name: 'Invalid', parentId: foreign.id }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + for (const input of [ + { projectId: f.projectId, folderId: foreign.id, name: 'Invalid' }, + { projectId: f.projectId, folderId: child.id, parentId: foreign.id }, + ]) + await expect(updateProjectFileFolder.execute({ principal, input })).rejects.toMatchObject({ + code: 'not_found', + }) + for (const parentId of [root.id, child.id]) + await expect( + updateProjectFileFolder.execute({ + principal, + input: { projectId: f.projectId, folderId: root.id, parentId }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + await expect( + resolveFolderTarget.execute({ + principal, + input: { projectId: f.projectId, folderId: root.id, folderPath: '/Docs' }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + await expect( + resolveFolderTarget.execute({ + principal, + input: { projectId: f.projectId, folderPath: '/Missing' }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + await expect( + resolveFolderTarget.execute({ + principal, + input: { projectId: f.projectId, folderId: foreign.id }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + const listing = await listProjectFileFolders.execute({ + principal, + input: { projectId: f.projectId }, + }) + expect( + listing.folders.map((folder) => ({ id: folder.id, parentId: folder.parentId })) + ).toEqual( + expect.arrayContaining([ + { id: root.id, parentId: null }, + { id: child.id, parentId: root.id }, + ]) + ) + } + ) + + check( + 'file listing isolates owners and preserves pagination and nested folder attribution', + async () => { + const f = await fixture() + const other = await fixture() + const rootFileId = generateId() + const nestedFileId = generateId() + const folderId = generateId() + await db.insert(folder).values({ + id: folderId, + projectId: f.projectId, + resourceType: 'file', + name: 'Docs', + userId: f.ownerId, + }) + await db.insert(workspaceFiles).values([ + { + id: rootFileId, + userId: f.ownerId, + projectId: f.projectId, + context: 'project', + key: `project/${f.projectId}/${rootFileId}`, + originalName: 'a.md', + contentType: 'text/markdown', + sizeBytes: 0, + }, + { + id: nestedFileId, + userId: f.ownerId, + projectId: f.projectId, + context: 'project', + folderId, + key: `project/${f.projectId}/${nestedFileId}`, + originalName: 'b.md', + contentType: 'text/markdown', + sizeBytes: 0, + }, + { + id: generateId(), + userId: f.ownerId, + workspaceId: f.workspaces[0], + context: 'workspace', + key: `workspace/${f.workspaces[0]}/a.md`, + originalName: 'a.md', + contentType: 'text/markdown', + sizeBytes: 0, + }, + { + id: generateId(), + userId: other.ownerId, + projectId: other.projectId, + context: 'project', + key: `project/${other.projectId}/a.md`, + originalName: 'a.md', + contentType: 'text/markdown', + sizeBytes: 0, + }, + ]) + const input = { projectId: f.projectId, limit: 1, sortBy: 'name', sortOrder: 'asc' } as const + const first = await listProjectFiles.execute({ principal: f.reader, input }) + expect(first.capabilities).toEqual({ canRead: true, canWrite: false }) + expect(first.files.map((file) => file.id)).toEqual([rootFileId]) + expect(first.files[0]).toMatchObject({ + owner: { entityType: 'project', entityId: f.projectId }, + uploadedBy: f.ownerId, + folderId: null, + folderPath: null, + }) + if (!first.nextKeys) throw new Error('The next Project file page must be available') + const second = await listProjectFiles.execute({ + principal: f.reader, + input: { ...input, after: first.nextKeys }, + }) + expect(second.files.map((file) => file.id)).toEqual([nestedFileId]) + expect(second.files[0]).toMatchObject({ folderId, folderPath: 'Docs' }) + expect(second.nextKeys).toBeNull() + } + ) + + check('folder path pages preserve shallow, recursive, root, and missing-path scope', async () => { + const f = await fixture() + const docsId = generateId() + const nestedId = generateId() + const siblingId = generateId() + await db.insert(folder).values([ + { + id: docsId, + projectId: f.projectId, + resourceType: 'file', + name: 'Docs', + userId: f.ownerId, + }, + { + id: nestedId, + projectId: f.projectId, + resourceType: 'file', + name: 'Q3/Q4', + parentId: docsId, + userId: f.ownerId, + }, + { + id: siblingId, + projectId: f.projectId, + resourceType: 'file', + name: 'Elsewhere', + userId: f.ownerId, + }, + ]) + const fileIds = [generateId(), generateId(), generateId(), generateId()] + await db.insert(workspaceFiles).values( + fileIds.map((id, index) => ({ + id, + userId: f.ownerId, + projectId: f.projectId, + context: 'project', + folderId: [null, docsId, nestedId, siblingId][index], + key: `project/${f.projectId}/${id}`, + originalName: `${index}.md`, + contentType: 'text/markdown', + sizeBytes: 0, + })) + ) + const base = { projectId: f.projectId, limit: 100, sortBy: 'name', sortOrder: 'asc' } as const + const page = (filter: { folderPath?: string; recursive?: boolean; folderId?: string | null }) => + listProjectFiles.execute({ principal: f.reader, input: { ...base, ...filter } }) + expect((await page({ folderPath: '/Docs' })).files.map((file) => file.id)).toEqual([fileIds[1]]) + expect( + (await page({ folderPath: '/Docs', recursive: true })).files.map((file) => file.id) + ).toEqual([fileIds[1], fileIds[2]]) + expect((await page({ folderPath: '/Docs/Q3%2FQ4' })).files.map((file) => file.id)).toEqual([ + fileIds[2], + ]) + expect((await page({ folderPath: '/' })).files.map((file) => file.id)).toEqual([fileIds[0]]) + expect((await page({ folderPath: '/', recursive: true })).files.map((file) => file.id)).toEqual( + fileIds + ) + expect((await page({ recursive: false })).files.map((file) => file.id)).toEqual(fileIds) + expect(await page({ folderPath: '/Missing' })).toMatchObject({ files: [], nextKeys: null }) + await expect(page({ folderPath: '/Docs', folderId: siblingId })).rejects.toMatchObject({ + code: 'validation', + }) + const first = await listProjectFiles.execute({ + principal: f.reader, + input: { ...base, folderPath: '/Docs', recursive: true, limit: 1 }, + }) + if (!first.nextKeys) throw new Error('The nested folder page must have a cursor') + const next = await listProjectFiles.execute({ + principal: f.reader, + input: { ...base, folderPath: '/Docs', recursive: true, limit: 1, after: first.nextKeys }, + }) + expect(next.files.map((file) => file.id)).toEqual([fileIds[2]]) + expect(next.nextKeys).toBeNull() + expect( + (await listProjectFiles.execute({ principal: f.reader, input: { ...base, limit: 1000 } })) + .files + ).toHaveLength(4) + }) + + check( + 'file references resolve exact owner paths and conceal foreign or archived identities', + async () => { + const f = await fixture() + const other = await fixture() + const folderId = generateId() + await db.insert(folder).values({ + id: folderId, + projectId: f.projectId, + resourceType: 'file', + name: 'Q3/Q4', + userId: f.ownerId, + }) + const fileId = generateId() + const foreignId = generateId() + const archivedId = generateId() + await db.insert(workspaceFiles).values([ + { + id: fileId, + userId: f.ownerId, + projectId: f.projectId, + context: 'project', + folderId, + key: `project/${f.projectId}/${fileId}`, + originalName: 'Architecture overview.md', + contentType: 'text/markdown', + sizeBytes: 0, + }, + { + id: foreignId, + userId: other.ownerId, + projectId: other.projectId, + context: 'project', + key: `project/${other.projectId}/${foreignId}`, + originalName: 'Architecture overview.md', + contentType: 'text/markdown', + sizeBytes: 0, + }, + { + id: archivedId, + userId: f.ownerId, + projectId: f.projectId, + context: 'project', + key: `project/${f.projectId}/${archivedId}`, + originalName: 'old.md', + contentType: 'text/markdown', + sizeBytes: 0, + deletedAt: new Date(), + }, + ]) + const resolve = (fileReference: string) => + resolveProjectFileReference.execute({ + principal: f.reader, + input: { projectId: f.projectId, fileReference }, + }) + const vfsPath = `projects/${f.projectId}/files/Q3%2FQ4/Architecture%20overview.md` + for (const reference of [fileId, 'files/Q3%2FQ4/Architecture%20overview.md', vfsPath]) { + const result = await resolve(reference) + expect(result.file.id).toBe(fileId) + expect(result.file.path).toBe(`/api/projects/${f.projectId}/files/${fileId}/content`) + expect(result.vfsPath).toBe(vfsPath) + } + for (const reference of [ + foreignId, + archivedId, + 'files/Q3%2FQ4/architecture%20overview.md', + 'files/Architecture%20overview.md', + ]) { + await expect(resolve(reference)).rejects.toMatchObject({ code: 'not_found' }) + } + for (const reference of [ + `projects/${other.projectId}/files/Architecture%20overview.md`, + `workspaces/${f.workspaces[0]}/files/example.md`, + 'files/../old.md', + 'files/Q3%2FQ4/%broken', + ]) { + await expect(resolve(reference)).rejects.toMatchObject({ code: 'validation' }) + } + } + ) + + check( + 'file metadata returns creator attribution and conceals a foreign Project file ID', + async () => { + const f = await fixture() + const other = await fixture() + const fileId = generateId() + const foreignFileId = generateId() + await db.insert(workspaceFiles).values([ + { + id: fileId, + userId: f.ownerId, + projectId: f.projectId, + context: 'project', + key: `project/${f.projectId}/${fileId}`, + originalName: 'architecture.md', + contentType: 'text/markdown', + sizeBytes: 0, + }, + { + id: foreignFileId, + userId: other.ownerId, + projectId: other.projectId, + context: 'project', + key: `project/${other.projectId}/${foreignFileId}`, + originalName: 'architecture.md', + contentType: 'text/markdown', + sizeBytes: 0, + }, + ]) + const result = await getProjectFileMetadata.execute({ + principal: f.reader, + input: { projectId: f.projectId, fileId }, + }) + expect(result.capabilities).toEqual({ canRead: true, canWrite: false }) + expect(result.file).toMatchObject({ + id: fileId, + owner: { entityType: 'project', entityId: f.projectId }, + name: 'architecture.md', + uploadedBy: f.ownerId, + }) + await expect( + getProjectFileMetadata.execute({ + principal: f.reader, + input: { projectId: f.projectId, fileId: foreignFileId }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + } + ) + + check('partial readers can read but cannot write or administer the Project', async () => { + const f = await fixture() + const args = { principal: f.reader, input: { projectId: f.projectId } } + expect(await readAccess.execute(args)).toEqual({ projectId: f.projectId, canWrite: false }) + await expect(writeAccess.execute(args)).rejects.toMatchObject({ code: 'forbidden' }) + await grant(f.readerId, f.workspaces[0], 'admin') + expect(await writeAccess.execute(args)).toEqual({ projectId: f.projectId }) + await expect( + renameProject.execute({ + ...args, + input: { projectId: f.projectId, name: 'Forbidden rename' }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + }) + + check( + 'write requires every active environment unless an environment admin or org admin', + async () => { + const f = await fixture() + const args = { principal: f.reader, input: { projectId: f.projectId } } + await grant(f.readerId, f.workspaces[0], 'write') + await expect(writeAccess.execute(args)).rejects.toMatchObject({ code: 'forbidden' }) + await grant(f.readerId, f.workspaces[1], 'write') + expect(await writeAccess.execute(args)).toEqual({ projectId: f.projectId }) + await grant(f.readerId, f.workspaces[1], 'read') + await expect(writeAccess.execute(args)).rejects.toMatchObject({ code: 'forbidden' }) + expect( + await writeAccess.execute({ + ...args, + principal: createSessionPrincipal({ userId: f.ownerId }), + }) + ).toEqual({ projectId: f.projectId }) + await grant(f.readerId, f.workspaces[1], 'admin') + await db + .update(workspace) + .set({ archivedAt: new Date() }) + .where(eq(workspace.id, f.workspaces[1])) + expect(await writeAccess.execute(args)).toEqual({ projectId: f.projectId }) + await grant(f.readerId, f.workspaces[0], 'read') + expect(await readAccess.execute(args)).toEqual({ projectId: f.projectId, canWrite: false }) + await expect(writeAccess.execute(args)).rejects.toMatchObject({ code: 'forbidden' }) + await grant(f.readerId, f.workspaces[0], 'admin') + expect(await writeAccess.execute(args)).toEqual({ projectId: f.projectId }) + await db.transaction(async (tx) => { + await tx + .update(workspace) + .set({ archivedAt: new Date() }) + .where(inArray(workspace.id, f.workspaces)) + await tx.update(project).set({ archivedAt: new Date() }).where(eq(project.id, f.projectId)) + }) + await expect( + writeAccess.execute({ ...args, principal: createSessionPrincipal({ userId: f.ownerId }) }) + ).rejects.toMatchObject({ code: 'conflict' }) + } + ) + + check( + 'credential policy aggregates accessible environments and rechecks OAuth scope', + async () => { + const f = await fixture() + await grant(f.readerId, f.workspaces[0], 'admin') + const principal = createPersonalApiKeyPrincipal({ userId: f.readerId }) + const input = { projectId: f.projectId } + await db + .update(workspace) + .set({ allowPersonalApiKeys: false }) + .where(eq(workspace.id, f.workspaces[1])) + expect(await writeAccess.execute({ principal, input })).toEqual({ projectId: f.projectId }) + await grant(f.readerId, f.workspaces[1], 'read') + await expect(writeAccess.execute({ principal, input })).rejects.toMatchObject({ + detailCode: 'PERSONAL_API_KEYS_DISABLED', + }) + const oauth: OAuthAccessTokenPrincipal = { + kind: 'oauth_access_token', + userId: f.readerId, + clientId: 'fixture-client', + tokenId: generateId(), + scopes: ['api:read'], + expiresAt: new Date(Date.now() + 60_000), + } + await expect(writeAccess.execute({ principal: oauth, input })).rejects.toMatchObject({ + detailCode: 'INSUFFICIENT_SCOPE', + }) + } + ) + + check( + 'Files permission policy aggregates visible environments without importing the Issues gate', + async () => { + const f = await fixture() + const groupId = generateId() + await db.insert(permissionGroup).values({ + id: groupId, + organizationId: f.organizationId, + createdBy: f.ownerId, + name: 'Files disabled', + config: { hideFilesTab: true }, + membershipMode: 'inherit', + }) + await db.insert(permissionGroupWorkspace).values({ + id: generateId(), + permissionGroupId: groupId, + workspaceId: f.workspaces[1], + organizationId: f.organizationId, + }) + const args = { principal: f.reader, input: { projectId: f.projectId } } + expect(await readAccess.execute(args)).toEqual({ projectId: f.projectId, canWrite: false }) + await grant(f.readerId, f.workspaces[1], 'read') + await expect(readAccess.execute(args)).rejects.toMatchObject({ + detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED', + }) + await db + .update(permissionGroup) + .set({ config: { deniedPartialAccessProjectIssues: [f.projectId] } }) + .where(eq(permissionGroup.id, groupId)) + expect(await readAccess.execute(args)).toEqual({ projectId: f.projectId, canWrite: false }) + } + ) + + check( + 'workspace keys and executor authority cannot be upgraded into Project access', + async () => { + const f = await fixture() + const input = { projectId: f.projectId } + for (const principal of [ + createWorkspaceApiKeyPrincipal({ workspaceId: f.workspaces[0] }), + createExecutorPrincipal({ subjectUserId: f.ownerId, workspaceId: f.workspaces[0] }), + ]) { + await expect(readAccess.execute({ principal, input })).rejects.toMatchObject({ + detailCode: 'PRINCIPAL_KIND_NOT_PERMITTED', + }) + } + } + ) + + check( + 'Copilot delegation binds its current environment and rejects revoked access or forged scope', + async () => { + const f = await fixture() + const principal = delegated(f.readerId, f.projectId, f.workspaces[0]) + const input = { projectId: f.projectId } + expect(await readAccess.execute({ principal, input })).toEqual({ + projectId: f.projectId, + canWrite: false, + }) + await expect( + readAccess.execute({ principal: { ...principal, expiresAt: new Date(0) }, input }) + ).rejects.toMatchObject({ code: 'forbidden' }) + const fileId = generateId() + await expect( + readAccess.execute({ + principal: { + ...principal, + scope: { kind: 'entity', entityType: 'project', entityId: f.projectId, fileId }, + }, + input: { ...input, fileId }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + await expect( + readAccess.execute({ + principal: { + ...principal, + scope: { kind: 'entity', entityType: 'project', entityId: generateId() }, + }, + input, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + await db.delete(permissions).where(eq(permissions.userId, f.readerId)) + await expect(readAccess.execute({ principal, input })).rejects.toMatchObject({ + code: 'forbidden', + }) + } + ) + + check( + 'organization conversations can reach authorized Projects without selecting an environment', + async () => { + const f = await fixture() + const chatId = generateId() + await db.insert(copilotChats).values({ + id: chatId, + userId: f.readerId, + organizationId: f.organizationId, + workspaceId: null, + type: 'mothership', + config: { conversationMode: 'agent' }, + title: 'Project context', + }) + const principal: ResourceDelegatedPrincipal = { + ...delegated(f.readerId, f.projectId, f.workspaces[0]), + serviceId: 'copilot', + invocation: { kind: 'chat', chatId }, + } + expect(await readAccess.execute({ principal, input: { projectId: f.projectId } })).toEqual({ + projectId: f.projectId, + canWrite: false, + }) + await db + .delete(member) + .where(and(eq(member.userId, f.readerId), eq(member.organizationId, f.organizationId))) + await expect( + readAccess.execute({ principal, input: { projectId: f.projectId } }) + ).rejects.toMatchObject({ code: 'not_found' }) + } + ) + + check('checked environment key policy cannot change before a file mutation commits', async () => { + const f = await fixture() + await grant(f.readerId, f.workspaces[0], 'admin') + const entered = createDeferred() + const release = createDeferred() + const mutation = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.create, + async execute() { + entered.resolve() + await release.promise + }, + }).execute({ + principal: createPersonalApiKeyPrincipal({ userId: f.readerId }), + input: { projectId: f.projectId }, + }) + await entered.promise + try { + const policyUpdate = db.transaction(async (tx) => { + await tx.execute(sql`SET LOCAL lock_timeout = '100ms'`) + await tx + .update(workspace) + .set({ allowPersonalApiKeys: false }) + .where(eq(workspace.id, f.workspaces[0])) + }) + expect(await policyUpdate.then(() => null, getPostgresErrorCode)).toBe('55P03') + } finally { + release.resolve() + await mutation + } + }) + + check( + 'file-bound realtime grants cannot become listing authority or cross-file reads', + async () => { + const f = await fixture() + const fileId = generateId() + const folderId = generateId() + await db.insert(folder).values({ + id: folderId, + userId: f.ownerId, + projectId: f.projectId, + resourceType: 'file', + name: 'Architecture', + }) + await db.insert(workspaceFiles).values({ + id: fileId, + projectId: f.projectId, + userId: f.ownerId, + context: 'project', + folderId, + key: `project/${f.projectId}/${fileId}`, + originalName: 'architecture.md', + contentType: 'text/markdown', + sizeBytes: 0, + }) + const principal: ResourceDelegatedPrincipal = { + kind: 'resource_delegated', + serviceId: 'realtime', + subjectUserId: f.readerId, + delegationId: generateId(), + audience: 'sim:project-files', + issuedAt: new Date(), + expiresAt: new Date(Date.now() + 30_000), + scope: { kind: 'entity', entityType: 'project', entityId: f.projectId, fileId }, + invocation: { kind: 'realtime', connectionId: 'fixture-connection' }, + } + expect( + await readFile.execute({ principal, input: { projectId: f.projectId, fileId } }) + ).toEqual({ fileId }) + await expect( + readAccess.execute({ principal, input: { projectId: f.projectId } }) + ).rejects.toMatchObject({ code: 'forbidden' }) + await expect( + readFile.execute({ principal, input: { projectId: f.projectId, fileId: generateId() } }) + ).rejects.toMatchObject({ code: 'forbidden' }) + } + ) +}) + +describe('shared file identity and current actor authority', () => { + check('creator attribution grants neither scope access nor access after departure', async () => { + const f = await fixture() + const workspaceFileId = generateId() + const projectFileId = generateId() + await db.insert(workspaceFiles).values([ + { + id: workspaceFileId, + userId: f.readerId, + workspaceId: f.workspaces[0], + context: 'workspace', + key: `workspace/${f.workspaces[0]}/${workspaceFileId}`, + originalName: 'workspace.md', + contentType: 'text/markdown', + sizeBytes: 0, + }, + { + id: projectFileId, + userId: f.readerId, + projectId: f.projectId, + context: 'project', + key: `project/${f.projectId}/${projectFileId}`, + originalName: 'project.md', + contentType: 'text/markdown', + sizeBytes: 0, + }, + ]) + const workspaceArgs = { principal: f.reader, input: { fileId: workspaceFileId } } + const projectArgs = { + principal: f.reader, + input: { projectId: f.projectId, fileId: projectFileId }, + } + await readWorkspaceFileMetadata.authorize(workspaceArgs) + await getProjectFileMetadata.authorize(projectArgs) + await expect( + readWorkspaceFileMetadata.authorize({ + ...workspaceArgs, + input: { fileId: workspaceFileId, assertedWorkspaceId: f.workspaces[1] }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + await expect( + readWorkspaceFileMetadata.authorize({ ...workspaceArgs, input: { fileId: projectFileId } }) + ).rejects.toMatchObject({ code: 'not_found' }) + await expect( + getProjectFileMetadata.authorize({ + ...projectArgs, + input: { projectId: f.projectId, fileId: workspaceFileId }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + await db.delete(permissions).where(eq(permissions.userId, f.readerId)) + await expect(readWorkspaceFileMetadata.authorize(workspaceArgs)).rejects.toMatchObject({ + code: 'forbidden', + }) + await expect(getProjectFileMetadata.authorize(projectArgs)).rejects.toMatchObject({ + code: 'not_found', + }) + const collaborator = createSessionPrincipal({ userId: f.ownerId }) + await readWorkspaceFileMetadata.authorize({ ...workspaceArgs, principal: collaborator }) + await getProjectFileMetadata.authorize({ ...projectArgs, principal: collaborator }) + const rows = await db + .select({ userId: workspaceFiles.userId }) + .from(workspaceFiles) + .where(inArray(workspaceFiles.id, [workspaceFileId, projectFileId])) + expect(rows.map((row) => row.userId)).toEqual([f.readerId, f.readerId]) + }) + + check( + 'workspace metadata keeps deleted-file opt-in separate from workspace archival', + async () => { + const f = await fixture() + const fileId = generateId() + await db.insert(workspaceFiles).values({ + id: fileId, + userId: f.ownerId, + workspaceId: f.workspaces[0], + context: 'workspace', + key: `workspace/${f.workspaces[0]}/${fileId}`, + originalName: 'archived.md', + contentType: 'text/markdown', + sizeBytes: 0, + deletedAt: new Date(), + }) + const args = { principal: f.reader, input: { fileId } } + await expect(readWorkspaceFileMetadata.authorize(args)).rejects.toMatchObject({ + code: 'not_found', + }) + await readWorkspaceFileMetadata.authorize({ + ...args, + input: { fileId, includeDeleted: true }, + }) + await db + .update(workspace) + .set({ archivedAt: new Date() }) + .where(eq(workspace.id, f.workspaces[0])) + await expect( + readWorkspaceFileMetadata.authorize({ ...args, input: { fileId, includeDeleted: true } }) + ).rejects.toMatchObject({ code: 'not_found' }) + } + ) + + check( + 'a ban or permission revocation during preparation prevents a committed write', + async () => { + for (const revoke of ['permission', 'ban'] as const) { + const f = await fixture() + await grant(f.readerId, f.workspaces[0], 'admin') + const folderId = generateId() + const mutation = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.createFolder, + async prepare() { + if (revoke === 'ban') + await db.update(user).set({ banned: true }).where(eq(user.id, f.readerId)) + else await grant(f.readerId, f.workspaces[0], 'read') + return folderId + }, + async execute({ tx, prepared }) { + if (!prepared) throw new Error('Prepared folder ID missing') + await tx.insert(folder).values({ + id: prepared, + projectId: f.projectId, + userId: f.readerId, + resourceType: 'file', + name: 'Must not commit', + }) + }, + }) + await expect( + mutation.execute({ principal: f.reader, input: { projectId: f.projectId } }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect( + await db.select({ id: folder.id }).from(folder).where(eq(folder.id, folderId)) + ).toEqual([]) + } + } + ) +}) + +describe('compound copy owner authority', () => { + function selection(f: Awaited>): Omit { + return { + source: { + owner: { entityType: 'project', entityId: f.projectId }, + fileIds: [generateId()], + folderIds: [], + }, + destination: { + owner: { entityType: 'workspace', entityId: f.workspaces[1] }, + folderId: null, + }, + } + } + + function copyPrincipal( + f: Awaited>, + input: Omit + ) { + return { + ...delegated(f.readerId, f.projectId, f.workspaces[0]), + audience: 'sim:files:copy', + scope: { kind: 'file_copy' as const, ...input }, + } + } + + check( + 'copy checks source read and destination write independently and rechecks after preparation', + async () => { + const f = await fixture() + await grant(f.readerId, f.workspaces[1], 'write') + const input = selection(f) + const authorize = await createFileCopyAuthorizer(f.reader, input) + expect(await db.transaction(authorize)).toMatchObject({ + source: { owner: input.source.owner, canWrite: false, ownerUserId: f.ownerId }, + destination: { owner: input.destination.owner, billedAccountUserId: f.ownerId }, + }) + const reverse = { + source: { ...input.source, owner: input.destination.owner }, + destination: { ...input.destination, owner: input.source.owner }, + } + const reverseAuthorize = await createFileCopyAuthorizer(f.reader, reverse) + await expect(db.transaction(reverseAuthorize)).rejects.toMatchObject({ code: 'forbidden' }) + await grant(f.readerId, f.workspaces[1], 'read') + await expect(db.transaction(authorize)).rejects.toMatchObject({ code: 'forbidden' }) + } + ) + + check( + 'copy refuses widened paired grants, entity grants, workspace keys and executors', + async () => { + const f = await fixture() + await grant(f.readerId, f.workspaces[1], 'write') + const input = selection(f) + const principal = copyPrincipal(f, input) + expect(await db.transaction(await createFileCopyAuthorizer(principal, input))).toMatchObject({ + source: { owner: input.source.owner }, + destination: { owner: input.destination.owner }, + }) + for (const changed of [ + { ...input, source: { ...input.source, fileIds: [generateId()] } }, + { ...input, destination: { ...input.destination, folderId: generateId() } }, + { ...input, source: { ...input.source, owner: input.destination.owner } }, + ]) { + await expect(createFileCopyAuthorizer(principal, changed)).rejects.toMatchObject({ + code: 'forbidden', + }) + } + const realtime: ResourceDelegatedPrincipal = { + kind: 'resource_delegated', + serviceId: 'realtime', + subjectUserId: f.readerId, + audience: 'sim:files:copy', + delegationId: generateId(), + issuedAt: new Date(), + expiresAt: new Date(Date.now() + 30_000), + scope: { + kind: 'entity', + entityType: 'project', + entityId: f.projectId, + fileId: input.source.fileIds[0], + }, + invocation: { kind: 'realtime', connectionId: 'copy-fixture' }, + } + for (const rejected of [ + realtime, + delegated(f.readerId, f.projectId, f.workspaces[0]), + createWorkspaceApiKeyPrincipal({ workspaceId: f.workspaces[1] }), + createExecutorPrincipal({ subjectUserId: f.readerId, workspaceId: f.workspaces[1] }), + ]) { + await expect(createFileCopyAuthorizer(rejected, input)).rejects.toMatchObject({ + code: 'forbidden', + }) + } + } + ) + + check('copy cannot escape the origin organization despite access to both owners', async () => { + const f = await fixture() + const other = await fixture() + for (const workspaceId of other.workspaces) await grant(f.ownerId, workspaceId, 'admin') + const input = { + ...selection(f), + destination: { + owner: { entityType: 'project' as const, entityId: other.projectId }, + folderId: null, + }, + } + const principal = { ...copyPrincipal(f, input), subjectUserId: f.ownerId } + const authorize = await createFileCopyAuthorizer(principal, input) + await expect(db.transaction(authorize)).rejects.toMatchObject({ code: 'not_found' }) + expect( + await db.transaction( + await createFileCopyAuthorizer(createSessionPrincipal({ userId: f.ownerId }), input) + ) + ).toMatchObject({ destination: { owner: input.destination.owner } }) + }) + + check('copy rechecks organization chat actor and mode at commit', async () => { + const f = await fixture() + await grant(f.readerId, f.workspaces[1], 'write') + const input = selection(f) + const chatId = generateId() + await db.insert(copilotChats).values({ + id: chatId, + userId: f.readerId, + organizationId: f.organizationId, + workspaceId: null, + type: 'mothership', + config: { conversationMode: 'agent' }, + title: 'Copy files', + }) + const principal = { ...copyPrincipal(f, input), invocation: { kind: 'chat' as const, chatId } } + const authorize = await createFileCopyAuthorizer(principal, input) + expect(await db.transaction(authorize)).toMatchObject({ source: { owner: input.source.owner } }) + await db + .update(copilotChats) + .set({ config: { conversationMode: 'ask' } }) + .where(eq(copilotChats.id, chatId)) + await expect(db.transaction(authorize)).rejects.toMatchObject({ code: 'not_found' }) + await db + .update(copilotChats) + .set({ config: { conversationMode: 'agent' }, userId: f.ownerId }) + .where(eq(copilotChats.id, chatId)) + await expect(db.transaction(authorize)).rejects.toMatchObject({ code: 'not_found' }) + }) + + check('copy applies personal-key availability and OAuth write scope to both owners', async () => { + const f = await fixture() + await grant(f.readerId, f.workspaces[1], 'write') + await db + .update(workspace) + .set({ allowPersonalApiKeys: true }) + .where(inArray(workspace.id, f.workspaces)) + const input = selection(f) + const key = createPersonalApiKeyPrincipal({ userId: f.readerId }) + expect(await db.transaction(await createFileCopyAuthorizer(key, input))).toMatchObject({ + destination: { owner: input.destination.owner }, + }) + await db + .update(workspace) + .set({ allowPersonalApiKeys: false }) + .where(eq(workspace.id, f.workspaces[0])) + await expect(db.transaction(await createFileCopyAuthorizer(key, input))).rejects.toMatchObject({ + code: 'forbidden', + }) + const oauth: OAuthAccessTokenPrincipal = { + kind: 'oauth_access_token', + userId: f.readerId, + tokenId: generateId(), + clientId: 'test-client', + scopes: ['api:read'], + expiresAt: new Date(Date.now() + 60_000), + } + await expect(createFileCopyAuthorizer(oauth, input)).rejects.toMatchObject({ + code: 'forbidden', + }) + }) + + check('copy blocks a suspended actor after preparation for workspace-only owners', async () => { + const f = await fixture() + await grant(f.readerId, f.workspaces[1], 'write') + const original = selection(f) + const input = { + ...original, + source: { + ...original.source, + owner: { entityType: 'workspace' as const, entityId: f.workspaces[0] }, + }, + } + const authorize = await createFileCopyAuthorizer(f.reader, input) + expect(await db.transaction(authorize)).toMatchObject({ source: { owner: input.source.owner } }) + await db.update(user).set({ banned: true }).where(eq(user.id, f.readerId)) + await expect(db.transaction(authorize)).rejects.toMatchObject({ code: 'forbidden' }) + }) + + check( + 'copy locks opposite Project directions consistently in overlapping transactions', + async () => { + const f = await fixture() + const other = await fixture() + for (const workspaceId of other.workspaces) await grant(f.ownerId, workspaceId, 'admin') + const principal = createSessionPrincipal({ userId: f.ownerId }) + const input = { + ...selection(f), + destination: { + owner: { entityType: 'project' as const, entityId: other.projectId }, + folderId: null, + }, + } + const reverse = { + source: { ...input.source, owner: input.destination.owner }, + destination: { ...input.destination, owner: input.source.owner }, + } + const forward = await createFileCopyAuthorizer(principal, input) + const backward = await createFileCopyAuthorizer(principal, reverse) + const results = await Promise.all([db.transaction(forward), db.transaction(backward)]) + expect(results.map((result) => result.destination.owner.entityId)).toEqual([ + other.projectId, + f.projectId, + ]) + } + ) + + check( + 'copy does not upgrade shared membership locks across two Projects in one organization', + async () => { + const f = await fixture() + const extraWorkspaceId = generateId() + f.workspaces.push(extraWorkspaceId) + await insertWorkspaceFixture(db, { + id: extraWorkspaceId, + name: 'Separate Project', + organizationId: f.organizationId, + ownerId: f.ownerId, + billedAccountUserId: f.ownerId, + workspaceMode: 'organization', + }) + const [extra] = await db + .select() + .from(projectWorkspace) + .where(eq(projectWorkspace.workspaceId, extraWorkspaceId)) + const first = selection(f) + const second = { + source: { + ...first.source, + owner: { entityType: 'project' as const, entityId: extra.projectId }, + }, + destination: { + owner: { entityType: 'workspace' as const, entityId: extraWorkspaceId }, + folderId: null, + }, + } + const principal = createSessionPrincipal({ userId: f.ownerId }) + const authorizeFirst = await createFileCopyAuthorizer(principal, first) + const authorizeSecond = await createFileCopyAuthorizer(principal, second) + const ready = createDeferred() + const release = createDeferred() + const organizationEdit = db.transaction(async (tx) => { + await acquirePermissionGroupOrgLock(tx, f.organizationId) + const [connection] = await tx.execute<{ pid: number }>(sql`SELECT pg_backend_pid() AS pid`) + ready.resolve(connection.pid) + await release.promise + }) + const pid = await Promise.race([ + ready.promise, + organizationEdit.then(() => { + throw new Error('Organization edit exited early') + }), + ]) + const outcomes = Promise.allSettled([ + db.transaction(authorizeFirst), + db.transaction(authorizeSecond), + ]) + try { + let blocked = 0 + for (let attempt = 0; attempt < 100; attempt++) { + const [state] = await db.execute<{ count: number }>(sql` + SELECT count(*)::int AS count FROM pg_stat_activity + WHERE ${pid} = ANY(pg_blocking_pids(pid)) AND wait_event = 'advisory' + `) + blocked = state.count + if (blocked === 2) break + await sleep(10) + } + expect(blocked).toBe(2) + } finally { + release.resolve() + await organizationEdit + await outcomes + } + const results = await outcomes + expect(results.map((result) => result.status)).toEqual(['fulfilled', 'fulfilled']) + } + ) +}) + +describe('Mothership origin navigation and independent Project authority', () => { + async function target(f: Awaited>) { + const workspaceId = generateId() + f.workspaces.push(workspaceId) + await insertWorkspaceFixture(db, { + id: workspaceId, + name: 'Independent target', + organizationId: f.organizationId, + ownerId: f.ownerId, + billedAccountUserId: f.ownerId, + workspaceMode: 'organization', + }) + const [binding] = await db + .select() + .from(projectWorkspace) + .where(eq(projectWorkspace.workspaceId, workspaceId)) + if (!binding) throw new Error('Target Project missing') + await grant(f.readerId, workspaceId, 'admin') + return { workspaceId, projectId: binding.projectId } + } + + check( + 'origin A reads and writes B and authorizes A to B copy with independent live grants', + async () => { + const f = await fixture() + const b = await target(f) + const principal = delegated(f.readerId, b.projectId, f.workspaces[0]) + const result = await createProjectFileFolder.execute({ + principal, + input: { projectId: b.projectId, name: 'Target folder' }, + }) + expect( + ( + await listProjectFileFolders.execute({ principal, input: { projectId: b.projectId } }) + ).folders.map((row) => row.id) + ).toContain(result.folder.id) + const { folder: source } = await createProjectFileFolder.execute({ + principal: createSessionPrincipal({ userId: f.ownerId }), + input: { projectId: f.projectId, name: 'Copy me' }, + }) + const input = { + source: { + owner: { entityType: 'project' as const, entityId: f.projectId }, + fileIds: [], + folderIds: [source.id], + }, + destination: { + owner: { entityType: 'project' as const, entityId: b.projectId }, + folderId: null, + }, + } + const copy = { + ...principal, + audience: 'sim:files:copy', + scope: { kind: 'file_copy' as const, ...input }, + } + const copied = await copyFileItems.execute({ principal: copy, input }) + expect(copied.folders).toHaveLength(1) + expect( + ( + await listProjectFileFolders.execute({ principal, input: { projectId: b.projectId } }) + ).folders.map((row) => row.id) + ).toContain(copied.folders[0].id) + const authorize = await createFileCopyAuthorizer(copy, input) + expect(await db.transaction(authorize)).toMatchObject({ + source: { canWrite: false }, + destination: { canWrite: true }, + }) + await grant(f.readerId, b.workspaceId, 'read') + await expect(db.transaction(authorize)).rejects.toMatchObject({ code: 'forbidden' }) + await db + .delete(permissions) + .where(and(eq(permissions.userId, f.readerId), eq(permissions.entityId, b.workspaceId))) + await expect( + listProjectFileFolders.execute({ principal, input: { projectId: b.projectId } }) + ).rejects.toMatchObject({ code: 'not_found' }) + } + ) + + check( + 'origin access and copilot capability are rechecked after preparation independently of B', + async () => { + for (const revoke of ['permission', 'copilot', 'files'] as const) { + const f = await fixture() + const b = await target(f) + const input = { + source: { + owner: { entityType: 'project' as const, entityId: b.projectId }, + fileIds: [generateId()], + folderIds: [], + }, + destination: { + owner: { entityType: 'project' as const, entityId: b.projectId }, + folderId: null, + }, + } + const principal = { + ...delegated(f.readerId, b.projectId, f.workspaces[0]), + audience: 'sim:files:copy', + scope: { kind: 'file_copy' as const, ...input }, + } + const authorize = await createFileCopyAuthorizer(principal, input) + await db.transaction(authorize) + if (revoke === 'permission') { + await db + .delete(permissions) + .where( + and(eq(permissions.userId, f.readerId), eq(permissions.entityId, f.workspaces[0])) + ) + } else { + const groupId = generateId() + await db.insert(permissionGroup).values({ + id: groupId, + organizationId: f.organizationId, + createdBy: f.ownerId, + name: 'Revoked capability', + membershipMode: 'inherit', + config: revoke === 'copilot' ? { hideCopilot: true } : { hideFilesTab: true }, + }) + await db.insert(permissionGroupWorkspace).values({ + id: generateId(), + permissionGroupId: groupId, + workspaceId: revoke === 'copilot' ? f.workspaces[0] : b.workspaceId, + organizationId: f.organizationId, + }) + } + await expect(db.transaction(authorize)).rejects.toMatchObject({ code: 'forbidden' }) + } + } + ) +}) diff --git a/apps/sim/lib/projects/files/__integration__/browser.integration.ts b/apps/sim/lib/projects/files/__integration__/browser.integration.ts new file mode 100644 index 00000000000..9c6bc6bf281 --- /dev/null +++ b/apps/sim/lib/projects/files/__integration__/browser.integration.ts @@ -0,0 +1,304 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { dirname } from 'node:path' +import { db } from '@sim/db' +import { + folder, + permissions, + projectWorkspace, + user, + workspace, + workspaceFiles, +} from '@sim/db/schema' +import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures' +import { createSessionPrincipal } from '@sim/testing/factories/principal.factory' +import { featureFlagsMock, featureFlagsMockFns } from '@sim/testing/mocks/feature-flags.mock' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { and, eq, inArray } from 'drizzle-orm' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import { listProjectFileItems } from '@/lib/projects/files/application' +import { deleteUserAccount } from '@/lib/users/account-deletion' +import type { FileBrowserQuery } from '@/lib/workspace-files/browser-query' + +interface BrowserFixture { + workspaceId: string + projectId?: string + users: string[] + fileIds: string[] + folderIds: string[] +} +vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock) + +const fixtures: BrowserFixture[] = [] +const checks: { name: string; status: 'passed' | 'failed'; durationMs: number; error?: string }[] = + [] +beforeEach(() => { + featureFlagsMockFns.mockIsFeatureEnabled.mockImplementation(async (flag) => flag === 'projects') + vi.stubEnv('PROJECT_FILES_ENABLED', 'true') +}) +function check(name: string, run: () => Promise) { + it(name, async () => { + const start = performance.now() + try { + await run() + checks.push({ name, status: 'passed', durationMs: performance.now() - start }) + } catch (error) { + checks.push({ + name, + status: 'failed', + durationMs: performance.now() - start, + error: getErrorMessage(error), + }) + throw error + } + }) +} +async function fixture() { + const ownerId = generateId() + const readerId = generateId() + const creatorId = generateId() + const workspaceId = generateId() + const owned: BrowserFixture = { + workspaceId, + users: [ownerId, readerId, creatorId], + fileIds: [], + folderIds: [], + } + fixtures.push(owned) + await db.insert(user).values( + [ownerId, readerId, creatorId].map((id, index) => ({ + id, + name: ['Owner', 'Reader', 'Other creator'][index], + email: `${id}@browser.invalid`, + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + })) + ) + await insertWorkspaceFixture(db, { + id: workspaceId, + name: 'File browser fixture', + ownerId, + billedAccountUserId: ownerId, + workspaceMode: 'personal', + }) + const [membership] = await db + .select({ projectId: projectWorkspace.projectId }) + .from(projectWorkspace) + .where(eq(projectWorkspace.workspaceId, workspaceId)) + if (!membership) throw new Error('Fixture Project missing') + const projectId = membership.projectId + owned.projectId = projectId + await db.insert(permissions).values([ + { + id: generateId(), + userId: ownerId, + entityType: 'workspace', + entityId: workspaceId, + permissionType: 'admin', + }, + { + id: generateId(), + userId: readerId, + entityType: 'workspace', + entityId: workspaceId, + permissionType: 'read', + }, + ]) + const reader = createSessionPrincipal({ userId: readerId }) + const list = (input: Partial = {}) => + listProjectFileItems.execute({ + principal: reader, + input: { projectId, scope: 'active', sortBy: 'name', sortOrder: 'asc', limit: 100, ...input }, + }) + const addFile = async ( + name: string, + size: number, + options: { folderId?: string; userId?: string; type?: string; archived?: boolean } = {} + ) => { + const id = generateId() + owned.fileIds.push(id) + await db.insert(workspaceFiles).values({ + id, + projectId: projectId, + context: 'project', + workspaceId: null, + userId: options.userId ?? ownerId, + folderId: options.folderId, + key: `project/${projectId}/${id}`, + originalName: name, + contentType: options.type ?? 'application/octet-stream', + sizeBytes: size, + deletedAt: options.archived ? new Date() : null, + }) + return id + } + const addFolder = async (name: string, parentId?: string, userId = ownerId) => { + const id = generateId() + owned.folderIds.push(id) + await db.insert(folder).values({ + id, + projectId: projectId, + workspaceId: null, + resourceType: 'file', + name, + userId, + parentId, + }) + return id + } + return { ownerId, readerId, creatorId, workspaceId, projectId, reader, list, addFile, addFolder } +} +afterAll(async () => { + const cleanup: { workspaceId: string; status: 'passed' | 'failed'; error?: string }[] = [] + try { + for (const fixture of fixtures) { + try { + await db.transaction(async (tx) => { + if (fixture.projectId) { + if (fixture.fileIds.length) + await tx + .delete(workspaceFiles) + .where( + and( + eq(workspaceFiles.projectId, fixture.projectId), + inArray(workspaceFiles.id, fixture.fileIds) + ) + ) + if (fixture.folderIds.length) + await tx + .delete(folder) + .where( + and( + eq(folder.projectId, fixture.projectId), + inArray(folder.id, fixture.folderIds) + ) + ) + } + await deleteWorkspaceFixture(tx, eq(workspace.id, fixture.workspaceId)) + await tx.delete(user).where(inArray(user.id, fixture.users)) + }) + cleanup.push({ workspaceId: fixture.workspaceId, status: 'passed' }) + } catch (error) { + cleanup.push({ + workspaceId: fixture.workspaceId, + status: 'failed', + error: getErrorMessage(error), + }) + } + } + } finally { + const report = process.env.PROJECT_FILE_BROWSER_REPORT_PATH + if (report) { + await mkdir(dirname(report), { recursive: true }) + await writeFile(report, JSON.stringify({ checks, cleanup }, null, 2)) + } + } + const failures = cleanup.filter((result) => result.status === 'failed') + if (failures.length) + throw new Error(`Browser fixture cleanup failed: ${JSON.stringify(failures)}`) +}) + +describe('Project browser real mixed collection', () => { + check( + 'type, size and creator predicates select the complete collection before pagination', + async () => { + const f = await fixture() + for (let index = 0; index < 5; index++) await f.addFile(`a${index}.txt`, 1) + const expected = await f.addFile('z-image.png', 1_048_576, { userId: f.creatorId }) + const page = await f.list({ + types: ['image'], + sizes: ['medium'], + creatorIds: [f.creatorId], + limit: 1, + }) + expect(page.items.map((item) => item.id)).toEqual([expected]) + expect(page.files.map((file) => file.id)).toEqual([expected]) + expect(page.nextKeys).toBeNull() + expect(page.capabilities.canWrite).toBe(false) + } + ) + check('effective MIME and exact size boundaries retain workspace product semantics', async () => { + const f = await fixture() + const small = await f.addFile('small.webm', 1_048_575) + const minimum = await f.addFile('minimum.webm', 1_048_576) + const maximum = await f.addFile('maximum.webm', 10_485_760) + const large = await f.addFile('large.webm', 10_485_761) + expect( + (await f.list({ types: ['video'], sizes: ['small'] })).items.map((item) => item.id) + ).toEqual([small]) + expect( + (await f.list({ types: ['video'], sizes: ['medium'] })).items.map((item) => item.id).sort() + ).toEqual([minimum, maximum].sort()) + expect( + (await f.list({ types: ['video'], sizes: ['large'] })).items.map((item) => item.id) + ).toEqual([large]) + expect((await f.list({ types: ['audio'] })).items).toEqual([]) + }) + check( + 'mixed size pages keep ascending name ties in both directions and include descendant rollup', + async () => { + const f = await fixture() + const a = await f.addFolder('a-folder') + const child = await f.addFolder('child', a) + await f.addFile('nested.txt', 10, { folderId: child }) + await f.addFile('ignored.txt', 1000, { folderId: child, archived: true }) + const b = await f.addFile('b-file.txt', 10) + const c = await f.addFile('c-file.txt', 20) + for (const sortOrder of ['asc', 'desc'] as const) { + const ids: string[] = [] + let after: FileBrowserQuery['after'] + for (let index = 0; index < 4; index++) { + const page = await f.list({ folderId: null, sortBy: 'size', sortOrder, limit: 1, after }) + ids.push(...page.items.map((item) => item.id)) + if (!page.nextKeys) break + after = page.nextKeys + } + expect(ids).toEqual(sortOrder === 'asc' ? [a, b, c] : [c, a, b]) + } + } + ) + check( + 'creator handoff updates filter choices and preserves file and folder pagination', + async () => { + const f = await fixture() + const other = await fixture() + await other.addFile('unrelated.png', 1, { userId: other.creatorId }) + const deleted = await f.addFile('deleted.txt', 1, { userId: f.creatorId }) + const deletedFolder = await f.addFolder('deleted-folder', undefined, f.creatorId) + const live = await f.addFile('live.txt', 1) + await deleteUserAccount(f.creatorId) + const page = await f.list() + expect(page.items.find((item) => item.id === deleted)?.creator).toMatchObject({ + id: f.ownerId, + }) + expect(page.items.find((item) => item.id === deletedFolder)?.creator).toMatchObject({ + id: f.ownerId, + }) + expect(page.files.find((file) => file.id === deleted)?.uploadedBy).toBe(f.ownerId) + expect(page.creators.map((creator) => creator.id)).toEqual([f.ownerId]) + expect(page.creators.some((creator) => creator.id === other.creatorId)).toBe(false) + expect((await f.list({ creatorIds: [f.creatorId] })).files).toEqual([]) + for (const sortOrder of ['asc', 'desc'] as const) { + const ids: string[] = [] + let after: FileBrowserQuery['after'] + for (let index = 0; index < 4; index++) { + const page = await f.list({ sortBy: 'owner', sortOrder, limit: 1, after }) + ids.push(...page.items.map((item) => item.id)) + if (!page.nextKeys) break + after = page.nextKeys + } + expect(ids).toEqual([deletedFolder, deleted, live]) + } + } + ) + check('live read revocation denies both item rows and creator metadata', async () => { + const f = await fixture() + await f.addFile('readable.txt', 1) + expect((await f.list()).items).toHaveLength(1) + await db + .delete(permissions) + .where(and(eq(permissions.userId, f.readerId), eq(permissions.entityId, f.workspaceId))) + await expect(f.list()).rejects.toMatchObject({ code: 'not_found' }) + }) +}) diff --git a/apps/sim/lib/projects/files/__integration__/content.integration.ts b/apps/sim/lib/projects/files/__integration__/content.integration.ts new file mode 100644 index 00000000000..b32f5b23386 --- /dev/null +++ b/apps/sim/lib/projects/files/__integration__/content.integration.ts @@ -0,0 +1,3657 @@ +import { mkdtempSync } from 'node:fs' +import { mkdir, readdir, readFile, rm, truncate, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { db } from '@sim/db' +import { + folder, + idempotencyKey, + member, + organization, + outboxEvent, + permissionGroup, + permissionGroupWorkspace, + permissions, + project, + projectWorkspace, + publicShare, + subscription, + user, + workspace, + workspaceFileSecretProvenance, + workspaceFiles, + workspaceFileVersion, +} from '@sim/db/schema' +import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures' +import { createSessionPrincipal } from '@sim/testing/factories/principal.factory' +import { createDeferred } from '@sim/testing/helpers/deferred' +import { emailMailerMock, emailMailerMockFns } from '@sim/testing/mocks/email-mailer.mock' +import { featureFlagsMock, featureFlagsMockFns } from '@sim/testing/mocks/feature-flags.mock' +import { setUploadDirServer, uploadsSetupMock } from '@sim/testing/mocks/uploads-setup.mock' +import { getErrorMessage, getPostgresErrorCode } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' +import { generateId } from '@sim/utils/id' +import { isRecordLike } from '@sim/utils/object' +import { and, eq, inArray, sql } from 'drizzle-orm' +import JSZip from 'jszip' +import { NextResponse } from 'next/server' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/uploads/core/setup.server', () => uploadsSetupMock) +vi.mock('@/lib/messaging/email/mailer', () => emailMailerMock) + +import * as tracking from '@/lib/billing/storage/tracking' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { processOutboxEventById } from '@/lib/core/outbox/service' +import * as sandboxTask from '@/lib/execution/sandbox/run-task' +import { readProjectFileArtifact } from '@/lib/projects/files/application/artifacts' +import { + createProjectFile, + readProjectFileContent, + updateProjectFileContent, +} from '@/lib/projects/files/application/content' +import { createProjectFileFolder } from '@/lib/projects/files/application/folders' +import { revertProjectFileVersion } from '@/lib/projects/files/application/versions' +import type { WorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' +import * as storageCleanup from '@/lib/uploads/contexts/workspace/workspace-file-storage-cleanup-outbox' +import { workspaceFileStorageCleanupOutboxHandlers } from '@/lib/uploads/contexts/workspace/workspace-file-storage-cleanup-outbox' +import * as storage from '@/lib/uploads/core/storage-service' +import { storeCompiledDoc } from '@/lib/uploads/documents/compiled-store' +import * as heic from '@/lib/uploads/server/heic' +import { deleteUserAccount } from '@/lib/users/account-deletion' +import { observeWorkspaceFileDelivery } from '@/lib/workspace-files/application/file-delivery-observer' +import { verifyFileAccess } from '@/app/api/files/authorization' + +vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock) + +const localStorageRoot = mkdtempSync(join(tmpdir(), 'sim-project-content-')) +setUploadDirServer(localStorageRoot) +const fixtures: { + ownerId: string + editorId: string + organizationId: string + workspaceId: string + projectId: string +}[] = [] +const checks: { name: string; status: 'passed' | 'failed'; durationMs: number; error?: string }[] = + [] + +beforeEach(() => { + vi.restoreAllMocks() + featureFlagsMockFns.mockIsFeatureEnabled.mockImplementation(async (flag) => flag === 'projects') + vi.stubEnv('PROJECT_FILES_ENABLED', 'true') + vi.stubEnv('FREE_STORAGE_LIMIT_GB', '') +}) + +function check(name: string, run: () => Promise) { + it(name, async () => { + const started = performance.now() + try { + await run() + checks.push({ name, status: 'passed', durationMs: performance.now() - started }) + } catch (error) { + checks.push({ + name, + status: 'failed', + durationMs: performance.now() - started, + error: getErrorMessage(error), + }) + throw error + } + }) +} + +async function fixture() { + const ownerId = generateId() + const editorId = generateId() + const organizationId = generateId() + const workspaceId = generateId() + await db.insert(user).values( + [ownerId, editorId].map((id) => ({ + id, + email: `${id}@content.invalid`, + name: 'File content fixture', + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + })) + ) + await db + .insert(organization) + .values({ id: organizationId, name: 'Content', slug: organizationId, createdAt: new Date() }) + await db.insert(member).values( + [ownerId, editorId].map((userId) => ({ + id: generateId(), + organizationId, + userId, + role: userId === ownerId ? 'owner' : 'member', + createdAt: new Date(), + })) + ) + await insertWorkspaceFixture(db, { + id: workspaceId, + ownerId, + billedAccountUserId: ownerId, + organizationId, + workspaceMode: 'organization', + name: 'Content environment', + }) + const [binding] = await db + .select() + .from(projectWorkspace) + .where(eq(projectWorkspace.workspaceId, workspaceId)) + if (!binding) throw new Error('Project fixture missing') + await db.insert(permissions).values({ + id: generateId(), + userId: editorId, + entityType: 'workspace', + entityId: workspaceId, + permissionType: 'admin', + }) + const ids = { ownerId, editorId, organizationId, workspaceId, projectId: binding.projectId } + fixtures.push(ids) + return { ...ids, principal: createSessionPrincipal({ userId: editorId }) } +} + +function createInput(projectId: string, content = 'Project architecture') { + return { + projectId, + name: 'architecture.md', + contentType: 'text/markdown', + content, + encoding: 'utf-8' as const, + exactName: true, + } +} + +async function keys(projectId: string) { + return readdir(join(localStorageRoot, 'project', projectId)).catch(() => []) +} + +async function ledger(organizationId: string) { + const [row] = await db + .select({ bytes: organization.storageUsedBytes }) + .from(organization) + .where(eq(organization.id, organizationId)) + return row?.bytes +} + +async function rows(projectId: string) { + return db.select().from(workspaceFiles).where(eq(workspaceFiles.projectId, projectId)) +} + +describe('Project file content against PostgreSQL and the local object store', () => { + for (const mutation of ['upload', 'update'] as const) { + check( + `workspace ${mutation} waits for Project authority before locking shared file resources`, + async () => { + const { uploadWorkspaceFile, updateWorkspaceFileContent } = await import( + '@/lib/uploads/contexts/workspace/workspace-file-manager' + ) + const { defineAuthorizedProjectFileUseCase } = await import( + '@/lib/projects/files/application/authorized-use-case' + ) + const { projectFileOperations } = await import( + '@/lib/projects/files/application/operations' + ) + const f = await fixture() + const initial = await uploadWorkspaceFile( + f.workspaceId, + f.editorId, + Buffer.from('old'), + 'original.txt', + 'text/plain', + { notifyWorkspaceChange: false } + ) + const ready = createDeferred() + const release = createDeferred() + const authority = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.create, + async execute({ tx }) { + const [connection] = await tx.execute<{ pid: number }>( + sql`SELECT pg_backend_pid() AS pid` + ) + ready.resolve(connection.pid) + await release.promise + }, + }).execute({ principal: f.principal, input: { projectId: f.projectId } }) + const pid = await Promise.race([ + ready.promise, + authority.then(() => { + throw new Error('Authority exited early') + }), + ]) + const pending = + mutation === 'upload' + ? uploadWorkspaceFile( + f.workspaceId, + f.editorId, + Buffer.from('next'), + 'new.txt', + 'text/plain', + { notifyWorkspaceChange: false } + ) + : updateWorkspaceFileContent( + f.workspaceId, + initial.id, + f.editorId, + Buffer.from('next'), + 'text/plain', + { + version: { source: 'user', authorUserId: f.editorId }, + syncLiveDoc: false, + } + ) + const observed = pending.then( + () => null, + (error: unknown) => error + ) + try { + let waiting: string | null = null + for (let attempt = 0; attempt < 100; attempt++) { + const [state] = await db.execute<{ wait_event: string | null }>(sql` + SELECT wait_event FROM pg_stat_activity + WHERE ${pid} = ANY(pg_blocking_pids(pid)) AND wait_event_type = 'Lock' + LIMIT 1 + `) + if (state?.wait_event) { + waiting = state.wait_event + break + } + await sleep(10) + } + expect(waiting).toBe('advisory') + } finally { + release.resolve() + await authority + expect(await observed).toBeNull() + } + expect(await ledger(f.organizationId)).toBe(mutation === 'upload' ? 7 : 4) + } + ) + } + + check( + 'workspace copy billing resolves the current plan within its owning transaction', + async () => { + const { resolveStorageBillingContext } = await import('@/lib/billing/storage/context') + const f = await fixture() + try { + await db.transaction(async (tx) => { + await tx.insert(subscription).values({ + id: generateId(), + referenceId: f.organizationId, + plan: 'enterprise', + status: 'active', + metadata: { customStorageLimitGB: 42 }, + }) + const billing = await resolveStorageBillingContext(f.workspaceId, tx) + expect(billing).toMatchObject({ + billingEntity: { type: 'organization', id: f.organizationId }, + plan: 'enterprise', + customStorageLimitGB: 42, + }) + }) + } finally { + await db.delete(subscription).where(eq(subscription.referenceId, f.organizationId)) + } + } + ) + + check( + 'a lost staging response retains a durable cleanup record before any file is admitted', + async () => { + const f = await fixture() + const upload = storage.uploadFile + let attemptedKey = '' + vi.spyOn(storage, 'uploadFile').mockImplementationOnce(async (options) => { + const uploaded = await upload(options) + attemptedKey = uploaded.key + throw new Error('Lost provider response') + }) + vi.spyOn(storage, 'deleteFile').mockRejectedValueOnce(new Error('Cleanup unavailable')) + await expect( + createProjectFile.execute({ principal: f.principal, input: createInput(f.projectId) }) + ).rejects.toThrow('Lost provider response') + expect(await rows(f.projectId)).toEqual([]) + expect(await ledger(f.organizationId)).toBe(0) + const [event] = await db + .select() + .from(outboxEvent) + .where(sql`${outboxEvent.payload}::jsonb ->> 'key' = ${attemptedKey}`) + expect(event?.payload).toMatchObject({ key: attemptedKey, context: 'project' }) + expect(event.status).toBe('pending') + expect((await readFile(join(localStorageRoot, attemptedKey))).length).toBeGreaterThan(0) + await db + .update(outboxEvent) + .set({ availableAt: new Date(0) }) + .where(eq(outboxEvent.id, event.id)) + await expect( + processOutboxEventById(event.id, workspaceFileStorageCleanupOutboxHandlers) + ).resolves.toBe('completed') + await expect(readFile(join(localStorageRoot, attemptedKey))).rejects.toMatchObject({ + code: 'ENOENT', + }) + } + ) + + check( + 'compound copy remaps selected page assets before staging and bills the actual rendered source bytes', + async () => { + const { copyFileItems } = await import('@/lib/workspace-files/application/copy-file-items') + const { createWorkspaceFileFromBuffer } = await import( + '@/lib/workspace-files/application/create-workspace-file' + ) + const { readProjectFileArtifact } = await import('@/lib/projects/files/application/artifacts') + const f = await fixture() + const image = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a9uoAAAAASUVORK5CYII=', + 'base64' + ) + const asset = await createWorkspaceFileFromBuffer.execute({ + principal: f.principal, + input: { + workspaceId: f.workspaceId, + name: 'diagram.png', + contentType: 'image/png', + content: image, + exactName: true, + }, + }) + const original = Buffer.from( + `---\ntitle: Architecture\n---\n\n![Diagram](sim:file/${asset.file.id})\n\n![Stored](/api/files/serve/${encodeURIComponent(asset.file.key)})` + ) + const source = await createWorkspaceFileFromBuffer.execute({ + principal: f.principal, + input: { + workspaceId: f.workspaceId, + name: 'Architecture', + contentType: 'text/x-sim-page', + content: original, + exactName: true, + }, + }) + const before = await ledger(f.organizationId) + const copied = await copyFileItems.execute({ + principal: f.principal, + input: { + source: { + owner: { entityType: 'workspace', entityId: f.workspaceId }, + fileIds: [asset.file.id, source.file.id], + folderIds: [], + }, + destination: { owner: { entityType: 'project', entityId: f.projectId }, folderId: null }, + }, + }) + const copiedSource = copied.files.find((file) => file.name === 'Architecture') + const copiedAsset = copied.files.find((file) => file.name === 'diagram.png') + if (!copiedSource || !copiedAsset) throw new Error('Copy omitted selected source or asset') + const rendered = await readProjectFileArtifact.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: copiedSource.id, maxBytes: 1_000_000 }, + }) + expect(rendered.buffer.toString()).toContain( + `data:image/png;base64,${image.toString('base64')}` + ) + const copiedBytes = await readFile(join(localStorageRoot, copiedSource.key)) + expect(copiedBytes.toString()).toContain(copiedAsset.id) + expect(copiedBytes.toString()).not.toContain(asset.file.id) + expect(await readFile(join(localStorageRoot, source.file.key))).toEqual(original) + expect(await readFile(join(localStorageRoot, copiedAsset.key))).toEqual(image) + expect(await ledger(f.organizationId)).toBe( + (before ?? 0) + copied.files.reduce((sum, file) => sum + file.size, 0) + ) + expect(copiedSource.size).toBe(copiedBytes.length) + } + ) + + check( + 'compound recursive copy preserves bytes and provenance under new owner and creator attribution', + async () => { + const { copyFileItems } = await import('@/lib/workspace-files/application/copy-file-items') + const { createWorkspaceFileFromBuffer } = await import( + '@/lib/workspace-files/application/create-workspace-file' + ) + const { createWorkspaceFileFolderOperation } = await import( + '@/lib/workspace-files/application/workspace-file-folders' + ) + const f = await fixture() + const sourceOwner = { entityType: 'workspace' as const, entityId: f.workspaceId } + const destinationOwner = { entityType: 'project' as const, entityId: f.projectId } + const directory = await createWorkspaceFileFolderOperation.execute({ + principal: f.principal, + input: { workspaceId: f.workspaceId, name: 'Source' }, + }) + const child = await createWorkspaceFileFolderOperation.execute({ + principal: f.principal, + input: { workspaceId: f.workspaceId, name: 'Nested', parentId: directory.folder.id }, + }) + const secret = { + status: 'exact' as const, + entries: [ + { + name: 'TOKEN', + encryptedValue: 'encrypted-fixture', + sourceUserId: f.ownerId, + sourceWorkspaceId: f.workspaceId, + }, + ], + } + const source = await createWorkspaceFileFromBuffer.execute({ + principal: createSessionPrincipal({ userId: f.ownerId }), + input: { + workspaceId: f.workspaceId, + name: 'source.bin', + contentType: 'application/octet-stream', + content: Buffer.from([1, 255, 0]), + folderId: child.folder.id, + exactName: true, + secretProvenance: secret, + }, + }) + const copied = await copyFileItems.execute({ + principal: f.principal, + input: { + source: { owner: sourceOwner, fileIds: [], folderIds: [directory.folder.id] }, + destination: { owner: destinationOwner, folderId: null }, + }, + }) + expect(copied.files).toHaveLength(1) + expect(copied.folders).toHaveLength(2) + expect(copied.files[0]).toMatchObject({ + owner: destinationOwner, + uploadedBy: f.editorId, + folderPath: 'Source/Nested', + }) + expect(copied.files[0].id).not.toBe(source.file.id) + const read = await readProjectFileContent.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: copied.files[0].id, + includeSecretProvenance: true, + }, + }) + expect(read.content).toEqual(Buffer.from([1, 255, 0])) + expect(read.secretProvenance).toEqual(secret) + expect(await ledger(f.organizationId)).toBe(6) + const sameOwner = await copyFileItems.execute({ + principal: f.principal, + input: { + source: { owner: destinationOwner, fileIds: [copied.files[0].id], folderIds: [] }, + destination: { owner: destinationOwner, folderId: null }, + }, + }) + expect(sameOwner.files[0].folderId).toBeNull() + expect(sameOwner.files[0].id).not.toBe(copied.files[0].id) + expect(await ledger(f.organizationId)).toBe(9) + } + ) + + check( + 'compound copy rejects source changes after staging without committing folders, files, or extra billing', + async () => { + const { copyFileItems } = await import('@/lib/workspace-files/application/copy-file-items') + const { createWorkspaceFile, createWorkspaceFileFromBuffer } = await import( + '@/lib/workspace-files/application/create-workspace-file' + ) + const { updateWorkspaceFileContent } = await import( + '@/lib/workspace-files/application/update-workspace-file-content' + ) + const { createWorkspaceFileFolderOperation } = await import( + '@/lib/workspace-files/application/workspace-file-folders' + ) + const f = await fixture() + const directory = await createWorkspaceFileFolderOperation.execute({ + principal: f.principal, + input: { workspaceId: f.workspaceId, name: 'Source' }, + }) + const source = await createWorkspaceFile.execute({ + principal: f.principal, + input: { + workspaceId: f.workspaceId, + name: 'notes.txt', + contentType: 'text/plain', + content: 'before', + encoding: 'utf-8', + folderId: directory.folder.id, + exactName: true, + }, + }) + await createWorkspaceFileFromBuffer.execute({ + principal: f.principal, + input: { + workspaceId: f.workspaceId, + name: 'more.bin', + contentType: 'application/octet-stream', + content: Buffer.from([1]), + folderId: directory.folder.id, + exactName: true, + }, + }) + const upload = storage.uploadFile + vi.spyOn(storage, 'uploadFile').mockImplementationOnce(async (options) => { + const staged = await upload(options) + await updateWorkspaceFileContent.execute({ + principal: f.principal, + input: { + fileId: source.file.id, + assertedWorkspaceId: f.workspaceId, + content: 'after source change', + encoding: 'utf-8', + syncLiveDoc: false, + }, + }) + return staged + }) + await expect( + copyFileItems.execute({ + principal: f.principal, + input: { + source: { + owner: { entityType: 'workspace', entityId: f.workspaceId }, + fileIds: [], + folderIds: [directory.folder.id], + }, + destination: { + owner: { entityType: 'project', entityId: f.projectId }, + folderId: null, + }, + }, + }) + ).rejects.toMatchObject({ code: 'conflict' }) + expect(await rows(f.projectId)).toEqual([]) + expect(await db.select().from(folder).where(eq(folder.projectId, f.projectId))).toEqual([]) + expect(await keys(f.projectId)).toEqual([]) + expect(await ledger(f.organizationId)).toBe(Buffer.byteLength('after source change') + 1) + } + ) + + check( + 'compound copy quota failure atomically rolls back the whole destination tree and releases staged bytes', + async () => { + const { copyFileItems } = await import('@/lib/workspace-files/application/copy-file-items') + const { createWorkspaceFile } = await import( + '@/lib/workspace-files/application/create-workspace-file' + ) + const { createWorkspaceFileFolderOperation } = await import( + '@/lib/workspace-files/application/workspace-file-folders' + ) + const f = await fixture() + const directory = await createWorkspaceFileFolderOperation.execute({ + principal: f.principal, + input: { workspaceId: f.workspaceId, name: 'Source' }, + }) + const source = await createWorkspaceFile.execute({ + principal: f.principal, + input: { + workspaceId: f.workspaceId, + name: 'notes.txt', + contentType: 'text/plain', + content: 'before', + encoding: 'utf-8', + folderId: directory.folder.id, + exactName: true, + }, + }) + vi.stubEnv('FREE_STORAGE_LIMIT_GB', '1') + await db + .update(organization) + .set({ storageUsedBytes: 1024 ** 3 }) + .where(eq(organization.id, f.organizationId)) + await expect( + copyFileItems.execute({ + principal: f.principal, + input: { + source: { + owner: { entityType: 'workspace', entityId: f.workspaceId }, + fileIds: [], + folderIds: [directory.folder.id], + }, + destination: { + owner: { entityType: 'project', entityId: f.projectId }, + folderId: null, + }, + }, + }) + ).rejects.toMatchObject({ name: 'StorageLimitExceededError' }) + expect(await rows(f.projectId)).toEqual([]) + expect(await db.select().from(folder).where(eq(folder.projectId, f.projectId))).toEqual([]) + expect(await keys(f.projectId)).toEqual([]) + expect(await readFile(join(localStorageRoot, source.file.key), 'utf8')).toBe('before') + expect(await ledger(f.organizationId)).toBe(1024 ** 3) + } + ) + + check( + 'Project history preserves source provenance and pagination through a version revert', + async () => { + const history = await import('@/lib/projects/files/application/versions') + const f = await fixture() + const secret = { + status: 'exact' as const, + entries: [ + { + name: 'TOKEN', + encryptedValue: 'encrypted-fixture', + sourceUserId: f.editorId, + sourceWorkspaceId: f.workspaceId, + }, + ], + } + const created = await createProjectFile.execute({ + principal: f.principal, + input: { ...createInput(f.projectId, 'classified original'), secretProvenance: secret }, + }) + const target = { projectId: f.projectId, fileId: created.file.id } + const changed = await updateProjectFileContent.execute({ + principal: f.principal, + input: { ...target, content: 'new', encoding: 'utf-8' }, + }) + const page = await history.listProjectFileVersions.execute({ + principal: f.principal, + input: { ...target, sortOrder: 'desc', limit: 1 }, + }) + expect(page.versions.map((version) => version.version)).toEqual([2]) + expect(page.nextKeys).not.toBeNull() + const older = await history.listProjectFileVersions.execute({ + principal: f.principal, + input: { ...target, sortOrder: 'desc', limit: 1, after: page.nextKeys ?? undefined }, + }) + expect(older.versions.map((version) => version.version)).toEqual([1]) + const read = await history.readProjectFileVersionContent.execute({ + principal: f.principal, + input: { ...target, version: 1 }, + }) + expect(read.content.toString()).toBe('classified original') + expect(read.secretProvenance).toEqual(secret) + const reverted = await history.revertProjectFileVersion.execute({ + principal: f.principal, + input: { ...target, version: 1, expectedCurrentVersion: 2 }, + }) + expect(reverted).toMatchObject({ + reverted: true, + revertedFrom: 2, + version: { + version: 3, + source: 'revert', + restoredFromVersion: 1, + authorUserIds: [f.editorId], + }, + }) + const current = await readProjectFileContent.execute({ + principal: f.principal, + input: { ...target, includeSecretProvenance: true }, + }) + expect(current.secretProvenance).toEqual(secret) + expect(current.content.toString()).toBe('classified original') + expect(await ledger(f.organizationId)).toBe(current.content.length) + expect(current.file.key).not.toBe(changed.file.key) + const noOp = await history.revertProjectFileVersion.execute({ + principal: f.principal, + input: { ...target, version: 3, expectedCurrentVersion: 3 }, + }) + expect(noOp.reverted).toBe(false) + expect(noOp.file.key).toBe(current.file.key) + const other = await fixture() + await expect( + history.listProjectFileVersions.execute({ + principal: f.principal, + input: { + projectId: other.projectId, + fileId: created.file.id, + sortOrder: 'desc', + limit: 1, + }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + } + ) + + check( + 'a Project revert refuses a concurrent edit after staging and releases only its orphan', + async () => { + const history = await import('@/lib/projects/files/application/versions') + const f = await fixture() + const created = await createProjectFile.execute({ + principal: f.principal, + input: createInput(f.projectId, 'first'), + }) + const target = { projectId: f.projectId, fileId: created.file.id } + await updateProjectFileContent.execute({ + principal: f.principal, + input: { ...target, content: 'second', encoding: 'utf-8' }, + }) + const upload = storage.uploadFile + let orphanKey = '' + vi.spyOn(storage, 'uploadFile').mockImplementationOnce(async (options) => { + const staged = await upload(options) + orphanKey = staged.key + await updateProjectFileContent.execute({ + principal: f.principal, + input: { ...target, content: 'concurrent winner', encoding: 'utf-8' }, + }) + return staged + }) + await expect( + history.revertProjectFileVersion.execute({ + principal: f.principal, + input: { ...target, version: 1, expectedCurrentVersion: 2 }, + }) + ).rejects.toMatchObject({ code: 'conflict' }) + expect(orphanKey).not.toBe('') + await expect(readFile(join(localStorageRoot, orphanKey))).rejects.toMatchObject({ + code: 'ENOENT', + }) + const current = await readProjectFileContent.execute({ + principal: f.principal, + input: target, + }) + expect(current.content.toString()).toBe('concurrent winner') + expect(await ledger(f.organizationId)).toBe(current.content.length) + expect( + ( + await history.listProjectFileVersions.execute({ + principal: f.principal, + input: { ...target, limit: 10, sortOrder: 'desc' }, + }) + ).versions + ).toHaveLength(3) + } + ) + + check( + 'Project history deletion protects the current version and durably cleans superseded bytes', + async () => { + const history = await import('@/lib/projects/files/application/versions') + const f = await fixture() + const created = await createProjectFile.execute({ + principal: f.principal, + input: createInput(f.projectId, 'first'), + }) + const target = { projectId: f.projectId, fileId: created.file.id } + await updateProjectFileContent.execute({ + principal: f.principal, + input: { ...target, content: 'second', encoding: 'utf-8' }, + }) + await expect( + history.deleteProjectFileVersion.execute({ + principal: f.principal, + input: { ...target, version: 2 }, + }) + ).rejects.toMatchObject({ code: 'conflict' }) + vi.spyOn(storage, 'deleteFile').mockRejectedValueOnce(new Error('provider unavailable')) + await history.deleteProjectFileVersion.execute({ + principal: f.principal, + input: { ...target, version: 1 }, + }) + await expect( + history.readProjectFileVersionContent.execute({ + principal: f.principal, + input: { ...target, version: 1 }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + const events = await db + .select() + .from(outboxEvent) + .where(sql`${outboxEvent.payload}::jsonb ->> 'key' = ${created.file.key}`) + expect(events).toHaveLength(1) + expect(events[0].payload).toMatchObject({ context: 'project', key: created.file.key }) + expect(await readFile(join(localStorageRoot, created.file.key), 'utf8')).toBe('first') + await db + .update(outboxEvent) + .set({ availableAt: new Date(0) }) + .where(eq(outboxEvent.id, events[0].id)) + await expect( + processOutboxEventById(events[0].id, workspaceFileStorageCleanupOutboxHandlers) + ).resolves.toBe('completed') + await expect(readFile(join(localStorageRoot, created.file.key))).rejects.toMatchObject({ + code: 'ENOENT', + }) + expect(await ledger(f.organizationId)).toBe(6) + } + ) + + check( + 'archived Project history releases bytes with canonical storage context in the purge transaction', + async () => { + const { releaseWorkspaceFileVersionsForPurgeInTx } = await import( + '@/lib/uploads/contexts/workspace/workspace-file-versions' + ) + const { archiveProjectFileItems } = await import('@/lib/projects/files/application/lifecycle') + const f = await fixture() + const created = await createProjectFile.execute({ + principal: f.principal, + input: createInput(f.projectId, 'first'), + }) + await updateProjectFileContent.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: created.file.id, + content: 'second', + encoding: 'utf-8', + }, + }) + await archiveProjectFileItems.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileIds: [created.file.id] }, + }) + await db.transaction(async (tx) => { + await releaseWorkspaceFileVersionsForPurgeInTx( + tx, + [created.file.id], + new Date(Date.now() + 1000) + ) + await tx.delete(workspaceFiles).where(eq(workspaceFiles.id, created.file.id)) + }) + const events = await db + .select() + .from(outboxEvent) + .where(sql`${outboxEvent.payload}::jsonb ->> 'key' = ${created.file.key}`) + expect(events).toHaveLength(1) + expect(events[0].payload).toMatchObject({ context: 'project', key: created.file.key }) + await processOutboxEventById(events[0].id, workspaceFileStorageCleanupOutboxHandlers) + await expect(readFile(join(localStorageRoot, created.file.key))).rejects.toMatchObject({ + code: 'ENOENT', + }) + } + ) + + check( + 'preserves workspace bytes, current folder paths, history, and accounting through shared storage', + async () => { + const f = await fixture() + const { createWorkspaceFile, createWorkspaceFileFromBuffer } = await import( + '@/lib/workspace-files/application/create-workspace-file' + ) + const { updateWorkspaceFileContent } = await import( + '@/lib/workspace-files/application/update-workspace-file-content' + ) + const { readWorkspaceFileContent } = await import( + '@/lib/workspace-files/application/read-workspace-file-content' + ) + const { archiveWorkspaceFileItemsOperation } = await import( + '@/lib/workspace-files/application/archive-workspace-file-items' + ) + const { restoreWorkspaceFileOperation } = await import( + '@/lib/workspace-files/application/restore-workspace-file' + ) + const { createWorkspaceFileFolderOperation, updateWorkspaceFileFolderOperation } = + await import('@/lib/workspace-files/application/workspace-file-folders') + const originalFolder = await createWorkspaceFileFolderOperation.execute({ + principal: f.principal, + input: { workspaceId: f.workspaceId, name: 'Design' }, + }) + const upload = storage.uploadFile + let replacementFolderId = '' + vi.spyOn(storage, 'uploadFile').mockImplementationOnce(async (options) => { + const stored = await upload(options) + await updateWorkspaceFileFolderOperation.execute({ + principal: f.principal, + input: { workspaceId: f.workspaceId, folderId: originalFolder.folder.id, name: 'Prior' }, + }) + const replacement = await createWorkspaceFileFolderOperation.execute({ + principal: f.principal, + input: { workspaceId: f.workspaceId, name: 'Design' }, + }) + replacementFolderId = replacement.folder.id + return stored + }) + const created = await createWorkspaceFile.execute({ + principal: f.principal, + input: { + workspaceId: f.workspaceId, + name: 'architecture.md', + content: 'first', + contentType: 'text/markdown', + encoding: 'utf-8', + exactName: true, + folderPath: '/Design', + }, + }) + expect(created.file.folderId).toBe(replacementFolderId) + const binary = await createWorkspaceFileFromBuffer.execute({ + principal: f.principal, + input: { + workspaceId: f.workspaceId, + name: 'image.bin', + content: Buffer.from([1, 0, 255]), + contentType: 'application/octet-stream', + exactName: true, + }, + }) + const updated = await updateWorkspaceFileContent.execute({ + principal: f.principal, + input: { + fileId: created.file.id, + assertedWorkspaceId: f.workspaceId, + content: 'second version', + encoding: 'utf-8', + syncLiveDoc: false, + }, + }) + expect(updated.file.currentVersion).toBe(2) + const read = await readWorkspaceFileContent.execute({ + principal: f.principal, + input: { + fileId: created.file.id, + assertedWorkspaceId: f.workspaceId, + includeSecretProvenance: true, + }, + }) + expect(read.content.toString()).toBe('second version') + expect(read.secretProvenance).toEqual({ status: 'exact', entries: [] }) + const binaryRead = await readWorkspaceFileContent.execute({ + principal: f.principal, + input: { fileId: binary.file.id, assertedWorkspaceId: f.workspaceId }, + }) + expect(binaryRead.content).toEqual(Buffer.from([1, 0, 255])) + expect(await ledger(f.organizationId)).toBe(17) + await archiveWorkspaceFileItemsOperation.execute({ + principal: f.principal, + input: { workspaceId: f.workspaceId, folderIds: [replacementFolderId] }, + }) + await expect( + readWorkspaceFileContent.execute({ + principal: f.principal, + input: { fileId: created.file.id, assertedWorkspaceId: f.workspaceId }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + const restored = await restoreWorkspaceFileOperation.execute({ + principal: f.principal, + input: { fileId: created.file.id, assertedWorkspaceId: f.workspaceId }, + }) + expect(restored.file.folderId).toBeNull() + expect(restored.file.key).toBe(updated.file.key) + expect(restored.file.uploadedBy).toBe(f.editorId) + expect(await ledger(f.organizationId)).toBe(17) + } + ) + + check( + 'creates binary and text files in the canonical owner, attributes the actor, and bills the organization once', + async () => { + const f = await fixture() + const directory = await createProjectFileFolder.execute({ + principal: f.principal, + input: { projectId: f.projectId, name: 'Design' }, + }) + const bytes = Buffer.from([0, 255, 18, 1]) + const created = await createProjectFile.execute({ + principal: f.principal, + input: { + ...createInput(f.projectId), + name: 'diagram.bin', + contentType: 'application/octet-stream', + content: bytes.toString('base64'), + encoding: 'base64', + folderId: directory.folder.id, + }, + }) + expect(created.file).toMatchObject({ + owner: { entityType: 'project', entityId: f.projectId }, + uploadedBy: f.editorId, + size: 4, + folderPath: 'Design', + }) + expect(created.file.key.startsWith(`project/${f.projectId}/`)).toBe(true) + const read = await readProjectFileContent.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: created.file.id, includeSecretProvenance: true }, + }) + expect(read.content).toEqual(bytes) + expect(read.secretProvenance).toEqual({ status: 'exact', entries: [] }) + expect(await ledger(f.organizationId)).toBe(4) + const [stored] = await rows(f.projectId) + expect(stored).toMatchObject({ + workspaceId: null, + organizationId: null, + context: 'project', + secretProvenanceVersion: 1, + userId: f.editorId, + }) + await expect( + verifyFileAccess(created.file.key, f.editorId, undefined, 'general') + ).resolves.toBe(false) + await expect( + verifyFileAccess(created.file.key, f.ownerId, undefined, 'workspace') + ).resolves.toBe(false) + } + ) + + check( + 'revocation while a blob is staged prevents metadata admission and cleans the uncommitted object', + async () => { + const f = await fixture() + const upload = storage.uploadFile + vi.spyOn(storage, 'uploadFile').mockImplementationOnce(async (options) => { + const result = await upload(options) + await db + .update(permissions) + .set({ permissionType: 'read' }) + .where(and(eq(permissions.userId, f.editorId), eq(permissions.entityId, f.workspaceId))) + return result + }) + await expect( + createProjectFile.execute({ principal: f.principal, input: createInput(f.projectId) }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(await rows(f.projectId)).toEqual([]) + expect(await ledger(f.organizationId)).toBe(0) + expect(await keys(f.projectId)).toEqual([]) + } + ) + + check( + 'foreign folders and duplicate names cannot commit a blob or debit either owner', + async () => { + const f = await fixture() + const other = await fixture() + const foreign = await createProjectFileFolder.execute({ + principal: other.principal, + input: { projectId: other.projectId, name: 'Private' }, + }) + await expect( + createProjectFile.execute({ + principal: f.principal, + input: { ...createInput(f.projectId), folderId: foreign.folder.id }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + const created = await createProjectFile.execute({ + principal: f.principal, + input: createInput(f.projectId), + }) + await expect( + createProjectFile.execute({ principal: f.principal, input: createInput(f.projectId) }) + ).rejects.toMatchObject({ code: 'conflict' }) + expect((await rows(f.projectId)).map((row) => row.id)).toEqual([created.file.id]) + expect(await ledger(f.organizationId)).toBe(created.file.size) + expect(await ledger(other.organizationId)).toBe(0) + expect((await keys(f.projectId)).filter((key) => !key.endsWith('.json'))).toHaveLength(1) + } + ) + + check( + 'concurrent writes sharing one revision yield one winner and preserve provenance origins in history', + async () => { + const f = await fixture() + const secret = { + status: 'exact' as const, + entries: [ + { + name: 'TOKEN', + encryptedValue: 'fixture-ciphertext', + sourceUserId: f.editorId, + sourceWorkspaceId: f.workspaceId, + }, + ], + } + const created = await createProjectFile.execute({ + principal: f.principal, + input: { ...createInput(f.projectId, 'sensitive architecture'), secretProvenance: secret }, + }) + const outcomes = await Promise.allSettled( + ['replacement one', 'replacement two'].map((content) => + updateProjectFileContent.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: created.file.id, + content, + encoding: 'utf-8', + expectedUpdatedAt: created.file.contentUpdatedAt, + provenanceMode: 'preserve', + }, + }) + ) + ) + expect(outcomes.filter((result) => result.status === 'fulfilled')).toHaveLength(1) + expect(outcomes.filter((result) => result.status === 'rejected')).toHaveLength(1) + const current = await readProjectFileContent.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: created.file.id, includeSecretProvenance: true }, + }) + expect(current.secretProvenance).toEqual(secret) + expect(await ledger(f.organizationId)).toBe(current.content.length) + const history = await db + .select() + .from(workspaceFileVersion) + .where(eq(workspaceFileVersion.fileId, created.file.id)) + expect(history).toHaveLength(2) + expect( + history.every( + (version) => version.workspaceId === null && version.secretProvenanceStatus === 'exact' + ) + ).toBe(true) + expect(history.flatMap((version) => version.secretProvenanceEntries ?? [])).toContainEqual( + expect.objectContaining({ sourceUserId: f.editorId, sourceWorkspaceId: f.workspaceId }) + ) + expect((await keys(f.projectId)).filter((key) => !key.endsWith('.json'))).toHaveLength(2) + } + ) + + check( + 'quota failure rolls back metadata, provenance, versions and counters, retaining previous bytes', + async () => { + const f = await fixture() + const created = await createProjectFile.execute({ + principal: f.principal, + input: createInput(f.projectId, 'before'), + }) + vi.stubEnv('FREE_STORAGE_LIMIT_GB', '1') + await db + .update(organization) + .set({ storageUsedBytes: 1024 ** 3 }) + .where(eq(organization.id, f.organizationId)) + await expect( + updateProjectFileContent.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: created.file.id, + content: 'larger replacement', + encoding: 'utf-8', + expectedUpdatedAt: created.file.contentUpdatedAt, + }, + }) + ).rejects.toMatchObject({ name: 'StorageLimitExceededError' }) + const [current] = await rows(f.projectId) + expect(current.key).toBe(created.file.key) + expect(await readFile(join(localStorageRoot, current.key), 'utf8')).toBe('before') + expect(await ledger(f.organizationId)).toBe(1024 ** 3) + expect( + await db + .select() + .from(workspaceFileVersion) + .where(eq(workspaceFileVersion.fileId, current.id)) + ).toEqual([]) + const [provenance] = await db + .select() + .from(workspaceFileSecretProvenance) + .where(eq(workspaceFileSecretProvenance.fileId, current.id)) + expect(provenance.contentUpdatedAt.getTime()).toBe(created.file.contentUpdatedAt.getTime()) + expect((await keys(f.projectId)).filter((key) => !key.endsWith('.json'))).toHaveLength(1) + } + ) + + check( + 'missing Project tracking cannot become exact-empty when a derived write preserves provenance', + async () => { + const f = await fixture() + const created = await createProjectFile.execute({ + principal: f.principal, + input: createInput(f.projectId), + }) + await db + .delete(workspaceFileSecretProvenance) + .where(eq(workspaceFileSecretProvenance.fileId, created.file.id)) + await db + .update(workspaceFiles) + .set({ secretProvenanceVersion: null }) + .where(eq(workspaceFiles.id, created.file.id)) + await updateProjectFileContent.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: created.file.id, + content: 'derived content', + encoding: 'utf-8', + expectedUpdatedAt: created.file.contentUpdatedAt, + provenanceMode: 'preserve', + }, + }) + const read = await readProjectFileContent.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: created.file.id, + includeSecretProvenance: true, + }, + }) + expect(read.secretProvenance).toEqual({ status: 'unknown' }) + const [current] = await rows(f.projectId) + expect(current.secretProvenanceVersion).toBe(1) + const history = await db + .select() + .from(workspaceFileVersion) + .where(eq(workspaceFileVersion.fileId, created.file.id)) + expect(history.every((version) => version.secretProvenanceStatus === 'unknown')).toBe(true) + } + ) + + check( + 'private delivery receives bound provenance and can refuse bytes before the caller receives them', + async () => { + const f = await fixture() + const created = await createProjectFile.execute({ + principal: f.principal, + input: { + ...createInput(f.projectId), + secretProvenance: { status: 'unknown' }, + }, + }) + await expect( + observeWorkspaceFileDelivery( + async (provenance) => { + if (provenance?.status !== 'exact') + throw new Error('Private delivery refuses unknown provenance') + }, + () => + readProjectFileContent.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: created.file.id, + }, + }) + ) + ).rejects.toThrow('Private delivery refuses unknown provenance') + } + ) + + check( + 'shared content survives creator departure and deletion without changing stored history or its payer', + async () => { + const f = await fixture() + const directory = await createProjectFileFolder.execute({ + principal: f.principal, + input: { projectId: f.projectId, name: 'Authored' }, + }) + const created = await createProjectFile.execute({ + principal: f.principal, + input: { ...createInput(f.projectId), folderId: directory.folder.id }, + }) + await updateProjectFileContent.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: created.file.id, + content: 'Documented before departure', + encoding: 'utf-8', + expectedUpdatedAt: created.file.contentUpdatedAt, + }, + }) + await db + .delete(member) + .where(and(eq(member.organizationId, f.organizationId), eq(member.userId, f.editorId))) + await db + .delete(permissions) + .where(and(eq(permissions.entityId, f.workspaceId), eq(permissions.userId, f.editorId))) + await expect( + readProjectFileContent.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: created.file.id }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + expect((await rows(f.projectId))[0].userId).toBe(f.editorId) + await deleteUserAccount(f.editorId) + const principal = createSessionPrincipal({ userId: f.ownerId }) + const read = await readProjectFileContent.execute({ + principal, + input: { projectId: f.projectId, fileId: created.file.id }, + }) + expect(read.file).toMatchObject({ + uploadedBy: f.ownerId, + folderPath: 'Authored', + }) + const [retainedFolder] = await db + .select() + .from(folder) + .where(eq(folder.id, directory.folder.id)) + expect(retainedFolder).toMatchObject({ userId: f.ownerId, projectId: f.projectId }) + await updateProjectFileContent.execute({ + principal, + input: { + projectId: f.projectId, + fileId: created.file.id, + content: 'Maintained by another editor', + encoding: 'utf-8', + expectedUpdatedAt: read.file.contentUpdatedAt, + }, + }) + const history = await db + .select() + .from(workspaceFileVersion) + .where(eq(workspaceFileVersion.fileId, created.file.id)) + expect(history.find((version) => version.version === 1)?.authorUserIds).toEqual([f.editorId]) + const [maintained] = await rows(f.projectId) + expect(maintained).toMatchObject({ + id: created.file.id, + userId: f.ownerId, + projectId: f.projectId, + folderId: directory.folder.id, + }) + expect(await readFile(join(localStorageRoot, maintained.key), 'utf8')).toBe( + 'Maintained by another editor' + ) + expect(await ledger(f.organizationId)).toBe(maintained.sizeBytes) + } + ) + + check( + 'a postcommit notification failure never deletes admitted bytes or their accounting', + async () => { + const f = await fixture() + vi.spyOn(tracking, 'maybeNotifyStorageLimitForBillingContext').mockRejectedValueOnce( + new Error('notification service unavailable') + ) + await expect( + createProjectFile.execute({ principal: f.principal, input: createInput(f.projectId) }) + ).rejects.toThrow('notification service unavailable') + const [file] = await rows(f.projectId) + expect(file).toBeDefined() + expect(await readFile(join(localStorageRoot, file.key), 'utf8')).toBe('Project architecture') + expect(await ledger(f.organizationId)).toBe(file.sizeBytes) + const [provenance] = await db + .select() + .from(workspaceFileSecretProvenance) + .where(eq(workspaceFileSecretProvenance.fileId, file.id)) + expect(provenance.status).toBe('exact') + } + ) + + check( + 'failed staged-object deletion is durable and an outbox retry removes only uncommitted bytes', + async () => { + const f = await fixture() + const upload = storage.uploadFile + vi.spyOn(storage, 'uploadFile').mockImplementationOnce(async (options) => { + const result = await upload(options) + await db + .update(permissions) + .set({ permissionType: 'read' }) + .where(and(eq(permissions.userId, f.editorId), eq(permissions.entityId, f.workspaceId))) + return result + }) + vi.spyOn(storage, 'deleteFile').mockRejectedValueOnce( + new Error('temporary object storage outage') + ) + await expect( + createProjectFile.execute({ principal: f.principal, input: createInput(f.projectId) }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect(await rows(f.projectId)).toEqual([]) + expect((await keys(f.projectId)).length).toBeGreaterThan(0) + const [event] = await db + .select() + .from(outboxEvent) + .where( + sql`${outboxEvent.payload}::jsonb ->> 'key' LIKE ${`project/${f.projectId}/%`} OR ${outboxEvent.payload}::jsonb -> 'owner' ->> 'entityId' = ${f.projectId}` + ) + expect(event.payload).toMatchObject({ context: 'project' }) + expect(event.status).toBe('pending') + await db + .update(outboxEvent) + .set({ availableAt: new Date(0) }) + .where(eq(outboxEvent.id, event.id)) + await expect( + processOutboxEventById(event.id, workspaceFileStorageCleanupOutboxHandlers) + ).resolves.toBe('completed') + expect(await keys(f.projectId)).toEqual([]) + expect(await ledger(f.organizationId)).toBe(0) + } + ) + + check( + 'content changed while being downloaded cannot be returned with a newer revision or classification', + async () => { + const f = await fixture() + const created = await createProjectFile.execute({ + principal: f.principal, + input: createInput(f.projectId), + }) + const download = storage.downloadFile + vi.spyOn(storage, 'downloadFile').mockImplementationOnce(async (options) => { + const content = await download(options) + await updateProjectFileContent.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: created.file.id, + content: 'updated during download', + encoding: 'utf-8', + expectedUpdatedAt: created.file.contentUpdatedAt, + }, + }) + return content + }) + await expect( + readProjectFileContent.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: created.file.id, includeSecretProvenance: true }, + }) + ).rejects.toMatchObject({ code: 'conflict' }) + } + ) +}) + +describe('Project file lifecycle against PostgreSQL and private storage', () => { + check( + 'recursive archive and restore preserve individually archived files, bytes, attribution, and billing', + async () => { + const { archiveProjectFileItems, restoreProjectFileFolder } = await import( + '@/lib/projects/files/application/lifecycle' + ) + const f = await fixture() + const parent = await createProjectFileFolder.execute({ + principal: f.principal, + input: { projectId: f.projectId, name: 'Design' }, + }) + const child = await createProjectFileFolder.execute({ + principal: f.principal, + input: { projectId: f.projectId, name: 'Diagrams', parentId: parent.folder.id }, + }) + const current = await createProjectFile.execute({ + principal: f.principal, + input: { ...createInput(f.projectId, 'shared content'), folderId: child.folder.id }, + }) + const prior = await createProjectFile.execute({ + principal: f.principal, + input: { + ...createInput(f.projectId, 'old content'), + name: 'retired.md', + folderId: child.folder.id, + }, + }) + await archiveProjectFileItems.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileIds: [prior.file.id] }, + }) + await db + .update(workspaceFiles) + .set({ deletedAt: new Date(0) }) + .where(eq(workspaceFiles.id, prior.file.id)) + const before = await ledger(f.organizationId) + const archived = await archiveProjectFileItems.execute({ + principal: f.principal, + input: { projectId: f.projectId, folderIds: [parent.folder.id] }, + }) + expect(archived.deletedItems).toEqual({ files: 1, folders: 2 }) + await expect( + readProjectFileContent.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: current.file.id }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + expect(await ledger(f.organizationId)).toBe(before) + await createProjectFileFolder.execute({ + principal: f.principal, + input: { projectId: f.projectId, name: 'Design' }, + }) + const restored = await restoreProjectFileFolder.execute({ + principal: f.principal, + input: { projectId: f.projectId, folderId: parent.folder.id }, + }) + expect(restored.folder.name).toBe('Design (1)') + expect(restored.restoredItems).toEqual({ files: 1, folders: 2 }) + const content = await readProjectFileContent.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: current.file.id, includeSecretProvenance: true }, + }) + expect(content.content.toString()).toBe('shared content') + expect(content.file.uploadedBy).toBe(f.editorId) + expect(content.secretProvenance).toEqual({ status: 'exact', entries: [] }) + await expect( + readProjectFileContent.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: prior.file.id }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + expect(await ledger(f.organizationId)).toBe(before) + } + ) + + check( + 'bulk moves and archives reject foreign items, conflicts, and descendant cycles atomically', + async () => { + const { archiveProjectFileItems, moveProjectFileItems } = await import( + '@/lib/projects/files/application/lifecycle' + ) + const f = await fixture() + const other = await fixture() + const destination = await createProjectFileFolder.execute({ + principal: f.principal, + input: { projectId: f.projectId, name: 'Target' }, + }) + const child = await createProjectFileFolder.execute({ + principal: f.principal, + input: { projectId: f.projectId, name: 'Child', parentId: destination.folder.id }, + }) + const a = await createProjectFile.execute({ + principal: f.principal, + input: createInput(f.projectId), + }) + const b = await createProjectFile.execute({ + principal: f.principal, + input: { ...createInput(f.projectId), name: 'second.md' }, + }) + const foreign = await createProjectFile.execute({ + principal: other.principal, + input: createInput(other.projectId), + }) + await createProjectFile.execute({ + principal: f.principal, + input: { ...createInput(f.projectId), name: 'second.md', folderId: destination.folder.id }, + }) + await expect( + moveProjectFileItems.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileIds: [a.file.id, b.file.id], + targetFolderId: destination.folder.id, + }, + }) + ).rejects.toMatchObject({ code: 'conflict' }) + await expect( + archiveProjectFileItems.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileIds: [a.file.id, foreign.file.id] }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + await expect( + moveProjectFileItems.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + folderIds: [destination.folder.id], + targetFolderId: child.folder.id, + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + const [unchanged] = (await rows(f.projectId)).filter((file) => file.id === a.file.id) + expect(unchanged).toMatchObject({ folderId: null, deletedAt: null }) + const moved = await moveProjectFileItems.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileIds: [a.file.id], targetFolderPath: '/Target/Child' }, + }) + expect(moved.movedFileIds).toEqual([a.file.id]) + const [stored] = (await rows(f.projectId)).filter((file) => file.id === a.file.id) + expect(stored.folderId).toBe(child.folder.id) + expect(stored.contentUpdatedAt).toEqual(a.file.contentUpdatedAt) + } + ) + + check( + 'file restore re-roots from archived folders and deduplicates while rename preserves byte identity', + async () => { + const { archiveProjectFileItems, renameProjectFile, restoreProjectFile } = await import( + '@/lib/projects/files/application/lifecycle' + ) + const f = await fixture() + const parent = await createProjectFileFolder.execute({ + principal: f.principal, + input: { projectId: f.projectId, name: 'Archive' }, + }) + const file = await createProjectFile.execute({ + principal: f.principal, + input: { ...createInput(f.projectId), folderId: parent.folder.id }, + }) + const renamed = await renameProjectFile.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: file.file.id, name: 'renamed.md' }, + }) + expect(renamed.file.key).toBe(file.file.key) + expect(renamed.file.contentUpdatedAt).toEqual(file.file.contentUpdatedAt) + await archiveProjectFileItems.execute({ + principal: f.principal, + input: { projectId: f.projectId, folderIds: [parent.folder.id] }, + }) + await createProjectFile.execute({ + principal: f.principal, + input: { ...createInput(f.projectId), name: 'renamed.md' }, + }) + const restored = await restoreProjectFile.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: file.file.id }, + }) + expect(restored.file.folderId).toBeNull() + expect(restored.file.name).not.toBe('renamed.md') + expect(restored.file.name.endsWith('.md')).toBe(true) + expect(restored.file.key).toBe(file.file.key) + expect(restored.file.contentUpdatedAt).toEqual(file.file.contentUpdatedAt) + } + ) +}) + +describe('Public file shares against PostgreSQL and private storage', () => { + check( + 'public share configuration requires Project write and the canonical organization sharing policy', + async () => { + const { updateProjectFileShare, getProjectFileShare } = await import( + '@/lib/projects/files/application/shares' + ) + const f = await fixture() + const other = await fixture() + const { file } = await createProjectFile.execute({ + principal: f.principal, + input: createInput(f.projectId), + }) + const input = { projectId: f.projectId, fileId: file.id, isActive: true } + const created = await updateProjectFileShare.execute({ principal: f.principal, input }) + expect(created.share.isActive).toBe(true) + const [stored] = await db + .select() + .from(publicShare) + .where(eq(publicShare.id, created.share.id)) + expect(stored).toMatchObject({ + entityType: 'project', + entityId: f.projectId, + workspaceId: null, + createdBy: f.editorId, + }) + await expect( + getProjectFileShare.execute({ + principal: other.principal, + input: { projectId: other.projectId, fileId: file.id }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + await db + .update(permissions) + .set({ permissionType: 'read' }) + .where(eq(permissions.userId, f.editorId)) + await expect( + updateProjectFileShare.execute({ principal: f.principal, input }) + ).rejects.toMatchObject({ code: 'forbidden' }) + await db + .update(permissions) + .set({ permissionType: 'admin' }) + .where(eq(permissions.userId, f.editorId)) + await db.insert(subscription).values({ + id: generateId(), + plan: 'enterprise', + referenceId: f.organizationId, + status: 'active', + metadata: {}, + }) + await db.insert(permissionGroup).values({ + id: generateId(), + organizationId: f.organizationId, + createdBy: f.ownerId, + name: 'No public sharing', + isDefault: true, + membershipMode: 'inherit', + config: { disablePublicFileSharing: true }, + }) + await expect( + updateProjectFileShare.execute({ principal: f.principal, input }) + ).rejects.toMatchObject({ detailCode: 'PUBLIC_SHARING_NOT_ALLOWED' }) + const disabled = await updateProjectFileShare.execute({ + principal: f.principal, + input: { ...input, isActive: false }, + }) + expect(disabled.share).toMatchObject({ isActive: false, token: created.share.token }) + } + ) + + check( + 'public share policy includes each accessible active environment without a selected-environment bypass', + async () => { + const { updateProjectFileShare } = await import('@/lib/projects/files/application/shares') + const f = await fixture() + const { file } = await createProjectFile.execute({ + principal: f.principal, + input: createInput(f.projectId), + }) + const secondId = generateId() + await insertWorkspaceFixture(db, { + id: secondId, + ownerId: f.ownerId, + billedAccountUserId: f.ownerId, + organizationId: f.organizationId, + workspaceMode: 'organization', + name: 'Restricted environment', + forkedFromWorkspaceId: f.workspaceId, + }) + try { + await db.insert(subscription).values({ + id: generateId(), + plan: 'enterprise', + referenceId: f.organizationId, + status: 'active', + metadata: {}, + }) + const groupId = generateId() + await db.insert(permissionGroup).values({ + id: groupId, + organizationId: f.organizationId, + createdBy: f.ownerId, + name: 'Restricted sharing', + membershipMode: 'inherit', + config: { disablePublicFileSharing: true }, + }) + await db.insert(permissionGroupWorkspace).values({ + id: generateId(), + permissionGroupId: groupId, + workspaceId: secondId, + organizationId: f.organizationId, + }) + const input = { projectId: f.projectId, fileId: file.id, isActive: true } + await updateProjectFileShare.execute({ principal: f.principal, input }) + await db.insert(permissions).values({ + id: generateId(), + userId: f.editorId, + entityType: 'workspace', + entityId: secondId, + permissionType: 'read', + }) + await expect( + updateProjectFileShare.execute({ principal: f.principal, input }) + ).rejects.toMatchObject({ detailCode: 'PUBLIC_SHARING_NOT_ALLOWED' }) + await db + .update(permissionGroup) + .set({ config: { allowedFileShareAuthTypes: ['password'] } }) + .where(eq(permissionGroup.id, groupId)) + await expect( + updateProjectFileShare.execute({ principal: f.principal, input }) + ).rejects.toMatchObject({ detailCode: 'PUBLIC_SHARING_NOT_ALLOWED' }) + const allowed = await updateProjectFileShare.execute({ + principal: f.principal, + input: { ...input, authType: 'password', password: 'a-valid-share-password' }, + }) + expect(allowed.share.authType).toBe('password') + } finally { + await deleteWorkspaceFixture(db, eq(workspace.id, secondId)) + } + } + ) + + check( + 'public share credentials preserve password cookies, verified email, and actual session SSO identity', + async () => { + const { updateProjectFileShare } = await import('@/lib/projects/files/application/shares') + const { authorizePublicFileShare, readPublicFileShareContent } = await import( + '@/lib/public-shares/application' + ) + const { setDeploymentAuthCookie, deploymentAuthCookieName } = await import( + '@/lib/core/security/deployment' + ) + const f = await fixture() + const { file } = await createProjectFile.execute({ + principal: f.principal, + input: createInput(f.projectId, 'Shared architecture'), + }) + const input = { projectId: f.projectId, fileId: file.id, isActive: true } + const { share } = await updateProjectFileShare.execute({ + principal: f.principal, + input: { ...input, authType: 'password', password: 'correct-password' }, + }) + const token = share.token + expect( + await authorizePublicFileShare({ token, credential: { method: 'GET' } }) + ).toMatchObject({ authorized: false, error: 'auth_required_password' }) + expect( + await authorizePublicFileShare({ + token, + credential: { method: 'POST', password: 'wrong-password' }, + }) + ).toMatchObject({ authorized: false, error: 'Invalid password' }) + const accepted = await authorizePublicFileShare({ + token, + credential: { method: 'POST', password: 'correct-password' }, + }) + if (!accepted.authorized) throw new Error('Correct share password rejected') + expect((await readPublicFileShareContent({ grant: accepted.grant })).buffer.toString()).toBe( + 'Shared architecture' + ) + const [passwordShare] = await db + .select() + .from(publicShare) + .where(eq(publicShare.id, share.id)) + const response = NextResponse.json({}) + await setDeploymentAuthCookie({ response, cookiePrefix: 'file', resource: passwordShare }) + const cookies = Object.fromEntries( + response.cookies.getAll().map(({ name, value }) => [name, value]) + ) + expect( + await authorizePublicFileShare({ token, credential: { method: 'GET', cookies } }) + ).toMatchObject({ authorized: true }) + await updateProjectFileShare.execute({ + principal: f.principal, + input: { ...input, authType: 'email', allowedEmails: ['viewer@shared.invalid'] }, + }) + expect( + await authorizePublicFileShare({ + token, + credential: { method: 'POST', email: 'viewer@shared.invalid', cookies }, + }) + ).toMatchObject({ authorized: false, error: 'otp_required' }) + const [emailShare] = await db.select().from(publicShare).where(eq(publicShare.id, share.id)) + await setDeploymentAuthCookie({ + response, + cookiePrefix: 'file', + resource: emailShare, + verifiedEmail: 'viewer@shared.invalid', + }) + const emailCookie = response.cookies.get(deploymentAuthCookieName('file', share.id)) + if (!emailCookie) throw new Error('Email fixture token missing') + expect( + await authorizePublicFileShare({ + token, + credential: { method: 'GET', cookies: { [emailCookie.name]: emailCookie.value } }, + }) + ).toMatchObject({ authorized: true, authenticatedEmail: 'viewer@shared.invalid' }) + await updateProjectFileShare.execute({ + principal: f.principal, + input: { ...input, authType: 'sso', allowedEmails: [`${f.editorId}@content.invalid`] }, + }) + expect( + await authorizePublicFileShare({ + token, + credential: { method: 'GET', email: `${f.editorId}@content.invalid` }, + }) + ).toMatchObject({ authorized: false, error: 'auth_required_sso' }) + expect( + await authorizePublicFileShare({ + token, + credential: { method: 'GET', sessionPrincipal: f.principal }, + }) + ).toMatchObject({ authorized: true, authenticatedEmail: `${f.editorId}@content.invalid` }) + expect( + await authorizePublicFileShare({ + token, + credential: { + method: 'GET', + sessionPrincipal: createSessionPrincipal({ userId: f.ownerId }), + }, + }) + ).toMatchObject({ authorized: false }) + } + ) + + check( + 'public share password exchange binds its cookie to the current password and rejects wrong modes', + async () => { + const { updateProjectFileShare } = await import('@/lib/projects/files/application/shares') + const { authorizePublicFileShare, authenticatePublicFileSharePassword } = await import( + '@/lib/public-shares/application' + ) + const f = await fixture() + const { file } = await createProjectFile.execute({ + principal: f.principal, + input: createInput(f.projectId), + }) + const input = { projectId: f.projectId, fileId: file.id, isActive: true } + const { share } = await updateProjectFileShare.execute({ principal: f.principal, input }) + await expect( + authenticatePublicFileSharePassword({ + token: share.token, + password: 'a-valid-share-password', + }) + ).rejects.toMatchObject({ code: 'validation' }) + await updateProjectFileShare.execute({ + principal: f.principal, + input: { ...input, authType: 'password', password: 'a-valid-share-password' }, + }) + expect( + await authenticatePublicFileSharePassword({ + token: share.token, + password: 'wrong-password', + }) + ).toMatchObject({ authorized: false, error: 'Invalid password' }) + const access = await authenticatePublicFileSharePassword({ + token: share.token, + password: 'a-valid-share-password', + }) + if (!access.authorized) throw new Error('Password exchange failed') + const cookies = { [access.cookie.name]: access.cookie.value } + expect( + await authorizePublicFileShare({ + token: share.token, + credential: { method: 'GET', cookies }, + }) + ).toMatchObject({ authorized: true, authType: 'password' }) + await updateProjectFileShare.execute({ + principal: f.principal, + input: { ...input, password: 'replacement-share-password' }, + }) + expect( + await authorizePublicFileShare({ + token: share.token, + credential: { method: 'GET', cookies }, + }) + ).toMatchObject({ authorized: false, authType: 'password' }) + } + ) + + check( + 'public share OTP delivery and verification retain allowlists, code attempts, and current token policy', + async () => { + const { updateProjectFileShare } = await import('@/lib/projects/files/application/shares') + const { authorizePublicFileShare, requestPublicFileShareOtp, verifyPublicFileShareOtp } = + await import('@/lib/public-shares/application') + const otpStore = await import('@/lib/core/security/otp') + const f = await fixture() + const email = `${generateId()}@shared.invalid` + const { file } = await createProjectFile.execute({ + principal: f.principal, + input: createInput(f.projectId), + }) + const input = { + projectId: f.projectId, + fileId: file.id, + isActive: true, + authType: 'email' as const, + allowedEmails: [email], + } + const { share } = await updateProjectFileShare.execute({ principal: f.principal, input }) + emailMailerMockFns.mockSendEmail.mockResolvedValue({ + success: true, + message: 'Local mail boundary accepted', + }) + await ( + await requestPublicFileShareOtp({ token: share.token, email: 'outsider@shared.invalid' }) + ).deliver() + expect(await otpStore.getOTP('file', share.id, 'outsider@shared.invalid')).toBeNull() + await (await requestPublicFileShareOtp({ token: share.token, email })).deliver() + const stored = await otpStore.getOTP('file', share.id, email) + if (!stored) throw new Error('Allowed email received no code') + const { otp } = otpStore.decodeOTPValue(stored) + expect( + await verifyPublicFileShareOtp({ token: share.token, email, otp: '000000' }) + ).toMatchObject({ authorized: false, status: 400 }) + const accepted = await verifyPublicFileShareOtp({ token: share.token, email, otp }) + if (!accepted.authorized) throw new Error('Valid verification code rejected') + expect( + await authorizePublicFileShare({ + token: share.token, + credential: { method: 'GET', cookies: { [accepted.cookie.name]: accepted.cookie.value } }, + }) + ).toMatchObject({ authorized: true, authenticatedEmail: email }) + expect(await verifyPublicFileShareOtp({ token: share.token, email, otp })).toMatchObject({ + authorized: false, + status: 400, + }) + await otpStore.storeOTP('file', share.id, email, '123456') + const get = otpStore.getOTP + const spy = vi.spyOn(otpStore, 'getOTP').mockImplementation(async (...args) => { + const result = await get(...args) + await db + .update(publicShare) + .set({ allowedEmails: ['replacement@shared.invalid'] }) + .where(eq(publicShare.id, share.id)) + return result + }) + await expect( + verifyPublicFileShareOtp({ token: share.token, email, otp: '123456' }) + ).rejects.toMatchObject({ code: 'not_found' }) + spy.mockRestore() + await otpStore.deleteOTP('file', share.id, email) + } + ) + + check( + 'public share SSO eligibility never grants bytes and follows current active owner policy', + async () => { + const { updateProjectFileShare } = await import('@/lib/projects/files/application/shares') + const { authorizePublicFileShare, getPublicFileShareSsoEligibility } = await import( + '@/lib/public-shares/application' + ) + const f = await fixture() + const email = `${f.editorId}@content.invalid` + const { file } = await createProjectFile.execute({ + principal: f.principal, + input: createInput(f.projectId), + }) + const { share } = await updateProjectFileShare.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: file.id, + isActive: true, + authType: 'sso', + allowedEmails: [email], + }, + }) + expect(await getPublicFileShareSsoEligibility({ token: share.token, email })).toEqual({ + allowed: true, + eligible: true, + }) + expect( + await getPublicFileShareSsoEligibility({ + token: share.token, + email: 'outsider@shared.invalid', + }) + ).toEqual({ allowed: true, eligible: false }) + expect( + await authorizePublicFileShare({ token: share.token, credential: { method: 'GET', email } }) + ).toMatchObject({ authorized: false, authType: 'sso' }) + await db.update(publicShare).set({ isActive: false }).where(eq(publicShare.id, share.id)) + await expect( + getPublicFileShareSsoEligibility({ token: share.token, email }) + ).rejects.toMatchObject({ code: 'not_found' }) + } + ) + + check( + 'public share Office artifacts require the current owner-qualified dependency cache without compilation', + async () => { + const { updateProjectFileShare } = await import('@/lib/projects/files/application/shares') + const { readProjectFileArtifact } = await import('@/lib/projects/files/application/artifacts') + const { authorizePublicFileShare, readPublicFileShareContent } = await import( + '@/lib/public-shares/application' + ) + const f = await fixture() + const asset = await createProjectFile.execute({ + principal: f.principal, + input: { ...createInput(f.projectId, 'first input'), name: 'input.txt' }, + }) + const source = await createProjectFile.execute({ + principal: f.principal, + input: { + ...createInput(f.projectId, `getFileBase64('${asset.file.id}')`), + name: 'report.pptx', + contentType: 'text/x-pptxgenjs', + }, + }) + const { share } = await updateProjectFileShare.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: source.file.id, isActive: true }, + }) + const access = await authorizePublicFileShare({ + token: share.token, + credential: { method: 'GET' }, + }) + if (!access.authorized) throw new Error('Public artifact fixture rejected') + await expect(readPublicFileShareContent({ grant: access.grant })).rejects.toMatchObject({ + code: 'conflict', + }) + vi.spyOn(sandboxTask, 'runSandboxTask').mockResolvedValue( + Buffer.from('PK\u0003\u0004artifact') + ) + const rendered = await readProjectFileArtifact.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: source.file.id, maxBytes: 1024 }, + }) + expect((await readPublicFileShareContent({ grant: access.grant })).buffer).toEqual( + rendered.buffer + ) + await updateProjectFileContent.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: asset.file.id, + content: 'changed input', + encoding: 'utf-8', + }, + }) + await expect(readPublicFileShareContent({ grant: access.grant })).rejects.toMatchObject({ + code: 'conflict', + }) + } + ) + + check( + 'public share revocation and password changes during object reads suppress downloaded bytes', + async () => { + const { updateProjectFileShare } = await import('@/lib/projects/files/application/shares') + const { authorizePublicFileShare, readPublicFileShareContent } = await import( + '@/lib/public-shares/application' + ) + const { encryptSecret } = await import('@/lib/core/security/encryption') + for (const change of ['disabled', 'password', 'archived'] as const) { + const f = await fixture() + const { file } = await createProjectFile.execute({ + principal: f.principal, + input: createInput(f.projectId), + }) + const { share } = await updateProjectFileShare.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: file.id, + isActive: true, + authType: 'password', + password: 'initial-password', + }, + }) + const access = await authorizePublicFileShare({ + token: share.token, + credential: { method: 'POST', password: 'initial-password' }, + }) + if (!access.authorized) throw new Error('Initial credential rejected') + const download = storage.downloadFile + const spy = vi.spyOn(storage, 'downloadFile').mockImplementation(async (options) => { + const result = await download(options) + if (options.key === file.key) { + if (change === 'disabled') + await db + .update(publicShare) + .set({ isActive: false }) + .where(eq(publicShare.id, share.id)) + if (change === 'password') + await db + .update(publicShare) + .set({ password: (await encryptSecret('replacement-password')).encrypted }) + .where(eq(publicShare.id, share.id)) + if (change === 'archived') + await db + .update(workspaceFiles) + .set({ deletedAt: new Date() }) + .where(eq(workspaceFiles.id, file.id)) + } + return result + }) + await expect(readPublicFileShareContent({ grant: access.grant })).rejects.toMatchObject({ + code: 'not_found', + }) + spy.mockRestore() + } + } + ) + + check( + 'public share grants cannot be cloned or reused after Project archive and preserve workspace links', + async () => { + const { updateProjectFileShare } = await import('@/lib/projects/files/application/shares') + const { authorizePublicFileShare, readPublicFileShare, readPublicFileShareContent } = + await import('@/lib/public-shares/application') + const { uploadWorkspaceFile } = await import( + '@/lib/uploads/contexts/workspace/workspace-file-manager' + ) + const { upsertFileShare } = await import('@/lib/public-shares/share-manager') + const f = await fixture() + const { file } = await createProjectFile.execute({ + principal: f.principal, + input: createInput(f.projectId), + }) + const { share } = await updateProjectFileShare.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: file.id, isActive: true }, + }) + const access = await authorizePublicFileShare({ + token: share.token, + credential: { method: 'GET' }, + }) + if (!access.authorized) throw new Error('Public share rejected') + expect(await readPublicFileShare({ grant: access.grant })).toMatchObject({ + file: { id: file.id, originalName: 'architecture.md' }, + }) + await expect( + readPublicFileShareContent({ grant: { ...access.grant } }) + ).rejects.toMatchObject({ code: 'not_found' }) + await db.transaction(async (tx) => { + await tx + .update(workspace) + .set({ archivedAt: new Date() }) + .where(eq(workspace.id, f.workspaceId)) + await tx.update(project).set({ archivedAt: new Date() }).where(eq(project.id, f.projectId)) + }) + await expect(readPublicFileShare({ grant: access.grant })).rejects.toMatchObject({ + code: 'not_found', + }) + await db.transaction(async (tx) => { + await tx.update(project).set({ archivedAt: null }).where(eq(project.id, f.projectId)) + await tx.update(workspace).set({ archivedAt: null }).where(eq(workspace.id, f.workspaceId)) + }) + const legacy = await uploadWorkspaceFile( + f.workspaceId, + f.ownerId, + Buffer.from('Workspace link'), + 'legacy.txt', + 'text/plain' + ) + const legacyShare = await upsertFileShare({ + workspaceId: f.workspaceId, + fileId: legacy.id, + userId: f.ownerId, + isActive: true, + }) + const legacyAccess = await authorizePublicFileShare({ + token: legacyShare.token, + credential: { method: 'GET' }, + }) + if (!legacyAccess.authorized) throw new Error('Existing workspace share rejected') + expect( + (await readPublicFileShareContent({ grant: legacyAccess.grant })).buffer.toString() + ).toBe('Workspace link') + } + ) + + check( + 'public share page assets remain confined to explicit references in the canonical owner', + async () => { + const { updateProjectFileShare } = await import('@/lib/projects/files/application/shares') + const { authorizePublicFileShare, readPublicFileShareContent, readPublicFileShareInline } = + await import('@/lib/public-shares/application') + const f = await fixture() + const other = await fixture() + const image = Buffer.from( + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+a9uoAAAAASUVORK5CYII=', + 'base64' + ) + const asset = await createProjectFile.execute({ + principal: f.principal, + input: { + ...createInput(f.projectId, image.toString('base64')), + encoding: 'base64', + name: 'diagram.png', + contentType: 'image/png', + }, + }) + const sibling = await createProjectFile.execute({ + principal: f.principal, + input: { ...createInput(f.projectId, 'Private sibling'), name: 'private.txt' }, + }) + const foreign = await createProjectFile.execute({ + principal: other.principal, + input: createInput(other.projectId), + }) + const source = await createProjectFile.execute({ + principal: f.principal, + input: { + ...createInput( + f.projectId, + `---\ntitle: Architecture\n---\n\n![Diagram](sim:file/${asset.file.id})` + ), + name: 'Architecture', + contentType: 'text/x-sim-page', + }, + }) + const { share } = await updateProjectFileShare.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: source.file.id, isActive: true }, + }) + const access = await authorizePublicFileShare({ + token: share.token, + credential: { method: 'GET' }, + }) + if (!access.authorized) throw new Error('Public page rejected') + const rendered = await readPublicFileShareContent({ grant: access.grant }) + expect(rendered.contentType).toBe('text/html') + expect(rendered.buffer.toString()).toContain( + `data:image/png;base64,${image.toString('base64')}` + ) + expect( + (await readPublicFileShareInline({ grant: access.grant, fileId: asset.file.id })).buffer + ).toEqual(image) + for (const fileId of [sibling.file.id, foreign.file.id]) + await expect( + readPublicFileShareInline({ grant: access.grant, fileId }) + ).rejects.toMatchObject({ code: 'not_found' }) + await updateProjectFileContent.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: source.file.id, + content: `---\ntitle: Architecture\n---\n\n![Foreign](sim:file/${foreign.file.id})`, + encoding: 'utf-8', + }, + }) + await expect(readPublicFileShareContent({ grant: access.grant })).rejects.toMatchObject({ + code: 'not_found', + }) + } + ) +}) + +describe('Project outer transaction cleanup', () => { + for (const operation of ['create', 'update'] as const) { + check(`cleans Project ${operation} bytes after a deferred COMMIT rejection`, async () => { + const f = await fixture() + const source = await createProjectFile.execute({ + principal: f.principal, + input: createInput(f.projectId, 'original'), + }) + const beforeUsage = await ledger(f.organizationId) + const triggerName = sql.identifier( + `reject_project_commit_${generateId().replaceAll('-', '')}` + ) + await db.execute(sql`CREATE FUNCTION ${triggerName}() RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + IF NEW.project_id = TG_ARGV[0] THEN + RAISE EXCEPTION 'Deferred file constraint rejected COMMIT' USING ERRCODE = '23514'; + END IF; + RETURN NEW; + END; + $$`) + await db.execute(sql`CREATE CONSTRAINT TRIGGER ${triggerName} + AFTER INSERT OR UPDATE ON ${workspaceFiles} DEFERRABLE INITIALLY DEFERRED + FOR EACH ROW EXECUTE FUNCTION ${triggerName}(${sql.raw(`'${f.projectId}'`)})`) + const transaction = db.transaction.bind(db) + let callbackCompleted = false + const observer = vi.spyOn(db, 'transaction').mockImplementation((callback, config) => + transaction(async (tx) => { + const result = await callback(tx) + if (isRecordLike(result) && isRecordLike(result.result) && 'file' in result.result) + callbackCompleted = true + return result + }, config) + ) + const upload = storage.uploadFile + let stagedKey = '' + const capture = vi.spyOn(storage, 'uploadFile').mockImplementation(async (args) => { + const result = await upload(args) + stagedKey = result.key + return result + }) + try { + const pending = + operation === 'create' + ? createProjectFile.execute({ + principal: f.principal, + input: { ...createInput(f.projectId, 'rejected'), name: 'new.md' }, + }) + : updateProjectFileContent.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: source.file.id, + content: 'rejected replacement', + encoding: 'utf-8', + }, + }) + const rejection = await pending.catch((error: unknown) => error) + expect(getPostgresErrorCode(rejection)).toBe('23514') + expect(callbackCompleted).toBe(true) + } finally { + observer.mockRestore() + capture.mockRestore() + await db.execute(sql`DROP TRIGGER ${triggerName} ON ${workspaceFiles}`) + await db.execute(sql`DROP FUNCTION ${triggerName}()`) + } + expect(stagedKey).not.toBe('') + expect(await ledger(f.organizationId)).toBe(beforeUsage) + expect( + await db.select().from(workspaceFiles).where(eq(workspaceFiles.key, stagedKey)) + ).toEqual([]) + expect( + await db + .select() + .from(workspaceFileVersion) + .where(eq(workspaceFileVersion.fileId, source.file.id)) + ).toEqual([]) + expect( + ( + await readProjectFileContent.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: source.file.id }, + }) + ).content.toString() + ).toBe('original') + await expect + .soft(readFile(join(localStorageRoot, stagedKey))) + .rejects.toMatchObject({ code: 'ENOENT' }) + expect( + await db + .select() + .from(outboxEvent) + .where(sql`${outboxEvent.payload}->>'key' = ${stagedKey}`) + ).toHaveLength(1) + }) + } + + for (const { operation, code } of [ + { operation: 'create', code: undefined }, + { operation: 'update', code: undefined }, + { operation: 'revert', code: undefined }, + { operation: 'preview', code: undefined }, + { operation: 'update', code: '40003' }, + { operation: 'update', code: 'CONNECTION_CLOSED' }, + ] as const) { + check( + `retains committed Project ${operation} bytes after acknowledgement loss (${code ?? 'uncoded'})`, + async () => { + const f = await fixture() + const sourceBytes = Buffer.alloc(16) + sourceBytes.writeUInt32BE(16, 0) + sourceBytes.write('ftypheic', 4, 'ascii') + const source = await createProjectFile.execute({ + principal: f.principal, + input: + operation === 'preview' + ? { + ...createInput(f.projectId), + name: 'preview.heic', + contentType: 'image/heic', + content: sourceBytes.toString('base64'), + encoding: 'base64', + } + : createInput(f.projectId, 'original'), + }) + if (operation === 'revert') + await updateProjectFileContent.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: source.file.id, + content: 'second', + encoding: 'utf-8', + }, + }) + vi.spyOn(heic, 'transcodeHeicToJpeg').mockResolvedValue(Buffer.alloc(128, 255)) + const upload = storage.uploadFile + const written: string[] = [] + vi.spyOn(storage, 'uploadFile').mockImplementation(async (args) => { + const result = await upload(args) + written.push(result.key) + return result + }) + const transaction = db.transaction.bind(db) + const primary = Object.assign( + new Error('Commit acknowledgement lost'), + code ? { code } : {} + ) + let lost = false + vi.spyOn(db, 'transaction').mockImplementation(async (callback, config) => { + const result = await transaction(callback, config) + if ( + !lost && + written.length > 0 && + isRecordLike(result) && + isRecordLike(result.result) && + 'file' in result.result + ) { + lost = true + throw primary + } + return result + }) + const input = { projectId: f.projectId, fileId: source.file.id } + const action = + operation === 'create' + ? createProjectFile.execute({ + principal: f.principal, + input: { ...createInput(f.projectId, 'committed'), name: 'new.md' }, + }) + : operation === 'update' + ? updateProjectFileContent.execute({ + principal: f.principal, + input: { ...input, content: 'committed', encoding: 'utf-8' }, + }) + : operation === 'revert' + ? revertProjectFileVersion.execute({ + principal: f.principal, + input: { ...input, version: 1, expectedCurrentVersion: 2 }, + }) + : readProjectFileArtifact.execute({ + principal: f.principal, + input: { ...input, preview: true, maxBytes: 1024 }, + }) + await expect(action).rejects.toBe(primary) + expect(lost).toBe(true) + expect(written).toHaveLength(1) + for (const key of written) { + expect((await readFile(join(localStorageRoot, key))).length).toBeGreaterThan(0) + expect( + await db + .select() + .from(outboxEvent) + .where(sql`${outboxEvent.payload}::jsonb ->> 'key' = ${key}`) + ).toEqual([]) + } + } + ) + } + + for (const deleteUnavailable of [false, true]) { + check( + `preserves aborted Project content error when cleanup enqueue fails (delete unavailable=${deleteUnavailable})`, + async () => { + const f = await fixture() + const source = await createProjectFile.execute({ + principal: f.principal, + input: createInput(f.projectId, 'original'), + }) + const primary = new OrchestrationError('conflict', 'Content transaction rejected') + vi.spyOn(tracking, 'prepareFileStorageMutationInTx').mockRejectedValueOnce(primary) + vi.spyOn(storageCleanup, 'enqueueWorkspaceFileStorageCleanups').mockRejectedValue( + new Error('Cleanup database unavailable') + ) + if (deleteUnavailable) + vi.spyOn(storage, 'deleteFile').mockRejectedValue(new Error('Storage unavailable')) + const upload = storage.uploadFile + let key = '' + vi.spyOn(storage, 'uploadFile').mockImplementation(async (args) => { + const result = await upload(args) + key = result.key + return result + }) + await expect( + updateProjectFileContent.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: source.file.id, + content: 'replacement', + encoding: 'utf-8', + }, + }) + ).rejects.toBe(primary) + expect(key).not.toBe('') + if (deleteUnavailable) + expect((await readFile(join(localStorageRoot, key))).toString()).toBe('replacement') + else + await expect(readFile(join(localStorageRoot, key))).rejects.toMatchObject({ + code: 'ENOENT', + }) + expect((await readFile(join(localStorageRoot, source.file.key))).toString()).toBe( + 'original' + ) + } + ) + } + + check( + 'preserves preview size rejection when cleanup enqueue fails and deletes the derivative', + async () => { + const f = await fixture() + const bytes = Buffer.alloc(16) + bytes.writeUInt32BE(16, 0) + bytes.write('ftypheic', 4, 'ascii') + const source = await createProjectFile.execute({ + principal: f.principal, + input: { + ...createInput(f.projectId), + name: 'preview.heic', + contentType: 'image/heic', + content: bytes.toString('base64'), + encoding: 'base64', + }, + }) + vi.spyOn(heic, 'transcodeHeicToJpeg').mockResolvedValue(Buffer.alloc(128, 255)) + vi.spyOn(storageCleanup, 'enqueueWorkspaceFileStorageCleanups').mockRejectedValue( + new Error('Cleanup database unavailable') + ) + await expect + .soft( + readProjectFileArtifact.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: source.file.id, preview: true, maxBytes: 64 }, + }) + ) + .rejects.toMatchObject({ name: 'PayloadSizeLimitError' }) + expect( + await readdir(join(localStorageRoot, 'project', f.projectId, 'image-derivative')) + ).toEqual([]) + } + ) +}) + +describe('Project rendered artifacts against PostgreSQL and private storage', () => { + for (const failure of ['size rejection', 'revision change'] as const) { + check( + `a preview with ${failure} removes its derivative and preserves the original error`, + async () => { + const f = await fixture() + const sourceBytes = Buffer.alloc(16) + sourceBytes.writeUInt32BE(16, 0) + sourceBytes.write('ftypheic', 4, 'ascii') + const source = await createProjectFile.execute({ + principal: f.principal, + input: { + ...createInput(f.projectId), + name: 'preview.heic', + contentType: 'image/heic', + content: sourceBytes.toString('base64'), + encoding: 'base64', + }, + }) + vi.spyOn(heic, 'transcodeHeicToJpeg').mockImplementation(async () => { + if (failure === 'revision change') { + await updateProjectFileContent.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: source.file.id, + content: 'replacement source', + encoding: 'utf-8', + }, + }) + } + return Buffer.alloc(128, 255) + }) + const rendering = readProjectFileArtifact.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: source.file.id, + preview: true, + maxBytes: failure === 'size rejection' ? 64 : 1024, + }, + }) + await expect + .soft(rendering) + .rejects.toMatchObject( + failure === 'size rejection' ? { name: 'PayloadSizeLimitError' } : { code: 'conflict' } + ) + expect + .soft(await readdir(join(localStorageRoot, 'project', f.projectId, 'image-derivative'))) + .toEqual([]) + const current = await readProjectFileContent.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: source.file.id }, + }) + expect(current.content).toEqual( + failure === 'size rejection' ? sourceBytes : Buffer.from('replacement source') + ) + } + ) + } + + for (const failure of ['revoked read', 'uncertain pointer upload'] as const) { + check(`a render with ${failure} durably removes its newly published objects`, async () => { + const { readProjectFileArtifact } = await import('@/lib/projects/files/application/artifacts') + const f = await fixture() + const asset = await createProjectFile.execute({ + principal: f.principal, + input: { ...createInput(f.projectId, 'image'), name: 'logo.png', contentType: 'image/png' }, + }) + const source = await createProjectFile.execute({ + principal: f.principal, + input: { + ...createInput(f.projectId, `getFileBase64('${asset.file.id}')`), + name: 'design.pptx', + contentType: 'text/x-pptxgenjs', + }, + }) + vi.spyOn(sandboxTask, 'runSandboxTask').mockResolvedValue( + Buffer.from('PK\u0003\u0004document') + ) + const upload = storage.uploadFile + const writtenKeys: string[] = [] + vi.spyOn(storage, 'uploadFile').mockImplementation(async (options) => { + const result = await upload(options) + if (result.key.startsWith(`project/${f.projectId}/compiled/`)) { + writtenKeys.push(result.key) + if (result.key.endsWith('.published.json')) { + if (failure === 'uncertain pointer upload') throw new Error('Lost upload response') + await db.delete(permissions).where(eq(permissions.userId, f.editorId)) + } + } + return result + }) + vi.spyOn(storage, 'deleteFile').mockRejectedValue(new Error('Cleanup unavailable')) + const rendering = readProjectFileArtifact.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: source.file.id, maxBytes: 1_000_000 }, + }) + if (failure === 'revoked read') { + await expect(rendering).rejects.toMatchObject({ code: 'not_found' }) + } else { + await expect(rendering).rejects.toThrow('Lost upload response') + } + expect(writtenKeys).toHaveLength(2) + const events = await db + .select() + .from(outboxEvent) + .where( + sql`${outboxEvent.payload}::jsonb ->> 'key' LIKE ${`project/${f.projectId}/compiled/%`}` + ) + expect(events).toHaveLength(2) + expect(events.map((event) => event.status)).toEqual(['pending', 'pending']) + vi.mocked(storage.deleteFile).mockRestore() + for (const event of events) { + await db + .update(outboxEvent) + .set({ availableAt: new Date(0) }) + .where(eq(outboxEvent.id, event.id)) + await processOutboxEventById(event.id, workspaceFileStorageCleanupOutboxHandlers) + } + expect(await readdir(join(localStorageRoot, 'project', f.projectId, 'compiled'))).toEqual([]) + expect(await readFile(join(localStorageRoot, source.file.key))).toEqual( + Buffer.from(`getFileBase64('${asset.file.id}')`) + ) + }) + } + + check( + 'artifact pages bind embedded asset provenance and separately gate opaque model delivery', + async () => { + const { readProjectFileArtifact } = await import('@/lib/projects/files/application/artifacts') + const f = await fixture() + const secret = { + status: 'exact' as const, + entries: [ + { + encryptedValue: 'fixture-ciphertext', + sourceUserId: f.editorId, + sourceWorkspaceId: f.workspaceId, + }, + ], + } + const asset = await createProjectFile.execute({ + principal: f.principal, + input: { + ...createInput(f.projectId, 'private image bytes'), + name: 'diagram.png', + contentType: 'image/png', + secretProvenance: secret, + }, + }) + const source = `---\ntitle: Design\n---\n\n![Diagram](sim:file/${asset.file.id})` + const page = await createProjectFile.execute({ + principal: f.principal, + input: { + ...createInput(f.projectId, source), + name: 'design.html', + contentType: 'text/x-sim-page', + }, + }) + const input = { projectId: f.projectId, fileId: page.file.id, maxBytes: 1_000_000 } + const rendered = await readProjectFileArtifact.execute({ principal: f.principal, input }) + expect(rendered.contentType).toBe('text/html') + expect(rendered.buffer.toString()).toContain( + `data:image/png;base64,${Buffer.from('private image bytes').toString('base64')}` + ) + expect(rendered.secretProvenance).toEqual(secret) + await expect( + readProjectFileArtifact.execute({ + principal: f.principal, + input: { ...input, forModel: true }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + await updateProjectFileContent.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: asset.file.id, + content: 'public image bytes', + encoding: 'utf-8', + expectedUpdatedAt: asset.file.contentUpdatedAt, + }, + }) + const clean = await readProjectFileArtifact.execute({ + principal: f.principal, + input: { ...input, forModel: true }, + }) + expect(clean.secretProvenance).toEqual({ status: 'exact', entries: [] }) + expect(clean.buffer.toString()).toContain( + Buffer.from('public image bytes').toString('base64') + ) + } + ) + + check( + 'artifact source cannot import a foreign Project asset through a valid source-file capability', + async () => { + const { readProjectFileArtifact } = await import('@/lib/projects/files/application/artifacts') + const f = await fixture() + const foreign = await fixture() + const asset = await createProjectFile.execute({ + principal: foreign.principal, + input: { + ...createInput(foreign.projectId, 'foreign bytes'), + name: 'private.png', + contentType: 'image/png', + }, + }) + const page = await createProjectFile.execute({ + principal: f.principal, + input: { + ...createInput( + f.projectId, + `---\ntitle: Design\n---\n\n![Diagram](sim:file/${asset.file.id})` + ), + name: 'design.html', + contentType: 'text/x-sim-page', + }, + }) + await expect( + readProjectFileArtifact.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: page.file.id, maxBytes: 1_000_000 }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + } + ) + + check( + 'an asset changed during render cannot be delivered with stale provenance or source identity', + async () => { + const { readProjectFileArtifact } = await import('@/lib/projects/files/application/artifacts') + const f = await fixture() + const asset = await createProjectFile.execute({ + principal: f.principal, + input: { + ...createInput(f.projectId, 'before'), + name: 'diagram.png', + contentType: 'image/png', + }, + }) + const page = await createProjectFile.execute({ + principal: f.principal, + input: { + ...createInput( + f.projectId, + `---\ntitle: Design\n---\n\n![Diagram](sim:file/${asset.file.id})` + ), + name: 'design.html', + contentType: 'text/x-sim-page', + }, + }) + const download = storage.downloadFile + let changed = false + vi.spyOn(storage, 'downloadFile').mockImplementation(async (options) => { + const bytes = await download(options) + if (!changed && options.key === asset.file.key) { + changed = true + await updateProjectFileContent.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: asset.file.id, + content: 'after', + encoding: 'utf-8', + expectedUpdatedAt: asset.file.contentUpdatedAt, + secretProvenance: { status: 'unknown' }, + }, + }) + } + return bytes + }) + await expect( + readProjectFileArtifact.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: page.file.id, maxBytes: 1_000_000 }, + }) + ).rejects.toMatchObject({ code: 'conflict' }) + } + ) + + check( + 'generated Office artifacts use the canonical owner cache without exposing generator source', + async () => { + const { readProjectFileArtifact } = await import('@/lib/projects/files/application/artifacts') + const a = await fixture() + const b = await fixture() + const source = 'const title = "architecture"' + const mime = 'application/vnd.openxmlformats-officedocument.presentationml.presentation' + const prepared = [] + for (const [f, marker] of [ + [a, 'project A'], + [b, 'project B'], + ] as const) { + const created = await createProjectFile.execute({ + principal: f.principal, + input: { + ...createInput(f.projectId, source), + name: 'architecture.pptx', + contentType: 'text/x-pptxgenjs', + }, + }) + const artifact = Buffer.from(`PK\u0003\u0004${marker}`) + await storeCompiledDoc( + { entityType: 'project', entityId: f.projectId }, + source, + 'pptx', + mime, + artifact + ) + prepared.push({ f, created, artifact }) + } + for (const { f, created, artifact } of prepared) { + const rendered = await readProjectFileArtifact.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: created.file.id, + maxBytes: 1_000_000, + forModel: true, + }, + }) + expect(rendered.buffer).toEqual(artifact) + expect(rendered.contentType).toBe(mime) + expect(rendered.secretProvenance).toEqual({ status: 'exact', entries: [] }) + } + } + ) +}) + +describe('Project archive download and Markdown snapshot export', () => { + check( + 'Project recursive download retains nested paths, deduplicates selections, and excludes archived or foreign items', + async () => { + const { downloadProjectFileItems } = await import( + '@/lib/projects/files/application/downloads' + ) + const { archiveProjectFileItems } = await import('@/lib/projects/files/application/lifecycle') + const f = await fixture() + const other = await fixture() + const root = await createProjectFileFolder.execute({ + principal: f.principal, + input: { projectId: f.projectId, name: 'Architecture' }, + }) + const nested = await createProjectFileFolder.execute({ + principal: f.principal, + input: { projectId: f.projectId, parentId: root.folder.id, name: 'Diagrams' }, + }) + const source = await createProjectFile.execute({ + principal: f.principal, + input: { ...createInput(f.projectId, 'nested bytes'), folderId: nested.folder.id }, + }) + const archived = await createProjectFile.execute({ + principal: f.principal, + input: { ...createInput(f.projectId, 'retired bytes'), folderId: root.folder.id }, + }) + await archiveProjectFileItems.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileIds: [archived.file.id] }, + }) + const foreign = await createProjectFile.execute({ + principal: other.principal, + input: createInput(other.projectId, 'foreign bytes'), + }) + const foreignFolder = await createProjectFileFolder.execute({ + principal: other.principal, + input: { projectId: other.projectId, name: 'Architecture' }, + }) + const input = { + projectId: f.projectId, + fileIds: [source.file.id, source.file.id], + folderIds: [root.folder.id, nested.folder.id], + } + const result = await downloadProjectFileItems.execute({ principal: f.principal, input }) + const zip = await JSZip.loadAsync(result.buffer) + const entries = Object.values(zip.files).filter((entry) => !entry.dir) + expect(entries.map((entry) => entry.name)).toEqual(['Architecture/Diagrams/architecture.md']) + expect(await entries[0].async('string')).toBe('nested bytes') + expect(result.fileCount).toBe(1) + expect(result.secretProvenance).toEqual({ status: 'exact', entries: [] }) + for (const selection of [ + { ...input, fileIds: [foreign.file.id] }, + { ...input, folderIds: [foreignFolder.folder.id] }, + ]) { + await expect( + downloadProjectFileItems.execute({ principal: f.principal, input: selection }) + ).rejects.toMatchObject({ code: 'not_found' }) + } + } + ) + + check( + 'Project recursive download enforces expanded file counts and actual aggregate bytes despite understated metadata', + async () => { + const { downloadProjectFileItems } = await import( + '@/lib/projects/files/application/downloads' + ) + const f = await fixture() + const fileIds: string[] = [] + for (let index = 0; index < 3; index++) { + const result = await createProjectFile.execute({ + principal: f.principal, + input: { + ...createInput(f.projectId, 'x'), + name: `large-${index}.bin`, + contentType: 'application/octet-stream', + }, + }) + await truncate(join(localStorageRoot, result.file.key), 90 * 1024 * 1024) + fileIds.push(result.file.id) + } + await expect( + downloadProjectFileItems.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileIds, folderIds: [] }, + }) + ).rejects.toMatchObject({ code: 'payload_too_large' }) + expect(await ledger(f.organizationId)).toBe(3) + const [template] = await rows(f.projectId) + if (!template) throw new Error('Download fixture metadata is missing') + const selected = Array.from({ length: 101 }, (_, index) => ({ + ...template, + id: generateId(), + key: `project/${f.projectId}/count-${index}`, + originalName: `count-${index}.bin`, + })) + await db.insert(workspaceFiles).values(selected) + await expect( + downloadProjectFileItems.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileIds: selected.map((file) => file.id), + folderIds: [], + }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + } + ) + + check( + 'Project recursive download rechecks source, descendant membership, and current access after object IO', + async () => { + const { downloadProjectFileItems } = await import( + '@/lib/projects/files/application/downloads' + ) + for (const change of ['source', 'selection', 'access'] as const) { + const f = await fixture() + const directory = await createProjectFileFolder.execute({ + principal: f.principal, + input: { projectId: f.projectId, name: 'Selected' }, + }) + const source = await createProjectFile.execute({ + principal: f.principal, + input: { ...createInput(f.projectId, 'before IO'), folderId: directory.folder.id }, + }) + const download = storage.downloadFile + let changed = false + const interception = vi + .spyOn(storage, 'downloadFile') + .mockImplementation(async (options) => { + const buffer = await download(options) + if (options.key === source.file.key && !changed) { + changed = true + if (change === 'source') { + await updateProjectFileContent.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: source.file.id, + content: 'after IO', + encoding: 'utf-8', + }, + }) + } else if (change === 'selection') { + await createProjectFile.execute({ + principal: f.principal, + input: { + ...createInput(f.projectId, 'new descendant'), + name: 'later.md', + folderId: directory.folder.id, + }, + }) + } else { + await db.delete(permissions).where(eq(permissions.userId, f.editorId)) + } + } + return buffer + }) + try { + await expect( + downloadProjectFileItems.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileIds: [], folderIds: [directory.folder.id] }, + }) + ).rejects.toMatchObject({ code: change === 'access' ? 'not_found' : 'conflict' }) + } finally { + interception.mockRestore() + } + } + } + ) + + check( + 'Project recursive download aggregates bulk-download policy across accessible environments while retaining single-file reads', + async () => { + const { downloadProjectFileItems } = await import( + '@/lib/projects/files/application/downloads' + ) + const f = await fixture() + const directory = await createProjectFileFolder.execute({ + principal: f.principal, + input: { projectId: f.projectId, name: 'Selected' }, + }) + const source = await createProjectFile.execute({ + principal: f.principal, + input: { ...createInput(f.projectId), folderId: directory.folder.id }, + }) + const secondId = generateId() + await insertWorkspaceFixture(db, { + id: secondId, + ownerId: f.ownerId, + billedAccountUserId: f.ownerId, + organizationId: f.organizationId, + workspaceMode: 'organization', + name: 'Restricted downloads', + forkedFromWorkspaceId: f.workspaceId, + }) + try { + await db.insert(subscription).values({ + id: generateId(), + plan: 'enterprise', + referenceId: f.organizationId, + status: 'active', + metadata: {}, + }) + const groupId = generateId() + await db.insert(permissionGroup).values({ + id: groupId, + organizationId: f.organizationId, + createdBy: f.ownerId, + name: 'Restricted downloads', + membershipMode: 'inherit', + config: { disableBulkFileDownload: true }, + }) + await db.insert(permissionGroupWorkspace).values({ + id: generateId(), + permissionGroupId: groupId, + workspaceId: secondId, + organizationId: f.organizationId, + }) + const input = { projectId: f.projectId, fileIds: [], folderIds: [directory.folder.id] } + await downloadProjectFileItems.execute({ principal: f.principal, input }) + await db.insert(permissions).values({ + id: generateId(), + userId: f.editorId, + entityType: 'workspace', + entityId: secondId, + permissionType: 'read', + }) + await expect( + downloadProjectFileItems.execute({ principal: f.principal, input }) + ).rejects.toMatchObject({ detailCode: 'PERMISSION_GROUP_CAPABILITY_BLOCKED' }) + const single = await downloadProjectFileItems.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileIds: [source.file.id], folderIds: [] }, + }) + expect(single.fileCount).toBe(1) + } finally { + await deleteWorkspaceFixture(db, eq(workspace.id, secondId)) + } + } + ) + + check( + 'Project Markdown snapshot export packages only same-owner images, preserves source fidelity, and delivers joined provenance without persisting edits', + async () => { + const { exportProjectFileSnapshot } = await import( + '@/lib/projects/files/application/downloads' + ) + const f = await fixture() + const other = await fixture() + const secret = { + status: 'exact' as const, + entries: [ + { + encryptedValue: 'asset-fixture', + sourceUserId: f.ownerId, + sourceWorkspaceId: f.workspaceId, + }, + ], + } + const image = await createProjectFile.execute({ + principal: f.principal, + input: { + ...createInput(f.projectId, 'same-owner image bytes'), + name: 'diagram.png', + contentType: 'image/png', + secretProvenance: secret, + }, + }) + const foreign = await createProjectFile.execute({ + principal: other.principal, + input: { + ...createInput(other.projectId, 'foreign image bytes'), + name: 'secret.png', + contentType: 'image/png', + }, + }) + const source = await createProjectFile.execute({ + principal: f.principal, + input: createInput(f.projectId, 'durable draft'), + }) + const headBefore = await db + .select() + .from(workspaceFiles) + .where(eq(workspaceFiles.id, source.file.id)) + const historyBefore = await db + .select() + .from(workspaceFileVersion) + .where(eq(workspaceFileVersion.fileId, source.file.id)) + const visible = `---\ntitle: Architecture\n---\n\n# Unsaved snapshot\n\n![diagram](sim:file/${image.file.id}?project=${f.projectId})\n\n![foreign](/api/projects/${other.projectId}/files/${foreign.file.id}/content)\n\n\`sim:file/${image.file.id}\`\n` + let delivered: unknown + const exported = await observeWorkspaceFileDelivery( + async (source) => { + delivered = source + }, + () => + exportProjectFileSnapshot.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: source.file.id, content: visible }, + }) + ) + const zip = await JSZip.loadAsync(exported.buffer) + expect( + Object.values(zip.files) + .filter((entry) => !entry.dir) + .map((entry) => entry.name) + ).toEqual(['architecture.md', 'assets/diagram.png']) + expect(await zip.file('assets/diagram.png')?.async('string')).toBe('same-owner image bytes') + expect(await zip.file('architecture.md')?.async('string')).toBe( + visible.replace(`sim:file/${image.file.id}?project=${f.projectId}`, './assets/diagram.png') + ) + expect(exported.secretProvenance).toEqual(secret) + expect(delivered).toEqual(secret) + expect(await readFile(join(localStorageRoot, source.file.key), 'utf8')).toBe('durable draft') + const history = await db + .select() + .from(workspaceFileVersion) + .where(eq(workspaceFileVersion.fileId, source.file.id)) + expect(history).toEqual(historyBefore) + const headAfter = await db + .select() + .from(workspaceFiles) + .where(eq(workspaceFiles.id, source.file.id)) + expect(headAfter).toEqual(headBefore) + + await updateProjectFileContent.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: source.file.id, + content: 'unknown source', + encoding: 'utf-8', + secretProvenance: { status: 'unknown' }, + }, + }) + const unknown = await exportProjectFileSnapshot.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: source.file.id, content: visible }, + }) + expect(unknown.secretProvenance.status).toBe('unknown') + } + ) + + check( + 'Project Markdown snapshot export never collapses admitted control-bearing names into ZIP traversal segments', + async () => { + const { exportProjectFileSnapshot } = await import( + '@/lib/projects/files/application/downloads' + ) + const f = await fixture() + const image = await createProjectFile.execute({ + principal: f.principal, + input: { + ...createInput(f.projectId, 'bundled asset'), + name: '.\r.', + contentType: 'image/png', + }, + }) + const source = await createProjectFile.execute({ + principal: f.principal, + input: { ...createInput(f.projectId, 'durable draft'), name: '.\n.' }, + }) + const exported = await exportProjectFileSnapshot.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: source.file.id, + content: `![asset](sim:file/${image.file.id}?project=${f.projectId})`, + }, + }) + const zip = await JSZip.loadAsync(exported.buffer) + const entries = Object.values(zip.files).filter((entry) => !entry.dir) + expect(entries).toHaveLength(2) + for (const entry of entries) { + const rawPath = entry.unsafeOriginalName ?? entry.name + expect(rawPath).not.toMatch(/[\x00-\x1f\x7f]/) + expect( + rawPath.split('/').every((segment) => segment && segment !== '.' && segment !== '..') + ).toBe(true) + } + const asset = entries.find((entry) => entry.name.startsWith('assets/')) + expect(await asset?.async('string')).toBe('bundled asset') + } + ) + + check( + 'Project Markdown snapshot export refuses an asset revision changed after its actual bytes were read', + async () => { + const { exportProjectFileSnapshot } = await import( + '@/lib/projects/files/application/downloads' + ) + const f = await fixture() + const image = await createProjectFile.execute({ + principal: f.principal, + input: { + ...createInput(f.projectId, 'image before'), + name: 'diagram.png', + contentType: 'image/png', + }, + }) + const source = await createProjectFile.execute({ + principal: f.principal, + input: createInput(f.projectId), + }) + const download = storage.downloadFile + let changed = false + vi.spyOn(storage, 'downloadFile').mockImplementation(async (options) => { + const buffer = await download(options) + if (options.key === image.file.key && !changed) { + changed = true + await updateProjectFileContent.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: image.file.id, + content: 'image after', + encoding: 'utf-8', + }, + }) + } + return buffer + }) + await expect( + exportProjectFileSnapshot.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: source.file.id, + content: `![diagram](sim:file/${image.file.id}?project=${f.projectId})`, + }, + }) + ).rejects.toMatchObject({ code: 'conflict' }) + } + ) +}) + +describe('Project ZIP extraction against PostgreSQL and local storage', () => { + async function archive( + f: Awaited>, + entries: Record, + provenance?: WorkspaceFileSecretProvenance + ) { + const zip = new JSZip() + for (const [path, content] of Object.entries(entries)) zip.file(path, content) + const buffer = await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }) + return createProjectFile.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + name: 'bundle.zip', + contentType: 'application/zip', + content: buffer.toString('base64'), + encoding: 'base64', + secretProvenance: provenance, + }, + }) + } + + async function folders(projectId: string) { + return db.select().from(folder).where(eq(folder.projectId, projectId)) + } + + check( + 'Project ZIP extraction atomically creates nested bytes beside the archive and bills the canonical payer', + async () => { + const { extractProjectFile } = await import('@/lib/projects/files/application/extract') + const f = await fixture() + const parent = await createProjectFileFolder.execute({ + principal: f.principal, + input: { projectId: f.projectId, name: 'Resources' }, + }) + const source = await archive(f, { + 'docs/readme.txt': 'architecture', + 'images/icon.txt': 'diagram', + '__MACOSX/._readme.txt': 'noise', + }) + await db + .update(workspaceFiles) + .set({ folderId: parent.folder.id }) + .where(eq(workspaceFiles.id, source.file.id)) + await createProjectFileFolder.execute({ + principal: f.principal, + input: { projectId: f.projectId, name: 'bundle', parentId: parent.folder.id }, + }) + const before = await ledger(f.organizationId) + const result = await extractProjectFile.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: source.file.id }, + }) + expect(result).toMatchObject({ extractedCount: 2, skippedCount: 1 }) + expect(result.folderName).not.toBe('bundle') + const tree = await folders(f.projectId) + const root = tree.find((row) => row.id === result.folderId) + expect(root).toMatchObject({ + parentId: parent.folder.id, + userId: f.editorId, + projectId: f.projectId, + workspaceId: null, + }) + const children = (await rows(f.projectId)).filter((row) => row.id !== source.file.id) + expect(children).toHaveLength(2) + for (const row of children) { + expect(row.userId).toBe(f.editorId) + expect(row.workspaceId).toBeNull() + const directory = tree.find((candidate) => candidate.id === row.folderId) + expect(directory?.parentId).toBe(result.folderId) + expect(await readFile(join(localStorageRoot, row.key), 'utf8')).toBe( + row.originalName === 'readme.txt' ? 'architecture' : 'diagram' + ) + } + expect(await ledger(f.organizationId)).toBe( + (before ?? 0) + Buffer.byteLength('architecturediagram') + ) + expect(await storage.downloadFile({ key: source.file.key, context: 'project' })).toHaveLength( + source.file.size + ) + } + ) + + check( + 'Project ZIP extraction conceals a foreign archive and refuses a read-only destination without publishing children', + async () => { + const { extractProjectFile } = await import('@/lib/projects/files/application/extract') + const f = await fixture() + const other = await fixture() + const source = await archive(f, { 'one.txt': 'one' }) + await expect( + extractProjectFile.execute({ + principal: f.principal, + input: { + projectId: f.projectId, + fileId: (await archive(other, { 'secret.txt': 'secret' })).file.id, + }, + }) + ).rejects.toMatchObject({ code: 'not_found' }) + await db + .update(permissions) + .set({ permissionType: 'read' }) + .where(and(eq(permissions.userId, f.editorId), eq(permissions.entityId, f.workspaceId))) + await expect( + extractProjectFile.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: source.file.id }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + expect((await rows(f.projectId)).map((row) => row.id)).toEqual([source.file.id]) + expect(await folders(f.projectId)).toEqual([]) + expect(await keys(f.projectId)).toHaveLength(1) + expect(await ledger(f.organizationId)).toBe(source.file.size) + } + ) + + for (const change of ['permission', 'source'] as const) { + check( + `Project ZIP extraction rejects ${change} changes after staging and leaves no partial tree`, + async () => { + const { extractProjectFile } = await import('@/lib/projects/files/application/extract') + const f = await fixture() + const source = await archive(f, { 'first.txt': 'first', 'nested/second.txt': 'second' }) + const upload = storage.uploadFile + let changed = false + vi.spyOn(storage, 'uploadFile').mockImplementation(async (options) => { + const result = await upload(options) + if (!changed) { + changed = true + if (change === 'permission') + await db + .update(permissions) + .set({ permissionType: 'read' }) + .where( + and(eq(permissions.userId, f.editorId), eq(permissions.entityId, f.workspaceId)) + ) + else + await db + .update(workspaceFiles) + .set({ originalName: 'renamed.zip', updatedAt: new Date() }) + .where(eq(workspaceFiles.id, source.file.id)) + } + return result + }) + await expect( + extractProjectFile.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: source.file.id }, + }) + ).rejects.toMatchObject({ code: change === 'permission' ? 'forbidden' : 'conflict' }) + expect((await rows(f.projectId)).map((row) => row.id)).toEqual([source.file.id]) + expect(await folders(f.projectId)).toEqual([]) + expect(await keys(f.projectId)).toHaveLength(1) + expect(await ledger(f.organizationId)).toBe(source.file.size) + } + ) + } + + check( + 'Project ZIP extraction blocks concurrent work and fences a replaced lease before metadata commit', + async () => { + const { extractProjectFile } = await import('@/lib/projects/files/application/extract') + const f = await fixture() + const source = await archive(f, { 'one.txt': 'one' }) + const uploaded = createDeferred() + const resume = createDeferred() + const upload = storage.uploadFile + vi.spyOn(storage, 'uploadFile').mockImplementationOnce(async (options) => { + const result = await upload(options) + uploaded.resolve() + await resume.promise + return result + }) + const args = { + principal: f.principal, + input: { projectId: f.projectId, fileId: source.file.id }, + } + const first = extractProjectFile.execute(args) + const firstResult = first.catch((error: unknown) => error) + await uploaded.promise + try { + await expect(extractProjectFile.execute(args)).rejects.toMatchObject({ code: 'conflict' }) + const replaced = await db + .update(idempotencyKey) + .set({ + result: { + status: 'in-progress', + claimToken: generateId(), + inProgressExpiresAt: Date.now() + 60_000, + }, + }) + .where(sql`${idempotencyKey.key} LIKE ${`%${f.projectId}%${source.file.id}%`}`) + .returning({ key: idempotencyKey.key }) + expect(replaced).toHaveLength(1) + } finally { + resume.resolve() + } + expect(await firstResult).toMatchObject({ code: 'conflict' }) + expect((await rows(f.projectId)).map((row) => row.id)).toEqual([source.file.id]) + expect(await folders(f.projectId)).toEqual([]) + expect(await keys(f.projectId)).toHaveLength(1) + expect(await ledger(f.organizationId)).toBe(source.file.size) + } + ) + + check( + 'Project ZIP extraction quota failure rolls back every child and preserves the source archive', + async () => { + const { extractProjectFile } = await import('@/lib/projects/files/application/extract') + const f = await fixture() + const source = await archive(f, { 'first.txt': 'first', 'nested/second.txt': 'second' }) + vi.stubEnv('FREE_STORAGE_LIMIT_GB', '1') + await db + .update(organization) + .set({ storageUsedBytes: 1024 ** 3 }) + .where(eq(organization.id, f.organizationId)) + await expect( + extractProjectFile.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: source.file.id }, + }) + ).rejects.toMatchObject({ name: 'StorageLimitExceededError' }) + expect((await rows(f.projectId)).map((row) => row.id)).toEqual([source.file.id]) + expect(await folders(f.projectId)).toEqual([]) + expect(await keys(f.projectId)).toHaveLength(1) + expect(await ledger(f.organizationId)).toBe(1024 ** 3) + } + ) + + check( + 'Project ZIP extraction rejects over-deep member paths before any destination mutation', + async () => { + const { extractProjectFile } = await import('@/lib/projects/files/application/extract') + const { MAX_FOLDER_PATH_SEGMENTS } = await import('@/lib/folders/paths') + const f = await fixture() + const source = await archive(f, { + [`${Array.from({ length: MAX_FOLDER_PATH_SEGMENTS + 1 }, () => 'deep').join('/')}/file.txt`]: + 'too deep', + }) + await expect( + extractProjectFile.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: source.file.id }, + }) + ).rejects.toMatchObject({ reason: 'invalid' }) + expect((await rows(f.projectId)).map((row) => row.id)).toEqual([source.file.id]) + expect(await folders(f.projectId)).toEqual([]) + expect(await keys(f.projectId)).toHaveLength(1) + expect(await ledger(f.organizationId)).toBe(source.file.size) + } + ) + + check( + 'Project ZIP extraction preserves conservative provenance through decompression', + async () => { + const { extractProjectFile } = await import('@/lib/projects/files/application/extract') + const classifications = [ + { status: 'exact' as const, entries: [] }, + { status: 'unknown' as const }, + { status: 'unrecorded' as const }, + { + status: 'exact' as const, + entries: [ + { + encryptedValue: 'archive-fixture', + sourceUserId: 'origin', + }, + ], + }, + ] + for (const classification of classifications) { + const f = await fixture() + const source = await archive(f, { 'data.txt': 'extracted' }, classification) + await extractProjectFile.execute({ + principal: f.principal, + input: { projectId: f.projectId, fileId: source.file.id }, + }) + const [child] = (await rows(f.projectId)).filter((row) => row.id !== source.file.id) + if (!child) throw new Error('Extraction did not publish a child') + const [provenance] = await db + .select() + .from(workspaceFileSecretProvenance) + .where(eq(workspaceFileSecretProvenance.fileId, child.id)) + const expected = + classification.status === 'exact' + ? classification.entries.length + ? 'unknown' + : 'exact' + : classification.status + expect(provenance.status).toBe(expected) + expect(provenance.entries).toEqual([]) + expect(await readFile(join(localStorageRoot, child.key), 'utf8')).toBe('extracted') + } + } + ) +}) + +afterAll(async () => { + vi.restoreAllMocks() + for (const f of fixtures) { + await db.delete(idempotencyKey).where(sql`${idempotencyKey.key} LIKE ${`%${f.projectId}%`}`) + await db + .delete(outboxEvent) + .where( + sql`${outboxEvent.payload}::jsonb ->> 'key' LIKE ${`project/${f.projectId}/%`} OR ${outboxEvent.payload}::jsonb -> 'owner' ->> 'entityId' = ${f.projectId}` + ) + await db.delete(workspaceFiles).where(eq(workspaceFiles.projectId, f.projectId)) + await db.delete(folder).where(eq(folder.projectId, f.projectId)) + await deleteWorkspaceFixture(db, eq(workspace.id, f.workspaceId)) + await db.delete(subscription).where(eq(subscription.referenceId, f.organizationId)) + await db.delete(organization).where(eq(organization.id, f.organizationId)) + await db.delete(user).where(inArray(user.id, [f.ownerId, f.editorId])) + } + await rm(localStorageRoot, { recursive: true, force: true }) + const report = + process.env.PROJECT_FILE_CONTENT_REPORT_PATH ?? 'test-results/project-file-content.json' + await mkdir(dirname(report), { recursive: true }) + await writeFile(report, JSON.stringify({ checks }, null, 2)) + await db.$client.end() +}) diff --git a/apps/sim/lib/projects/files/__integration__/documents.integration.ts b/apps/sim/lib/projects/files/__integration__/documents.integration.ts new file mode 100644 index 00000000000..1a941939feb --- /dev/null +++ b/apps/sim/lib/projects/files/__integration__/documents.integration.ts @@ -0,0 +1,477 @@ +import { mkdtempSync } from 'node:fs' +import { mkdir, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import type { ResourceDelegatedPrincipal } from '@sim/auth/principal' +import { db } from '@sim/db' +import { + member, + organization, + outboxEvent, + permissions, + projectWorkspace, + user, + workspace, + workspaceFiles, + workspaceFileVersion, +} from '@sim/db/schema' +import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures' +import { FILE_DOC_SEED } from '@sim/realtime-protocol/file-doc' +import { createSessionPrincipal } from '@sim/testing/factories/principal.factory' +import { featureFlagsMock, featureFlagsMockFns } from '@sim/testing/mocks/feature-flags.mock' +import { setUploadDirServer, uploadsSetupMock } from '@sim/testing/mocks/uploads-setup.mock' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { eq, inArray, sql } from 'drizzle-orm' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' +import * as Y from 'yjs' + +vi.mock('@/lib/uploads/core/setup.server', () => uploadsSetupMock) + +import { applyMarkdownToYDoc } from '@/lib/collab-doc/converter' +import { + createProjectFile, + readProjectFileContent, + updateProjectFileContent, +} from '@/lib/projects/files/application/content' +import { rotateProjectFileDocInTx } from '@/lib/projects/files/application/document-lifecycle' +import { + buildProjectFileDocSeed, + getProjectFileDocAccess, + persistProjectFileDoc, +} from '@/lib/projects/files/application/documents' + +vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock) + +const uploadRoot = mkdtempSync(join(tmpdir(), 'sim-project-documents-')) +setUploadDirServer(uploadRoot) +const fixtures: { + ownerId: string + editorId: string + organizationId: string + workspaceId: string + projectId: string +}[] = [] +const lifecycleTargets: string[] = [] +const checks: { name: string; status: 'passed' | 'failed'; durationMs: number; error?: string }[] = + [] + +beforeEach(() => { + featureFlagsMockFns.mockIsFeatureEnabled.mockImplementation(async (flag) => flag === 'projects') + vi.stubEnv('PROJECT_FILES_ENABLED', 'true') + vi.stubEnv('FREE_STORAGE_LIMIT_GB', '') +}) + +function check(name: string, run: () => Promise) { + it(name, async () => { + const started = performance.now() + try { + await run() + checks.push({ name, status: 'passed', durationMs: performance.now() - started }) + } catch (error) { + checks.push({ + name, + status: 'failed', + durationMs: performance.now() - started, + error: getErrorMessage(error), + }) + throw error + } + }) +} + +async function fixture(permissionType: 'read' | 'write' = 'write') { + const ownerId = generateId() + const editorId = generateId() + const organizationId = generateId() + const workspaceId = generateId() + await db.insert(user).values( + [ownerId, editorId].map((id) => ({ + id, + email: `${id}@documents.invalid`, + name: 'Document fixture', + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + })) + ) + await db + .insert(organization) + .values({ id: organizationId, name: 'Documents', slug: organizationId, createdAt: new Date() }) + await db.insert(member).values( + [ownerId, editorId].map((userId) => ({ + id: generateId(), + organizationId, + userId, + role: userId === ownerId ? 'owner' : 'member', + createdAt: new Date(), + })) + ) + await insertWorkspaceFixture(db, { + id: workspaceId, + ownerId, + billedAccountUserId: ownerId, + organizationId, + workspaceMode: 'organization', + name: 'Document environment', + }) + const [binding] = await db + .select() + .from(projectWorkspace) + .where(eq(projectWorkspace.workspaceId, workspaceId)) + if (!binding) throw new Error('Project binding is missing') + await db.insert(permissions).values({ + id: generateId(), + userId: editorId, + entityType: 'workspace', + entityId: workspaceId, + permissionType, + }) + const ids = { ownerId, editorId, organizationId, workspaceId, projectId: binding.projectId } + fixtures.push(ids) + const owner = createSessionPrincipal({ userId: ownerId }) + const created = await createProjectFile.execute({ + principal: owner, + input: { + projectId: ids.projectId, + name: 'architecture.md', + contentType: 'text/markdown', + content: '# Original architecture\n', + encoding: 'utf-8', + }, + }) + const target = { projectId: ids.projectId, fileId: created.file.id } + const principal: ResourceDelegatedPrincipal = { + kind: 'resource_delegated', + serviceId: 'realtime', + subjectUserId: editorId, + audience: 'sim:project-files', + delegationId: generateId(), + issuedAt: new Date(), + expiresAt: new Date(Date.now() + 60_000), + scope: { + kind: 'entity', + entityType: 'project', + entityId: ids.projectId, + fileId: created.file.id, + }, + invocation: { kind: 'realtime', connectionId: `socket-${generateId()}` }, + } + return { ...ids, owner, target, principal } +} + +function edit(update: Uint8Array, markdown: string, replaceIdentity = false) { + const doc = new Y.Doc() + try { + Y.applyUpdate(doc, update) + applyMarkdownToYDoc(doc, markdown) + if (replaceIdentity) + doc.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.docIdKey, generateId()) + return Y.encodeStateAsUpdate(doc) + } finally { + doc.destroy() + } +} + +function documentId(update: Uint8Array) { + const doc = new Y.Doc() + try { + Y.applyUpdate(doc, update) + return doc.getMap(FILE_DOC_SEED.configMap).get(FILE_DOC_SEED.docIdKey) + } finally { + doc.destroy() + } +} + +describe('Project collaborative documents against current policy and durable storage', () => { + check('readers receive a stable seed but cannot publish durable document edits', async () => { + const f = await fixture('read') + expect( + await getProjectFileDocAccess.execute({ principal: f.principal, input: f.target }) + ).toMatchObject({ canRead: true, canWrite: false }) + const seed = await buildProjectFileDocSeed.execute({ principal: f.principal, input: f.target }) + const again = await buildProjectFileDocSeed.execute({ principal: f.principal, input: f.target }) + expect(typeof documentId(seed.update)).toBe('string') + expect(documentId(again.update)).toBe(documentId(seed.update)) + await expect( + persistProjectFileDoc.execute({ + principal: f.principal, + input: { + ...f.target, + docState: edit(seed.update, '# Reader edit'), + expectedVersion: seed.version, + }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + const stored = await readProjectFileContent.execute({ principal: f.owner, input: f.target }) + expect(stored.content.toString()).toBe('# Original architecture\n') + }) + + check( + 'accepted snapshots preserve document identity and attribute versions to the editor', + async () => { + const f = await fixture() + const seed = await buildProjectFileDocSeed.execute({ + principal: f.principal, + input: f.target, + }) + const result = await persistProjectFileDoc.execute({ + principal: f.principal, + input: { + ...f.target, + docState: edit(seed.update, '# Shared architecture'), + expectedVersion: seed.version, + }, + }) + expect(result.status).toBe('persisted') + const stored = await readProjectFileContent.execute({ principal: f.owner, input: f.target }) + expect(stored.content.toString()).toContain('# Shared architecture') + const again = await buildProjectFileDocSeed.execute({ + principal: f.principal, + input: f.target, + }) + expect(documentId(again.update)).toBe(documentId(seed.update)) + const versions = await db + .select() + .from(workspaceFileVersion) + .where(eq(workspaceFileVersion.fileId, f.target.fileId)) + expect( + versions.some( + (version) => version.source === 'collab' && version.authorUserIds.includes(f.editorId) + ) + ).toBe(true) + const [billing] = await db + .select({ bytes: organization.storageUsedBytes }) + .from(organization) + .where(eq(organization.id, f.organizationId)) + expect(billing?.bytes).toBe(stored.content.length) + } + ) + + check( + 'lifecycle identity rotation is atomic and old snapshots cannot return after restore', + async () => { + const f = await fixture() + lifecycleTargets.push(f.target.fileId) + const initial = await buildProjectFileDocSeed.execute({ + principal: f.principal, + input: f.target, + }) + const original = new Y.Doc() + Y.applyUpdate(original, initial.update) + const originalId = original.getMap(FILE_DOC_SEED.configMap).get(FILE_DOC_SEED.docIdKey) + original.destroy() + const archive = await db.transaction(async (tx) => { + await tx + .select({ id: workspaceFiles.id }) + .from(workspaceFiles) + .where(eq(workspaceFiles.id, f.target.fileId)) + .for('update') + return rotateProjectFileDocInTx(tx, f.target) + }) + expect(archive).not.toBeNull() + const afterArchive = await getProjectFileDocAccess.execute({ + principal: f.principal, + input: f.target, + }) + expect(afterArchive.docId).not.toBe(originalId) + await expect( + db.transaction(async (tx) => { + await tx + .select({ id: workspaceFiles.id }) + .from(workspaceFiles) + .where(eq(workspaceFiles.id, f.target.fileId)) + .for('update') + await rotateProjectFileDocInTx(tx, f.target) + throw new Error('rollback-lifecycle') + }) + ).rejects.toThrow('rollback-lifecycle') + expect( + (await getProjectFileDocAccess.execute({ principal: f.principal, input: f.target })).docId + ).toBe(afterArchive.docId) + const restore = await db.transaction(async (tx) => { + await tx + .select({ id: workspaceFiles.id }) + .from(workspaceFiles) + .where(eq(workspaceFiles.id, f.target.fileId)) + .for('update') + return rotateProjectFileDocInTx(tx, f.target) + }) + if (!archive || !restore) throw new Error('Missing committed lifecycle events') + const rows = await db + .select() + .from(outboxEvent) + .where(sql`${outboxEvent.payload}->>'fileId' = ${f.target.fileId}`) + expect(rows).toHaveLength(2) + const current = await buildProjectFileDocSeed.execute({ + principal: f.principal, + input: f.target, + }) + expect( + await persistProjectFileDoc.execute({ + principal: f.principal, + input: { ...f.target, docState: initial.update, expectedVersion: initial.version }, + }) + ).toMatchObject({ status: 'conflict' }) + const fresh = new Y.Doc() + try { + Y.applyUpdate(fresh, current.update) + expect(fresh.getMap(FILE_DOC_SEED.configMap).get(FILE_DOC_SEED.docIdKey)).not.toBe( + afterArchive.docId + ) + expect(fresh.getMap(FILE_DOC_SEED.configMap).get(FILE_DOC_SEED.docIdKey)).not.toBe( + originalId + ) + } finally { + fresh.destroy() + } + expect( + ( + await readProjectFileContent.execute({ principal: f.owner, input: f.target }) + ).content.toString() + ).toContain('Original architecture') + } + ) + + check( + 'binary files remain readable as files but cannot become collaborative documents', + async () => { + const f = await fixture() + const binary = Buffer.from('%PDF-1.7\n\x00\xff') + const created = await createProjectFile.execute({ + principal: f.owner, + input: { + projectId: f.projectId, + name: 'architecture.pdf', + contentType: 'application/pdf', + content: binary.toString('base64'), + encoding: 'base64', + }, + }) + const target = { projectId: f.projectId, fileId: created.file.id } + const principal: ResourceDelegatedPrincipal = { + ...f.principal, + scope: { + kind: 'entity', + entityType: 'project', + entityId: f.projectId, + fileId: created.file.id, + }, + } + await expect( + getProjectFileDocAccess.execute({ principal, input: target }) + ).rejects.toMatchObject({ code: 'validation' }) + await expect( + buildProjectFileDocSeed.execute({ principal, input: target }) + ).rejects.toMatchObject({ code: 'validation' }) + const seed = await buildProjectFileDocSeed.execute({ + principal: f.principal, + input: f.target, + }) + await expect( + persistProjectFileDoc.execute({ + principal, + input: { ...target, docState: seed.update, expectedVersion: Date.now() }, + }) + ).rejects.toMatchObject({ code: 'validation' }) + expect( + ( + await readProjectFileContent.execute({ principal: f.owner, input: target }) + ).content.equals(binary) + ).toBe(true) + } + ) + + check('current write revocation leaves readers live but rejects a dirty flush', async () => { + const f = await fixture() + const seed = await buildProjectFileDocSeed.execute({ principal: f.principal, input: f.target }) + await db + .update(permissions) + .set({ permissionType: 'read' }) + .where(eq(permissions.userId, f.editorId)) + expect( + await getProjectFileDocAccess.execute({ principal: f.principal, input: f.target }) + ).toMatchObject({ canRead: true, canWrite: false }) + await expect( + persistProjectFileDoc.execute({ + principal: f.principal, + input: { + ...f.target, + docState: edit(seed.update, '# Revoked edit'), + expectedVersion: seed.version, + }, + }) + ).rejects.toMatchObject({ code: 'forbidden' }) + await db.delete(permissions).where(eq(permissions.userId, f.editorId)) + await expect( + buildProjectFileDocSeed.execute({ principal: f.principal, input: f.target }) + ).rejects.toMatchObject({ code: 'not_found' }) + }) + + check('file-bound realtime grants never read or mutate another Project or file', async () => { + const f = await fixture() + for (const target of [ + { ...f.target, projectId: generateId() }, + { ...f.target, fileId: generateId() }, + ]) { + await expect( + buildProjectFileDocSeed.execute({ principal: f.principal, input: target }) + ).rejects.toMatchObject({ code: 'forbidden' }) + } + }) + + check( + 'a replacement generation and an unsynchronized durable write cannot be overwritten', + async () => { + const f = await fixture() + const seed = await buildProjectFileDocSeed.execute({ + principal: f.principal, + input: f.target, + }) + expect( + await persistProjectFileDoc.execute({ + principal: f.principal, + input: { + ...f.target, + docState: edit(seed.update, '# Wrong generation', true), + expectedVersion: seed.version, + }, + }) + ).toEqual({ status: 'conflict' }) + await updateProjectFileContent.execute({ + principal: f.owner, + input: { ...f.target, content: '# New durable architecture', encoding: 'utf-8' }, + }) + expect( + await persistProjectFileDoc.execute({ + principal: f.principal, + input: { + ...f.target, + docState: edit(seed.update, '# Stale client architecture'), + expectedVersion: seed.version, + }, + }) + ).toEqual({ status: 'conflict' }) + const stored = await readProjectFileContent.execute({ principal: f.owner, input: f.target }) + expect(stored.content.toString()).toBe('# New durable architecture') + } + ) +}) + +afterAll(async () => { + const reportPath = + process.env.PROJECT_FILE_DOCUMENTS_REPORT_PATH ?? + resolve('test-results/project-file-documents.json') + await mkdir(dirname(reportPath), { recursive: true }) + await writeFile(reportPath, JSON.stringify({ checks }, null, 2)) + for (const fileId of lifecycleTargets) { + await db.delete(outboxEvent).where(sql`${outboxEvent.payload}->>'fileId' = ${fileId}`) + } + for (const f of fixtures) { + await db.delete(workspaceFiles).where(eq(workspaceFiles.projectId, f.projectId)) + await deleteWorkspaceFixture(db, eq(workspace.id, f.workspaceId)) + await db.delete(organization).where(eq(organization.id, f.organizationId)) + await db.delete(user).where(inArray(user.id, [f.ownerId, f.editorId])) + } + await rm(uploadRoot, { recursive: true, force: true }) +}) diff --git a/apps/sim/lib/projects/files/__integration__/private-purge.integration.ts b/apps/sim/lib/projects/files/__integration__/private-purge.integration.ts index 5744ec07811..75deb46cf9a 100644 --- a/apps/sim/lib/projects/files/__integration__/private-purge.integration.ts +++ b/apps/sim/lib/projects/files/__integration__/private-purge.integration.ts @@ -21,27 +21,32 @@ import { } from '@sim/db/schema' import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures' import { sha256Hex } from '@sim/security/hash' +import { createSessionPrincipal } from '@sim/testing/factories/principal.factory' import { createDeferred } from '@sim/testing/helpers/deferred' +import { featureFlagsMock, featureFlagsMockFns } from '@sim/testing/mocks/feature-flags.mock' import { setUploadDirServer, uploadsSetupMock } from '@sim/testing/mocks/uploads-setup.mock' import { getErrorMessage } from '@sim/utils/errors' import { sleep } from '@sim/utils/helpers' import { generateId } from '@sim/utils/id' import { and, eq, inArray, or, sql } from 'drizzle-orm' import { NextRequest } from 'next/server' -import { afterAll, describe, expect, it, vi } from 'vitest' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@/lib/uploads/core/setup.server', () => uploadsSetupMock) import { resolveProjectStorageBillingContext } from '@/lib/billing/storage/context' -import { prepareProjectStorageMutationInTx } from '@/lib/billing/storage/tracking' +import { prepareFileStorageMutationInTx } from '@/lib/billing/storage/tracking' import { processOutboxEventById } from '@/lib/core/outbox/service' import { prepareProjectsForAccountDeletion } from '@/lib/projects/account-deletion' +import { createProjectFileUploadSession } from '@/lib/projects/files/application/uploads' import { workspaceFileStorageCleanupOutboxHandlers } from '@/lib/uploads/contexts/workspace/workspace-file-storage-cleanup-outbox' import { UPLOAD_URL_TTL_MS } from '@/lib/uploads/upload-session/provider' import { PROJECT_FILE_UPLOAD_BINDING_KEY } from '@/lib/uploads/upload-session/types' import { deleteUserAccount } from '@/lib/users/account-deletion' import { PUT as putUploadBytes } from '@/app/api/v2/uploads/[uploadId]/route' +vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock) + const storageRoot = mkdtempSync(join(tmpdir(), 'sim-project-purge-')) setUploadDirServer(storageRoot) const fixtures: { userId: string; projectId: string; workspaceId: string; fileIds: string[] }[] = [] @@ -66,6 +71,11 @@ function check(name: string, run: () => Promise) { }) } +beforeEach(() => { + featureFlagsMockFns.mockIsFeatureEnabled.mockImplementation(async (flag) => flag === 'projects') + vi.stubEnv('PROJECT_FILES_ENABLED', 'true') +}) + async function seedUpload(f: { userId: string; projectId: string }, fileName: string) { const id = generateId() const uploadToken = generateId() @@ -316,7 +326,7 @@ describe('Private Project teardown and durable object cleanup', () => { { projectId: f.projectId, ownerId: f.userId, organizationId: null }, tx ) - const accounting = await prepareProjectStorageMutationInTx(tx, context) + const accounting = await prepareFileStorageMutationInTx(tx, context) await tx .update(workspaceFiles) .set({ sizeBytes: 10 }) @@ -400,7 +410,17 @@ describe('Private Project teardown and durable object cleanup', () => { async () => { const f = await fixture() const live = await fixture() - const upload = await seedUpload(f, 'pending.bin') + const principal = createSessionPrincipal({ userId: f.userId, sessionId: generateId() }) + const upload = await createProjectFileUploadSession.execute({ + principal, + input: { + projectId: f.projectId, + fileName: 'pending.bin', + contentType: 'application/octet-stream', + fileSize: 4, + localOrigin: 'http://localhost:3000', + }, + }) const derived = `project/${f.projectId}/compiled/derived.pdf` await mkdir(dirname(join(storageRoot, derived)), { recursive: true }) await writeFile(join(storageRoot, derived), 'derived') @@ -493,7 +513,16 @@ describe('Private Project teardown and durable object cleanup', () => { 'a local stream finishing after retirement queues cleanup instead of leaving a late object', async () => { const f = await fixture() - const created = await seedUpload(f, 'stream.bin') + const created = await createProjectFileUploadSession.execute({ + principal: createSessionPrincipal({ userId: f.userId, sessionId: generateId() }), + input: { + projectId: f.projectId, + fileName: 'stream.bin', + contentType: 'application/octet-stream', + fileSize: 4, + localOrigin: 'http://localhost:3000', + }, + }) const entered = createDeferred() const release = createDeferred() const body = new ReadableStream( diff --git a/apps/sim/lib/projects/files/__integration__/retention.integration.ts b/apps/sim/lib/projects/files/__integration__/retention.integration.ts index a978d5c6296..c0d66f20127 100644 --- a/apps/sim/lib/projects/files/__integration__/retention.integration.ts +++ b/apps/sim/lib/projects/files/__integration__/retention.integration.ts @@ -12,6 +12,7 @@ import { user, userStats, workspace, + workspaceFile, workspaceFiles, workspaceFileVersion, } from '@sim/db/schema' @@ -28,12 +29,14 @@ import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' vi.mock('@/lib/core/config/env-flags', () => envFlagsMock) -import { createSessionPrincipal } from '@sim/testing/factories/principal.factory' import { type CleanupJobPayload, runCleanupWithLimits } from '@/lib/billing/cleanup-dispatcher' import { changeWorkspaceStoragePayerInTx } from '@/lib/billing/storage/payer-transfer' import { createCleanupBudgets } from '@/lib/cleanup/limits' -import { authorizeProject } from '@/lib/projects/application/authorization' -import { projectOperations } from '@/lib/projects/application/operations' +import { beginFileArchiveCleanup, cleanupFileVersions } from '@/lib/file-retention' +import { loadProjectAccess } from '@/lib/projects/application/authorization' +import { processWorkspaceFileStorageCleanupsNow } from '@/lib/uploads/contexts/workspace/workspace-file-storage-cleanup-outbox' +import { deleteFile, downloadFile, uploadFile } from '@/lib/uploads/core/storage-service' +import type { FileOwner } from '@/lib/workspace-files/ownership' import { runCleanupFileVersions } from '@/background/cleanup-file-versions' import { runCleanupSoftDeletes } from '@/background/cleanup-soft-deletes' @@ -46,6 +49,7 @@ const fixtures: { workspaceId: string projectId: string }[] = [] +const storedKeys: string[] = [] const HOUR = 60 * 60 * 1000 const control = postgres(readTestDatabaseUrl(), { max: 2, onnotice: () => undefined }) @@ -207,19 +211,108 @@ async function versions(fileId: string) { .orderBy(workspaceFileVersion.version) } -async function cleanupEvents(projectId: string) { +async function cleanupEvents(entityId: string, kind = 'project') { return db - .select({ payload: outboxEvent.payload }) + .select({ id: outboxEvent.id, payload: outboxEvent.payload }) .from(outboxEvent) .where( and( eq(outboxEvent.eventType, 'workspace-file.storage.cleanup'), - sql`${outboxEvent.payload}->>'key' LIKE ${`project/${projectId}/%`}` + sql`${outboxEvent.payload}->>'key' LIKE ${`${kind}/${entityId}/%`}` ) ) } describe('Project file retention follows the current payer in PostgreSQL', () => { + check( + 'unsupported file owners reject the whole batch before any history or archive deletion', + async () => { + const f = await fixture() + const expired = await seedFile(f, 40 * 24, true) + const owners: FileOwner[] = [ + { entityType: 'project', entityId: f.projectId }, + { entityType: 'user', entityId: f.ownerId }, + ] + const options = { plan: 'free' as const, cutoff: new Date(), label: 'invalid-owner-batch' } + await expect(cleanupFileVersions(owners, options, 10)).rejects.toThrow( + 'File owner is unavailable' + ) + await expect(beginFileArchiveCleanup(owners, options)).rejects.toThrow( + 'File owner is unavailable' + ) + expect(await versions(expired.fileId)).toHaveLength(12) + expect( + await db + .select({ id: workspaceFiles.id }) + .from(workspaceFiles) + .where(eq(workspaceFiles.id, expired.fileId)) + ).toEqual([{ id: expired.fileId }]) + expect(await cleanupEvents(f.projectId)).toEqual([]) + const [usage] = await db + .select({ bytes: userStats.storageUsedBytes }) + .from(userStats) + .where(eq(userStats.userId, f.ownerId)) + expect(usage.bytes).toBe(15) + } + ) + + check( + 'a mixed queued batch retains each owner policy and leaves unselected workspace history alone', + async () => { + const f = await fixture('pro') + const other = await fixture() + const projectFile = await seedFile(f, 60 * 24) + const workspaceFile = await seedFile(f, 60 * 24, false, 'workspace') + const unrelated = await seedFile(other, 60 * 24, false, 'workspace') + await runCleanupFileVersions({ + ...payload(f.projectId), + projectIds: [f.projectId, f.projectId], + workspaceIds: [f.workspaceId, f.workspaceId], + }) + expect(await versions(projectFile.fileId)).toHaveLength(12) + expect(await versions(workspaceFile.fileId)).toHaveLength(10) + expect(await versions(unrelated.fileId)).toHaveLength(12) + expect(await cleanupEvents(f.projectId)).toEqual([]) + const events = await db + .select({ payload: outboxEvent.payload }) + .from(outboxEvent) + .where( + and( + eq(outboxEvent.eventType, 'workspace-file.storage.cleanup'), + sql`${outboxEvent.payload}->>'key' LIKE ${`workspace/${f.workspaceId}/%`}` + ) + ) + expect(events.map(({ payload }) => payload)).toEqual( + expect.arrayContaining(workspaceFile.keys.slice(0, 2).map((key) => ({ key }))) + ) + expect(events).toHaveLength(2) + } + ) + + check( + 'one version-deletion budget bounds a mixed owner batch without changing either current head', + async () => { + const f = await fixture() + const projectFile = await seedFile(f, 60 * 24) + const workspaceFile = await seedFile(f, 60 * 24, false, 'workspace') + const deleted = await cleanupFileVersions( + [ + { entityType: 'project', entityId: f.projectId }, + { entityType: 'workspace', entityId: f.workspaceId }, + ], + { plan: 'free', cutoff: new Date(), label: 'bounded-owner-batch' }, + 3 + ) + expect(deleted).toBe(3) + expect(await versions(projectFile.fileId)).toEqual( + Array.from({ length: 10 }, (_, index) => ({ version: index + 3 })) + ) + const remaining = await versions(workspaceFile.fileId) + expect(remaining).toHaveLength(11) + expect(remaining.at(-1)).toEqual({ version: 12 }) + } + ) + check( 'scheduled discovery includes shared Projects with their payer policy independently of environment chunks', async () => { @@ -243,18 +336,7 @@ describe('Project file retention follows the current payer in PostgreSQL', () => const acquired = createDeferred() const release = createDeferred() const authority = db.transaction(async (tx) => { - await authorizeProject( - tx, - createSessionPrincipal({ userId: f.ownerId }), - projectOperations.get, - { projectId: f.projectId }, - 'hold' - ) - await tx - .select({ id: workspace.id }) - .from(workspace) - .where(eq(workspace.id, f.workspaceId)) - .for('share') + await loadProjectAccess(tx, f.ownerId, { projectId: f.projectId }) const result = await tx.execute<{ pid: number }>(sql`SELECT pg_backend_pid() AS pid`) acquired.resolve(result[0].pid) await release.promise @@ -325,6 +407,211 @@ describe('Project file retention follows the current payer in PostgreSQL', () => } ) + check( + 'workspace archive release preserves a restored head and durably deletes only expired objects', + async () => { + const f = await fixture() + const expired = await seedFile(f, 40 * 24, true, 'workspace') + const restored = await seedFile(f, 40 * 24, true, 'workspace') + const legacyKey = `workspace/${f.workspaceId}/${generateId()}.md` + await db.insert(workspaceFile).values({ + id: generateId(), + workspaceId: f.workspaceId, + name: 'legacy.md', + key: legacyKey, + size: 5, + type: 'text/markdown', + uploadedBy: f.ownerId, + deletedAt: new Date(Date.now() - 40 * 24 * HOUR), + }) + await db + .update(workspace) + .set({ storageUsedBytes: 10 }) + .where(eq(workspace.id, f.workspaceId)) + for (const key of [expired.keys[11], restored.keys[11], legacyKey]) { + storedKeys.push(key) + await uploadFile({ + file: Buffer.from('draft'), + fileName: 'note.md', + contentType: 'text/markdown', + customKey: key, + preserveKey: true, + context: 'workspace', + persistMetadata: false, + }) + } + const cleanup = await beginFileArchiveCleanup( + [{ entityType: 'workspace', entityId: f.workspaceId }], + { plan: 'free', cutoff: new Date(), label: 'restore-selected-workspace-file' } + ) + await db + .update(workspaceFiles) + .set({ deletedAt: null }) + .where(eq(workspaceFiles.id, restored.fileId)) + const deletion = await cleanup.cleanupStorage() + expect(await deletion.deleteRows()).toBe(2) + const events = await cleanupEvents(f.workspaceId, 'workspace') + expect(new Set(events.map(({ payload }) => (payload as { key: string }).key))).toEqual( + new Set([...expired.keys, legacyKey]) + ) + expect( + events.find(({ payload }) => (payload as { key: string }).key === expired.keys[11])?.payload + ).toEqual({ key: expired.keys[11] }) + await processWorkspaceFileStorageCleanupsNow( + events.map(({ id }) => id), + { label: 'retention-fixture' } + ) + expect(await downloadFile({ key: restored.keys[11], context: 'workspace' })).toEqual( + Buffer.from('draft') + ) + await expect( + downloadFile({ key: expired.keys[11], context: 'workspace' }) + ).rejects.toMatchObject({ code: 'ENOENT' }) + await expect(downloadFile({ key: legacyKey, context: 'workspace' })).rejects.toMatchObject({ + code: 'ENOENT', + }) + expect(await versions(restored.fileId)).toHaveLength(12) + const [usage] = await db + .select({ bytes: userStats.storageUsedBytes }) + .from(userStats) + .where(eq(userStats.userId, f.ownerId)) + expect(usage.bytes).toBe(10) + } + ) + + check('workspace file folder deletion failure rolls back child locations and names', async () => { + const f = await fixture() + const parentId = generateId() + const childId = generateId() + const owner = { workspaceId: f.workspaceId, userId: f.ownerId, resourceType: 'file' as const } + await db.insert(folder).values({ + ...owner, + id: parentId, + name: 'Archived parent', + deletedAt: new Date(Date.now() - 40 * 24 * HOUR), + }) + await db.insert(folder).values({ ...owner, id: childId, name: 'Child', parentId }) + const file = await seedFile(f, 1, false, 'workspace') + await db + .update(workspaceFiles) + .set({ folderId: parentId, originalName: 'Child.md' }) + .where(eq(workspaceFiles.id, file.fileId)) + const trigger = `folder_retention_failure_${generateId().replaceAll('-', '')}` + await db.execute( + sql.raw(`CREATE FUNCTION ${trigger}() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN + IF OLD.id = '${parentId}' THEN RAISE EXCEPTION 'folder retention rollback fixture'; END IF; RETURN OLD; END $$`) + ) + await db.execute( + sql.raw( + `CREATE TRIGGER ${trigger} BEFORE DELETE ON folder FOR EACH ROW EXECUTE FUNCTION ${trigger}()` + ) + ) + try { + await runCleanupSoftDeletes( + { + workspaceIds: [f.workspaceId], + plan: 'free', + retentionHours: 30 * 24, + label: 'workspace-folder-rollback', + }, + createCleanupBudgets({ folders: 1 }) + ).catch(() => undefined) + const [child] = await db + .select({ parentId: folder.parentId, name: folder.name }) + .from(folder) + .where(eq(folder.id, childId)) + expect(child).toEqual({ parentId, name: 'Child' }) + const [head] = await db + .select({ folderId: workspaceFiles.folderId, name: workspaceFiles.originalName }) + .from(workspaceFiles) + .where(eq(workspaceFiles.id, file.fileId)) + expect(head).toEqual({ folderId: parentId, name: 'Child.md' }) + } finally { + await db.execute(sql.raw(`DROP TRIGGER ${trigger} ON folder`)) + await db.execute(sql.raw(`DROP FUNCTION ${trigger}()`)) + } + }) + + check( + 'workspace file folder purge waits for a concurrent restore and leaves its children in place', + async () => { + const f = await fixture() + const parentId = generateId() + const childId = generateId() + const owner = { workspaceId: f.workspaceId, userId: f.ownerId, resourceType: 'file' as const } + await db.insert(folder).values({ + ...owner, + id: parentId, + name: 'Restoring parent', + deletedAt: new Date(Date.now() - 40 * 24 * HOUR), + }) + await db.insert(folder).values({ ...owner, id: childId, name: 'Child', parentId }) + const acquired = createDeferred() + const restore = createDeferred() + const authority = db.transaction(async (tx) => { + await loadProjectAccess(tx, f.ownerId, { projectId: f.projectId }) + const result = await tx.execute<{ pid: number }>(sql`SELECT pg_backend_pid() AS pid`) + acquired.resolve(result[0].pid) + await restore.promise + await tx.update(folder).set({ deletedAt: null }).where(eq(folder.id, parentId)) + }) + const authPid = await Promise.race([ + acquired.promise, + authority.then(() => { + throw new Error('Restore ended before publishing its lock') + }), + ]) + let settled = false + const cleanup = runCleanupSoftDeletes( + { + workspaceIds: [f.workspaceId], + plan: 'free', + retentionHours: 30 * 24, + label: 'workspace-folder-restore', + }, + createCleanupBudgets({ folders: 1 }) + ).then( + () => { + settled = true + return null + }, + (error: unknown) => { + settled = true + return error + } + ) + let observed: string | null = null + try { + for (let attempt = 0; attempt < 100 && !settled; attempt++) { + const [waiting] = await control< + { wait_event: string }[] + >`SELECT wait_event FROM pg_stat_activity WHERE ${authPid} = ANY(pg_blocking_pids(pid)) AND wait_event_type = 'Lock'` + if (waiting) { + observed = waiting.wait_event + break + } + await sleep(10) + } + } finally { + restore.resolve() + await authority + await cleanup + } + expect(observed).toBe('advisory') + expect(await cleanup).toBeNull() + const [parent] = await db + .select({ deletedAt: folder.deletedAt }) + .from(folder) + .where(eq(folder.id, parentId)) + expect(parent).toEqual({ deletedAt: null }) + const [child] = await db + .select({ parentId: folder.parentId, name: folder.name }) + .from(folder) + .where(eq(folder.id, childId)) + expect(child).toEqual({ parentId, name: 'Child' }) + } + ) + check( 'free history is pruned with its ten-version floor while a paid Project ignores a stale free queue policy and uploader plan', async () => { @@ -454,42 +741,66 @@ describe('Project file retention follows the current payer in PostgreSQL', () => } ) - check( - 'a failed archive deletion rolls back current bytes, version removal, and the cleanup outbox', - async () => { - const f = await fixture() - const expired = await seedFile(f, 40 * 24, true) - const trigger = `retention_failure_${generateId().replaceAll('-', '')}` - await db.execute( - sql.raw(`CREATE FUNCTION ${trigger}() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN + for (const kind of ['project', 'workspace'] as const) + check( + `a failed ${kind} archive deletion rolls back current bytes, version removal, and the cleanup outbox`, + async () => { + const f = await fixture() + const expired = await seedFile(f, 40 * 24, true, kind) + if (kind === 'workspace') + await db + .update(workspace) + .set({ storageUsedBytes: 5 }) + .where(eq(workspace.id, f.workspaceId)) + const trigger = `retention_failure_${generateId().replaceAll('-', '')}` + await db.execute( + sql.raw(`CREATE FUNCTION ${trigger}() RETURNS trigger LANGUAGE plpgsql AS $$ BEGIN IF OLD.id = '${expired.fileId}' THEN RAISE EXCEPTION 'retention rollback fixture'; END IF; RETURN OLD; END $$`) - ) - await db.execute( - sql.raw( - `CREATE TRIGGER ${trigger} BEFORE DELETE ON workspace_files FOR EACH ROW EXECUTE FUNCTION ${trigger}()` ) - ) - try { - await expect( - runCleanupSoftDeletes(payload(f.projectId), createCleanupBudgets({ files: 1 })) - ).rejects.toMatchObject({ cause: { message: 'retention rollback fixture' } }) - expect(await versions(expired.fileId)).toHaveLength(12) - expect(await cleanupEvents(f.projectId)).toEqual([]) - const [usage] = await db - .select({ bytes: userStats.storageUsedBytes }) - .from(userStats) - .where(eq(userStats.userId, f.ownerId)) - expect(usage.bytes).toBe(15) - } finally { - await db.execute(sql.raw(`DROP TRIGGER ${trigger} ON workspace_files`)) - await db.execute(sql.raw(`DROP FUNCTION ${trigger}()`)) + await db.execute( + sql.raw( + `CREATE TRIGGER ${trigger} BEFORE DELETE ON workspace_files FOR EACH ROW EXECUTE FUNCTION ${trigger}()` + ) + ) + try { + await expect( + runCleanupSoftDeletes( + kind === 'project' + ? payload(f.projectId) + : { + workspaceIds: [f.workspaceId], + plan: 'free', + retentionHours: 30 * 24, + label: 'workspace-rollback', + }, + createCleanupBudgets({ files: 1 }) + ) + ).rejects.toMatchObject( + kind === 'project' + ? { cause: { message: 'retention rollback fixture' } } + : { message: 'File row cleanup failed' } + ) + expect(await versions(expired.fileId)).toHaveLength(12) + expect( + await cleanupEvents(kind === 'project' ? f.projectId : f.workspaceId, kind) + ).toEqual([]) + const [usage] = await db + .select({ bytes: userStats.storageUsedBytes }) + .from(userStats) + .where(eq(userStats.userId, f.ownerId)) + expect(usage.bytes).toBe(15) + } finally { + await db.execute(sql.raw(`DROP TRIGGER ${trigger} ON workspace_files`)) + await db.execute(sql.raw(`DROP FUNCTION ${trigger}()`)) + } } - } - ) + ) }) afterAll(async () => { try { + for (const key of storedKeys) + await deleteFile({ key, context: 'workspace' }).catch(() => undefined) for (const f of fixtures) { await db .delete(outboxEvent) diff --git a/apps/sim/lib/projects/files/__integration__/uploads.integration.ts b/apps/sim/lib/projects/files/__integration__/uploads.integration.ts new file mode 100644 index 00000000000..f8ca99a85f6 --- /dev/null +++ b/apps/sim/lib/projects/files/__integration__/uploads.integration.ts @@ -0,0 +1,656 @@ +import { mkdtempSync } from 'node:fs' +import { mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import type { ResourceDelegatedPrincipal } from '@sim/auth/principal' +import { db } from '@sim/db' +import { + folder, + member, + organization, + outboxEvent, + permissions, + projectWorkspace, + uploadSession, + user, + workspace, + workspaceFileSecretProvenance, + workspaceFiles, +} from '@sim/db/schema' +import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures' +import { createSessionPrincipal } from '@sim/testing/factories/principal.factory' +import { createDeferred } from '@sim/testing/helpers/deferred' +import { featureFlagsMock, featureFlagsMockFns } from '@sim/testing/mocks/feature-flags.mock' +import { setUploadDirServer, uploadsSetupMock } from '@sim/testing/mocks/uploads-setup.mock' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { isRecordLike } from '@sim/utils/object' +import { and, eq, inArray, sql } from 'drizzle-orm' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/core/config/feature-flags', () => featureFlagsMock) + +await vi.hoisted(async () => { + const { readTestRedisUrl } = await import('@sim/db/testing/test-infrastructure') + const redisUrl = readTestRedisUrl() + if (redisUrl) process.env.REDIS_URL = redisUrl +}) + +vi.mock('@/lib/uploads/core/setup.server', () => uploadsSetupMock) + +import { closeRedisConnection } from '@/lib/core/config/redis' +import * as application from '@/lib/projects/files/application' +import * as prefixCleanup from '@/lib/projects/files/prefix-cleanup' +import * as manager from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { + UPLOAD_URL_TTL_MS, + writeLocalMultipartPart, + writeLocalPutObject, +} from '@/lib/uploads/upload-session/provider' +import * as sessions from '@/lib/uploads/upload-session/service' +import { simPageSourceEmbedBlock } from '@/lib/workspace-files/page-source-embed' + +const storageRoot = mkdtempSync(join(tmpdir(), 'sim-project-upload-')) +setUploadDirServer(storageRoot) +const fixtures: { + ownerId: string + editorId: string + organizationId: string + workspaceId: string + projectId: string +}[] = [] +const checks: { name: string; status: 'passed' | 'failed'; durationMs: number; error?: string }[] = + [] + +beforeEach(() => { + featureFlagsMockFns.mockIsFeatureEnabled.mockImplementation(async (flag) => flag === 'projects') + vi.stubEnv('PROJECT_FILES_ENABLED', 'true') + vi.stubEnv('FREE_STORAGE_LIMIT_GB', '') +}) + +function check(name: string, run: () => Promise) { + it(name, async () => { + const started = performance.now() + try { + await run() + checks.push({ name, status: 'passed', durationMs: performance.now() - started }) + } catch (error) { + checks.push({ + name, + status: 'failed', + durationMs: performance.now() - started, + error: getErrorMessage(error), + }) + throw error + } + }) +} + +async function fixture() { + const ownerId = generateId() + const editorId = generateId() + const organizationId = generateId() + const workspaceId = generateId() + await db.insert(user).values( + [ownerId, editorId].map((id) => ({ + id, + email: `${id}@upload.invalid`, + name: 'Upload fixture', + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + })) + ) + await db + .insert(organization) + .values({ id: organizationId, name: 'Upload', slug: organizationId, createdAt: new Date() }) + await db.insert(member).values( + [ownerId, editorId].map((userId) => ({ + id: generateId(), + organizationId, + userId, + role: userId === ownerId ? 'owner' : 'member', + createdAt: new Date(), + })) + ) + await insertWorkspaceFixture(db, { + id: workspaceId, + ownerId, + billedAccountUserId: ownerId, + organizationId, + workspaceMode: 'organization', + name: 'Upload environment', + }) + const [binding] = await db + .select() + .from(projectWorkspace) + .where(eq(projectWorkspace.workspaceId, workspaceId)) + if (!binding) throw new Error('Project fixture missing') + await db.insert(permissions).values({ + id: generateId(), + userId: editorId, + entityType: 'workspace', + entityId: workspaceId, + permissionType: 'admin', + }) + const f = { ownerId, editorId, organizationId, workspaceId, projectId: binding.projectId } + fixtures.push(f) + return { ...f, principal: createSessionPrincipal({ userId: editorId, sessionId: generateId() }) } +} + +function stream(bytes: Buffer) { + return new ReadableStream({ + start(controller) { + controller.enqueue(bytes) + controller.close() + }, + }) +} +function input(projectId: string, fileSize = 4) { + return { + projectId, + fileName: 'architecture.bin', + contentType: 'application/octet-stream', + fileSize, + localOrigin: 'http://localhost:3000', + } +} +function control(projectId: string, session: sessions.UploadSessionRecord) { + return { projectId, uploadId: session.id, uploadToken: session.uploadToken } +} +async function put(session: sessions.UploadSessionRecord, bytes: Buffer) { + await writeLocalPutObject({ + uploadId: session.id, + key: session.finalKey, + body: stream(bytes), + expectedSize: bytes.length, + contentType: session.contentType, + metadata: sessions.uploadSessionObjectMetadata(session), + }) +} +async function usage(id: string) { + const [row] = await db + .select({ bytes: organization.storageUsedBytes }) + .from(organization) + .where(eq(organization.id, id)) + return row.bytes +} + +describe('Project outer transaction cleanup for upload sessions', () => { + check('preserves upload authorization failure when its cleanup hook fails', async () => { + const f = await fixture() + const create = sessions.createUploadSession + const preparation = vi + .spyOn(sessions, 'createUploadSession') + .mockImplementation(async (args) => { + const session = await create(args) + await db.delete(permissions).where(eq(permissions.userId, f.editorId)) + return session + }) + const cleanup = vi + .spyOn(prefixCleanup, 'queueRetiredProjectUploadCleanup') + .mockRejectedValue(new Error('Cleanup database unavailable')) + try { + await expect( + application.createProjectFileUploadSession.execute({ + principal: f.principal, + input: input(f.projectId), + }) + ).rejects.toMatchObject({ code: 'not_found' }) + } finally { + preparation.mockRestore() + cleanup.mockRestore() + } + }) + check('retains the created upload session after acknowledgement loss', async () => { + const f = await fixture() + const transaction = db.transaction.bind(db) + let sessionId = '' + let originalStatus: unknown + const primary = new Error('Commit acknowledgement lost') + const interception = vi + .spyOn(db, 'transaction') + .mockImplementation(async (callback, config) => { + const result = await transaction(callback, config) + if ( + isRecordLike(result) && + isRecordLike(result.result) && + result.result.purpose === 'project_file' + ) { + sessionId = String(result.result.id) + originalStatus = result.result.status + throw primary + } + return result + }) + try { + await expect( + application.createProjectFileUploadSession.execute({ + principal: f.principal, + input: input(f.projectId), + }) + ).rejects.toBe(primary) + } finally { + interception.mockRestore() + } + expect(sessionId).not.toBe('') + const [row] = await db.select().from(uploadSession).where(eq(uploadSession.id, sessionId)) + expect(row.status).toBe(originalStatus) + }) + check('retains committed restored upload source after acknowledgement loss', async () => { + const f = await fixture() + const source = '---\ntitle: Architecture\n---\n# Notes\nShared Project description' + const bytes = Buffer.from( + `${simPageSourceEmbedBlock(source)}Compiled page` + ) + const session = await application.createProjectFileUploadSession.execute({ + principal: f.principal, + input: { + ...input(f.projectId, bytes.length), + fileName: 'Architecture.html', + contentType: 'text/html', + }, + }) + await put(session, bytes) + const transaction = db.transaction.bind(db) + const primary = new Error('Commit acknowledgement lost') + let key = '' + const interception = vi + .spyOn(db, 'transaction') + .mockImplementation(async (callback, config) => { + const result = await transaction(callback, config) + if ( + !key && + isRecordLike(result) && + isRecordLike(result.result) && + isRecordLike(result.result.file) + ) { + key = String(result.result.file.key) + throw primary + } + return result + }) + try { + await expect( + application.completeProjectFileUploadSession.execute({ + principal: f.principal, + input: control(f.projectId, session), + }) + ).rejects.toBe(primary) + } finally { + interception.mockRestore() + } + expect(key).not.toBe('') + expect(await readFile(join(storageRoot, key), 'utf8')).toBe(source) + expect( + await db.select().from(outboxEvent).where(sql`${outboxEvent.payload}->>'key' = ${key}`) + ).toEqual([]) + }) +}) + +describe('Project upload sessions with real leases, storage, and accounting', () => { + check( + 'PUT completion creates one Project file attributed to the actor and replay never bills twice', + async () => { + const f = await fixture() + const directory = await application.createProjectFileFolder.execute({ + principal: f.principal, + input: { projectId: f.projectId, name: 'Design' }, + }) + const session = await application.createProjectFileUploadSession.execute({ + principal: f.principal, + input: { ...input(f.projectId), folderId: directory.folder.id }, + }) + expect(session).toMatchObject({ + workspaceId: null, + purpose: 'project_file', + storageContext: 'project', + userId: f.editorId, + }) + await put(session, Buffer.from([0, 255, 17, 1])) + const result = await application.completeProjectFileUploadSession.execute({ + principal: f.principal, + input: control(f.projectId, session), + }) + expect(result.value.file).toMatchObject({ + owner: { entityType: 'project', entityId: f.projectId }, + uploadedBy: f.editorId, + folderPath: 'Design', + size: 4, + }) + expect(await readFile(join(storageRoot, result.value.file.key))).toEqual( + Buffer.from([0, 255, 17, 1]) + ) + const replay = await application.completeProjectFileUploadSession.execute({ + principal: f.principal, + input: control(f.projectId, session), + }) + expect(replay.value.file.id).toBe(result.value.file.id) + expect(replay.alreadyCompleted).toBe(true) + expect(await usage(f.organizationId)).toBe(4) + const polled = await application.getProjectFileUploadSession.execute({ + principal: f.principal, + input: control(f.projectId, session), + }) + expect(polled.file?.id).toBe(result.value.file.id) + await expect( + application.abortProjectFileUploadSession.execute({ + principal: f.principal, + input: control(f.projectId, session), + }) + ).rejects.toMatchObject({ code: 'conflict' }) + } + ) + + check( + 'multipart completion assembles real parts and refuses another credential or revoked caller on every control leg', + async () => { + const f = await fixture() + const size = sessions.UPLOAD_SESSION_PART_SIZE + 1 + const session = await application.createProjectFileUploadSession.execute({ + principal: f.principal, + input: input(f.projectId, size), + }) + const target = { + ...control(f.projectId, session), + localOrigin: 'http://localhost:3000', + partNumbers: [1, 2], + } + const wrong = { ...f.principal, sessionId: generateId() } + for (const operation of [ + application.getProjectFileUploadSession, + application.getProjectFileUploadPartUrls, + application.completeProjectFileUploadSession, + application.abortProjectFileUploadSession, + ]) { + await expect(operation.execute({ principal: wrong, input: target })).rejects.toMatchObject({ + code: 'not_found', + }) + } + await db + .update(permissions) + .set({ permissionType: 'read' }) + .where(and(eq(permissions.userId, f.editorId), eq(permissions.entityId, f.workspaceId))) + for (const operation of [ + application.getProjectFileUploadSession, + application.getProjectFileUploadPartUrls, + application.completeProjectFileUploadSession, + application.abortProjectFileUploadSession, + ]) { + await expect( + operation.execute({ principal: f.principal, input: target }) + ).rejects.toMatchObject({ code: 'forbidden' }) + } + await db + .update(permissions) + .set({ permissionType: 'admin' }) + .where(and(eq(permissions.userId, f.editorId), eq(permissions.entityId, f.workspaceId))) + expect( + ( + await application.getProjectFileUploadPartUrls.execute({ + principal: f.principal, + input: target, + }) + ).parts + ).toHaveLength(2) + await writeLocalMultipartPart({ + uploadId: session.id, + partNumber: 1, + body: stream(Buffer.alloc(sessions.UPLOAD_SESSION_PART_SIZE, 7)), + expectedSize: sessions.UPLOAD_SESSION_PART_SIZE, + }) + await writeLocalMultipartPart({ + uploadId: session.id, + partNumber: 2, + body: stream(Buffer.from([9])), + expectedSize: 1, + }) + const result = await application.completeProjectFileUploadSession.execute({ + principal: f.principal, + input: target, + }) + const content = await readFile(join(storageRoot, result.value.file.key)) + expect(content.length).toBe(size) + expect(content[0]).toBe(7) + expect(content[size - 1]).toBe(9) + expect(await usage(f.organizationId)).toBe(size) + } + ) + + check( + 'completion enforces current quota atomically and failed registration remains retryable without deleting bytes', + async () => { + const f = await fixture() + vi.stubEnv('FREE_STORAGE_LIMIT_GB', '1') + const session = await application.createProjectFileUploadSession.execute({ + principal: f.principal, + input: input(f.projectId), + }) + await put(session, Buffer.from('file')) + await db + .update(organization) + .set({ storageUsedBytes: 1024 ** 3 }) + .where(eq(organization.id, f.organizationId)) + await expect( + application.completeProjectFileUploadSession.execute({ + principal: f.principal, + input: control(f.projectId, session), + }) + ).rejects.toMatchObject({ code: 'payload_too_large' }) + expect(await readFile(join(storageRoot, session.finalKey), 'utf8')).toBe('file') + expect( + await db.select().from(workspaceFiles).where(eq(workspaceFiles.projectId, f.projectId)) + ).toHaveLength(0) + await db + .update(organization) + .set({ storageUsedBytes: 0 }) + .where(eq(organization.id, f.organizationId)) + const result = await application.completeProjectFileUploadSession.execute({ + principal: f.principal, + input: control(f.projectId, session), + }) + expect(result.value.file.size).toBe(4) + expect(await usage(f.organizationId)).toBe(4) + } + ) + + check( + 'Copilot retries bind the current invocation and preserve unknown provenance when no trusted source was supplied', + async () => { + const f = await fixture() + const issuedAt = new Date() + const principal: ResourceDelegatedPrincipal = { + kind: 'resource_delegated', + serviceId: 'copilot', + subjectUserId: f.editorId, + delegationId: generateId(), + audience: 'sim:project-files', + issuedAt, + expiresAt: new Date(issuedAt.getTime() + 60_000), + invocation: { kind: 'workspace', workspaceId: f.workspaceId }, + scope: { kind: 'entity', entityType: 'project', entityId: f.projectId }, + } + const session = await application.createProjectFileUploadSession.execute({ + principal, + input: input(f.projectId), + }) + await put(session, Buffer.from('file')) + const retryIssuedAt = new Date() + const fresh = { + ...principal, + delegationId: generateId(), + issuedAt: retryIssuedAt, + expiresAt: new Date(retryIssuedAt.getTime() + 60_000), + } + const result = await application.completeProjectFileUploadSession.execute({ + principal: fresh, + input: control(f.projectId, session), + }) + const [provenance] = await db + .select() + .from(workspaceFileSecretProvenance) + .where(eq(workspaceFileSecretProvenance.fileId, result.value.file.id)) + expect(provenance.status).toBe('unknown') + expect(provenance.entries).toEqual([]) + expect(result.value.file.uploadedBy).toBe(f.editorId) + } + ) + + check( + 'an expired completion proof cannot register after another real completion claimed its lease', + async () => { + const f = await fixture() + const session = await application.createProjectFileUploadSession.execute({ + principal: f.principal, + input: input(f.projectId), + }) + await put(session, Buffer.from('file')) + await expect( + sessions.completeUploadSession({ + session, + finalize: async (first) => { + const staged = manager.adoptVerifiedUploadSession(first, { + entityType: 'project', + entityId: f.projectId, + }) + await expect(manager.discardStagedFileContent(staged)).rejects.toThrow('upload session') + await db + .update(uploadSession) + .set({ processingLeaseExpiresAt: new Date(Date.now() - 1000) }) + .where(eq(uploadSession.id, first.id)) + const entered = createDeferred() + const release = createDeferred() + const second = sessions.completeUploadSession({ + session: first, + finalize: async () => { + entered.resolve() + await release.promise + throw new Error('Second lease owns completion') + }, + }) + await entered.promise + try { + await expect( + db.transaction((tx) => sessions.lockUploadSessionRegistrationInTx(tx, first)) + ).rejects.toMatchObject({ code: 'conflict' }) + } finally { + release.resolve() + await expect(second).rejects.toThrow('Second lease owns completion') + } + throw new Error('First completion stopped after registration admission check') + }, + }) + ).rejects.toThrow('First completion stopped after registration admission check') + expect(await readFile(join(storageRoot, session.finalKey), 'utf8')).toBe('file') + expect(await usage(f.organizationId)).toBe(0) + const result = await application.completeProjectFileUploadSession.execute({ + principal: f.principal, + input: control(f.projectId, session), + }) + expect(result.value.file.size).toBe(4) + } + ) + + check('part URLs signed during a concurrent access revocation are not exposed', async () => { + const f = await fixture() + const session = await application.createProjectFileUploadSession.execute({ + principal: f.principal, + input: input(f.projectId, 8 * 1024 * 1024 + 1), + }) + const entered = createDeferred() + const release = createDeferred() + const createRealUrls = sessions.createUploadPartUrls + const signing = vi.spyOn(sessions, 'createUploadPartUrls').mockImplementation(async (args) => { + const result = await createRealUrls(args) + entered.resolve() + await release.promise + return result + }) + const pending = application.getProjectFileUploadPartUrls.execute({ + principal: f.principal, + input: { + ...control(f.projectId, session), + partNumbers: [1], + localOrigin: 'http://localhost:3000', + }, + }) + await entered.promise + try { + await db + .update(permissions) + .set({ permissionType: 'read' }) + .where(and(eq(permissions.userId, f.editorId), eq(permissions.entityId, f.workspaceId))) + release.resolve() + await expect(pending).rejects.toMatchObject({ code: 'forbidden' }) + } finally { + release.resolve() + await pending.catch(() => undefined) + signing.mockRestore() + } + }) + + check( + 'compiled page uploads restore editable source without breaking receipt replay or charging compiled bytes', + async () => { + const f = await fixture() + const source = '---\ntitle: Architecture\n---\n# Notes\nShared Project description' + const bytes = Buffer.from( + `${simPageSourceEmbedBlock(source)}Compiled page` + ) + const session = await application.createProjectFileUploadSession.execute({ + principal: f.principal, + input: { + ...input(f.projectId, bytes.length), + fileName: 'Architecture.html', + contentType: 'text/html', + }, + }) + await put(session, bytes) + const completionStartedAt = Date.now() + const result = await application.completeProjectFileUploadSession.execute({ + principal: f.principal, + input: control(f.projectId, session), + }) + expect(result.value.file).toMatchObject({ + name: 'Architecture', + type: 'text/x-sim-page', + size: Buffer.byteLength(source), + }) + expect(await readFile(join(storageRoot, result.value.file.key), 'utf8')).toBe(source) + const [originalCleanup] = await db + .select() + .from(outboxEvent) + .where(sql`${outboxEvent.payload}->>'key' = ${session.finalKey}`) + expect(originalCleanup.availableAt.getTime()).toBeGreaterThanOrEqual( + completionStartedAt + UPLOAD_URL_TTL_MS + ) + expect(await usage(f.organizationId)).toBe(Buffer.byteLength(source)) + const replay = await application.completeProjectFileUploadSession.execute({ + principal: f.principal, + input: control(f.projectId, session), + }) + expect(replay.value.file.id).toBe(result.value.file.id) + expect(await usage(f.organizationId)).toBe(Buffer.byteLength(source)) + } + ) +}) + +afterAll(async () => { + for (const f of fixtures) { + await db.delete(uploadSession).where(inArray(uploadSession.userId, [f.ownerId, f.editorId])) + await db + .delete(outboxEvent) + .where(sql`${outboxEvent.payload}::jsonb ->> 'key' LIKE ${`project/${f.projectId}/%`}`) + await db.delete(workspaceFiles).where(eq(workspaceFiles.projectId, f.projectId)) + await db.delete(folder).where(eq(folder.projectId, f.projectId)) + await deleteWorkspaceFixture(db, eq(workspace.id, f.workspaceId)) + await db.delete(organization).where(eq(organization.id, f.organizationId)) + await db.delete(user).where(inArray(user.id, [f.ownerId, f.editorId])) + } + await rm(storageRoot, { recursive: true, force: true }) + await closeRedisConnection() + const report = process.env.PROJECT_UPLOAD_REPORT_PATH ?? 'test-results/project-upload.json' + await mkdir(dirname(report), { recursive: true }) + await writeFile(report, `${JSON.stringify({ checks }, null, 2)}\n`) + await db.$client.end() +}) diff --git a/apps/sim/lib/projects/files/api/content-presenter.ts b/apps/sim/lib/projects/files/api/content-presenter.ts new file mode 100644 index 00000000000..28ee6b947a9 --- /dev/null +++ b/apps/sim/lib/projects/files/api/content-presenter.ts @@ -0,0 +1,19 @@ +import type { OwnedFileRecord } from '@/lib/uploads/contexts/workspace' +import { FILE_CACHE_CONTROL, presentFileDelivery } from '@/lib/uploads/server/delivery' + +/** Presents authorized bytes without caching the mutable file URL. */ +export function presentProjectFileContent({ + file, + content, +}: { + file: OwnedFileRecord + content: Buffer +}) { + return presentFileDelivery({ + body: content, + filename: file.name, + contentType: file.type, + contentLength: content.length, + cacheControl: FILE_CACHE_CONTROL.noStore, + }) +} diff --git a/apps/sim/lib/projects/files/api/download-presenter.ts b/apps/sim/lib/projects/files/api/download-presenter.ts new file mode 100644 index 00000000000..b4cddc60067 --- /dev/null +++ b/apps/sim/lib/projects/files/api/download-presenter.ts @@ -0,0 +1,17 @@ +import { FILE_CACHE_CONTROL, presentFileDelivery } from '@/lib/uploads/server/delivery' + +/** Serves the authorized archive through a buffer view, without cloning its bounded payload. */ +export function presentProjectFileDownload(result: { + buffer: Buffer + fileName: string + contentType: string +}) { + return presentFileDelivery({ + body: result.buffer, + filename: result.fileName, + contentType: result.contentType, + contentLength: result.buffer.length, + attachment: true, + cacheControl: FILE_CACHE_CONTROL.noStore, + }) +} diff --git a/apps/sim/lib/projects/files/api/index.ts b/apps/sim/lib/projects/files/api/index.ts new file mode 100644 index 00000000000..0ab5b3517d8 --- /dev/null +++ b/apps/sim/lib/projects/files/api/index.ts @@ -0,0 +1,5 @@ +export { toFileVersion as toProjectFileVersion } from '@/lib/workspace-files/api/version-presenters' +export { presentProjectFileContent } from './content-presenter' +export { toV2ProjectFile } from './presenters' +export { presentProjectFileUpload } from './upload-presenter' +export { presentProjectFileVersionContent } from './version-presenters' diff --git a/apps/sim/lib/projects/files/api/presenters.ts b/apps/sim/lib/projects/files/api/presenters.ts new file mode 100644 index 00000000000..ec8af2d8941 --- /dev/null +++ b/apps/sim/lib/projects/files/api/presenters.ts @@ -0,0 +1,42 @@ +import type { ProjectFileFolderRecord } from '@/lib/api/contracts/project-file-folders' +import type { V2ProjectFileFolder } from '@/lib/api/contracts/v2/project-file-folders' +import type { V2ProjectFile } from '@/lib/api/contracts/v2/project-files' +import { buildFolderPath } from '@/lib/folders/paths' +import type { OwnedFileRecord } from '@/lib/uploads/contexts/workspace' +import { workspaceFileRevisionField } from '@/lib/workspace-files/application/file-revision' +import { parseWorkspaceFileFolderDisplayPath } from '@/lib/workspace-files/folder-display-path' + +/** Public projection of file metadata without collaboration or storage lifecycle internals. */ +export function toV2ProjectFile( + file: OwnedFileRecord<{ entityType: 'project'; entityId: string }> +): V2ProjectFile { + if (file.folderId && file.folderPath === null) { + throw new Error('File references an unresolved folder') + } + return { + id: file.id, + owner: file.owner, + name: file.name, + size: file.size, + type: file.type, + key: file.key, + folderPath: file.folderPath + ? buildFolderPath(parseWorkspaceFileFolderDisplayPath(file.folderPath)) + : '/', + uploadedBy: file.uploadedBy, + uploadedAt: file.uploadedAt.toISOString(), + updatedAt: file.updatedAt.toISOString(), + deletedAt: file.deletedAt?.toISOString() ?? null, + ...workspaceFileRevisionField(file), + } +} + +/** Projects the shared folder record without relying on Date response coercion. */ +export function toV2ProjectFileFolder(folder: ProjectFileFolderRecord): V2ProjectFileFolder { + return { + ...folder, + createdAt: folder.createdAt.toISOString(), + updatedAt: folder.updatedAt.toISOString(), + deletedAt: folder.deletedAt?.toISOString() ?? null, + } +} diff --git a/apps/sim/lib/projects/files/api/upload-presenter.ts b/apps/sim/lib/projects/files/api/upload-presenter.ts new file mode 100644 index 00000000000..51ddaf46aa9 --- /dev/null +++ b/apps/sim/lib/projects/files/api/upload-presenter.ts @@ -0,0 +1,26 @@ +import type { ProjectFileUploadSession } from '@/lib/api/contracts/project-file-uploads' +import type { V2ProjectFileUpload } from '@/lib/api/contracts/v2/project-file-uploads' +import { toV2ProjectFile } from '@/lib/projects/files/api/presenters' +import type { OwnedFileRecord } from '@/lib/uploads/contexts/workspace' +import { presentUploadSessionState } from '@/lib/uploads/upload-session/presenter' +import type { UploadSessionRecord } from '@/lib/uploads/upload-session/service' + +/** Selects public fields explicitly; session metadata contains private credential and provenance bindings. */ +export function presentProjectFileUpload( + session: UploadSessionRecord, + file: OwnedFileRecord<{ entityType: 'project'; entityId: string }> | null +): ProjectFileUploadSession { + return { + ...presentUploadSessionState(session), + purpose: 'project_file', + result: file, + } +} + +/** The public control protocol presents canonical file paths after registration. */ +export function toV2ProjectFileUpload( + session: UploadSessionRecord, + file: OwnedFileRecord<{ entityType: 'project'; entityId: string }> | null +): V2ProjectFileUpload { + return { ...presentUploadSessionState(session), file: file ? toV2ProjectFile(file) : null } +} diff --git a/apps/sim/lib/projects/files/api/version-presenters.ts b/apps/sim/lib/projects/files/api/version-presenters.ts new file mode 100644 index 00000000000..206edfe9bd4 --- /dev/null +++ b/apps/sim/lib/projects/files/api/version-presenters.ts @@ -0,0 +1,22 @@ +import type { OwnedFileRecord } from '@/lib/uploads/contexts/workspace' +import type { WorkspaceFileVersionRecord } from '@/lib/uploads/contexts/workspace/workspace-file-versions' +import { FILE_CACHE_CONTROL, presentFileDelivery } from '@/lib/uploads/server/delivery' +/** Historical source bytes use the selected version's type rather than the current head's type. */ +export function presentProjectFileVersionContent({ + file, + version, + content, +}: { + file: OwnedFileRecord + version: WorkspaceFileVersionRecord + content: Buffer +}) { + return presentFileDelivery({ + body: content, + filename: file.name, + contentType: version.contentType, + contentLength: content.length, + attachment: true, + cacheControl: FILE_CACHE_CONTROL.noStore, + }) +} diff --git a/apps/sim/lib/projects/files/application/artifacts.ts b/apps/sim/lib/projects/files/application/artifacts.ts new file mode 100644 index 00000000000..16e316baaa6 --- /dev/null +++ b/apps/sim/lib/projects/files/application/artifacts.ts @@ -0,0 +1,365 @@ +import { db } from '@sim/db' +import type { WorkspaceFileRow } from '@sim/db/schema' +import { workspaceFiles } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { describeError } from '@sim/utils/errors' +import { and, asc, inArray, isNull } from 'drizzle-orm' +import { isDocSandboxEnabled } from '@/lib/core/config/env-flags' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { assertKnownSizeWithinLimit } from '@/lib/core/utils/stream-limits' +import type { DbTransaction } from '@/lib/db/types' +import type { ProjectFileTarget } from '@/lib/projects/files/application/authorization' +import { defineAuthorizedProjectFileUseCase } from '@/lib/projects/files/application/authorized-use-case' +import { projectFileOperations } from '@/lib/projects/files/application/operations' +import { + buildWorkspaceFileFolderPathMap, + listFileFolders, + mapFileRecord, + type OwnedFileRecord, +} from '@/lib/uploads/contexts/workspace' +import { + getBoundWorkspaceFileSecretProvenanceByMetadata, + MODEL_UNSAFE_WORKSPACE_FILE_ERROR_MESSAGE, + mergeWorkspaceFileSecretProvenance, + type WorkspaceFileSecretProvenance, +} from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' +import { + enqueueWorkspaceFileStorageCleanups, + processWorkspaceFileStorageCleanupsNow, +} from '@/lib/uploads/contexts/workspace/workspace-file-storage-cleanup-outbox' +import { deleteFile, downloadFile } from '@/lib/uploads/core/storage-service' +import { + collectReferencedFileIds, + compileFileDocument, + getDocumentSourceLanguage, + getE2BDocFormat, + isCompiledDocumentBuffer, + resolveDocumentRender, +} from '@/lib/uploads/documents' +import { fileDocumentInputIdentity } from '@/lib/uploads/documents/input-identity' +import { resolveServableImageBytes } from '@/lib/uploads/server/image-derivative' +import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' +import { reportWorkspaceFileDelivery } from '@/lib/workspace-files/application/file-delivery-observer' +import { fileOwnerCondition } from '@/lib/workspace-files/ownership-query' +import { isSimPageSource, SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile' +import { renderSimPageDocument } from '@/lib/workspace-files/page-document' +import { + collectSimPageFileReferences, + inlineSimPageImages, +} from '@/lib/workspace-files/page-document.server' +import { createFileReadReceipt, type FileReadReceipt } from '@/lib/workspace-files/read-receipt' +import { markFileSearchArtifactReadyInTx } from '@/lib/workspace-files/search/artifact-ready' + +const logger = createLogger('ProjectFileArtifacts') + +type ProjectOwner = { entityType: 'project'; entityId: string } +interface ArtifactInput extends ProjectFileTarget { + fileId: string + maxBytes: number + forModel?: boolean + preview?: boolean +} +interface SourceSnapshot { + file: WorkspaceFileRow + content: Buffer +} +interface ArtifactManifest { + source: WorkspaceFileRow + dependencies: WorkspaceFileRow[] + pageHtml?: string + generatedDocument: boolean +} +interface PreparedArtifact { + artifactKey?: string + writtenArtifactKeys: readonly string[] + manifest: ArtifactManifest + buffer: Buffer + contentType: string + dependsOnReferencedFiles: boolean +} +interface ArtifactResult { + file: OwnedFileRecord + buffer: Buffer + contentType: string + dependsOnReferencedFiles: boolean + secretProvenance: WorkspaceFileSecretProvenance + receipt: FileReadReceipt +} + +async function discardArtifactWrites(owner: ProjectOwner, keys: readonly string[]) { + if (keys.length === 0) return + const derivativePrefix = `project/${owner.entityId}/image-derivative/` + if ( + keys.some( + (key) => + !key.startsWith(`project/${owner.entityId}/compiled/`) && + !( + key.startsWith(derivativePrefix) && + /^[a-f0-9]{64}\.jpg$/.test(key.slice(derivativePrefix.length)) + ) + ) + ) + throw new Error('Artifact cleanup does not belong to this Project') + let events: string[] + try { + events = await enqueueWorkspaceFileStorageCleanups(db, keys, 'project') + } catch (error) { + logger.warn('Artifact cleanup could not be persisted; attempting direct deletion', { + owner, + keys, + error: describeError(error), + }) + for (const key of keys) { + try { + await deleteFile({ key, context: 'project' }) + } catch (cleanupError) { + logger.error('Uncommitted artifact could not be deleted or queued for cleanup', { + owner, + key, + error: describeError(cleanupError), + }) + } + } + return + } + await processWorkspaceFileStorageCleanupsNow(events, { owner, reason: 'artifact read failed' }) +} + +function requireSameRevision(current: WorkspaceFileRow | undefined, expected: WorkspaceFileRow) { + if ( + !current || + current.key !== expected.key || + current.contentUpdatedAt.getTime() !== expected.contentUpdatedAt.getTime() + ) { + throw new OrchestrationError('conflict', 'A document source or input changed during rendering') + } +} + +async function loadDependencies(tx: DbTransaction, owner: ProjectOwner, ids: string[]) { + if (ids.length === 0) return [] + const rows = await tx + .select() + .from(workspaceFiles) + .where( + and( + fileOwnerCondition(owner), + inArray(workspaceFiles.id, ids), + isNull(workspaceFiles.deletedAt) + ) + ) + .orderBy(asc(workspaceFiles.id)) + .for('share') + if (rows.length !== ids.length) + throw new OrchestrationError('not_found', 'Document input not found') + return rows +} + +const snapshotArtifact = defineAuthorizedProjectFileUseCase< + typeof projectFileOperations.readArtifact, + ArtifactInput & { source: SourceSnapshot }, + ArtifactManifest +>({ + operation: projectFileOperations.readArtifact, + async execute({ input, context, tx }) { + requireSameRevision(context.file, input.source.file) + const source = input.source.content.toString('utf-8') + const page = + (input.source.file.contentType === SIM_PAGE_CONTENT_TYPE || + input.source.file.originalName.toLowerCase().endsWith('.html')) && + isSimPageSource(source) + const pageHtml = page + ? renderSimPageDocument(source, { projectId: context.projectId }) + : undefined + const format = await getE2BDocFormat(input.source.file.originalName) + const generatedDocument = + !page && + format !== null && + !isCompiledDocumentBuffer(input.source.file.originalName, input.source.content) + const references = pageHtml ? collectSimPageFileReferences(pageHtml) : [] + if ( + references.some( + (reference) => reference.projectId && reference.projectId !== context.projectId + ) + ) { + throw new OrchestrationError('not_found', 'Document input not found') + } + const ids = [ + ...new Set( + pageHtml + ? references.map((reference) => reference.fileId) + : generatedDocument && format + ? collectReferencedFileIds( + source, + isDocSandboxEnabled + ? getDocumentSourceLanguage(source, format, input.source.file.contentType) + : 'javascript' + ) + : [] + ), + ] + if (ids.length > (pageHtml ? 256 : 500)) + throw new OrchestrationError( + 'payload_too_large', + 'Document exceeds the referenced input limit' + ) + const dependencies = await loadDependencies(tx, context.owner, ids) + return { source: input.source.file, dependencies, pageHtml, generatedDocument } + }, +}) + +/** Renders only source-owned dependencies, then rechecks the source, every input, and current authority. */ +export const readProjectFileArtifact = defineAuthorizedProjectFileUseCase< + typeof projectFileOperations.readArtifact, + ArtifactInput, + ArtifactResult, + PreparedArtifact +>({ + operation: projectFileOperations.readArtifact, + async prepare({ principal, input, context }) { + if ( + !Number.isSafeInteger(input.maxBytes) || + input.maxBytes <= 0 || + input.maxBytes > MAX_BUFFERED_TRANSFER_BYTES + ) { + throw new OrchestrationError('validation', 'Invalid document byte limit') + } + const file = context.file + if (!file) throw new OrchestrationError('not_found', 'File not found') + const writtenArtifactKeys = new Set() + try { + const artifact = await resolveDocumentRender( + file.originalName, + { maxBytes: input.maxBytes }, + async () => { + const content = await downloadFile({ + key: file.key, + context: 'project', + maxBytes: input.maxBytes, + }) + const manifest = await snapshotArtifact.execute({ + principal, + input: { ...input, source: { file, content } }, + }) + let remaining = manifest.pageHtml ? 32 * 1024 * 1024 : 50 * 1024 * 1024 + const inputs = [] + for (const dependency of manifest.dependencies) { + const bytes = await downloadFile({ + key: dependency.key, + context: 'project', + maxBytes: Math.min(remaining, manifest.pageHtml ? 8 * 1024 * 1024 : 25 * 1024 * 1024), + }) + remaining -= bytes.length + inputs.push({ + fileId: dependency.id, + contentType: dependency.contentType, + content: bytes, + }) + } + if (manifest.pageHtml) { + const byId = new Map(inputs.map((input) => [input.fileId, input])) + const page = await inlineSimPageImages( + manifest.pageHtml, + async ({ fileId }) => { + const asset = byId.get(fileId) + if (!asset) throw new OrchestrationError('not_found', 'Document input not found') + return { bytes: asset.content, contentType: asset.contentType, identity: fileId } + }, + { strict: true } + ) + const buffer = Buffer.from(page.html, 'utf-8') + assertKnownSizeWithinLimit(buffer.length, input.maxBytes, 'rendered page') + return { + manifest, + buffer, + contentType: 'text/html', + dependsOnReferencedFiles: inputs.length > 0, + } + } + if (manifest.generatedDocument) { + const inputIdentity = fileDocumentInputIdentity(context.owner, manifest.dependencies) + const compiled = await compileFileDocument({ + owner: context.owner, + source: content.toString('utf-8'), + fileName: file.originalName, + inputs, + onArtifactWrite: (key) => { + writtenArtifactKeys.add(key) + }, + inputIdentity, + maxBytes: input.maxBytes, + }) + return { manifest, ...compiled } + } + return { + manifest, + buffer: content, + contentType: file.contentType, + dependsOnReferencedFiles: false, + } + } + ) + const derivative = input.preview + ? await resolveServableImageBytes(artifact.buffer, artifact.manifest.source.key, { + owner: context.owner, + onArtifactWrite: (key) => writtenArtifactKeys.add(key), + }) + : null + if (derivative) + assertKnownSizeWithinLimit(derivative.buffer.length, input.maxBytes, 'image preview') + return { ...artifact, ...derivative, writtenArtifactKeys: [...writtenArtifactKeys] } + } catch (error) { + await discardArtifactWrites(context.owner, [...writtenArtifactKeys]) + throw error + } + }, + onCommitFailure: ({ context, prepared }) => + discardArtifactWrites(context.owner, prepared.writtenArtifactKeys), + async execute({ input, context, tx, prepared }) { + if (!prepared) throw new Error('Prepared document is unavailable') + requireSameRevision(context.file, prepared.manifest.source) + const dependencies = await loadDependencies( + tx, + context.owner, + prepared.manifest.dependencies.map((file) => file.id) + ) + const currentById = new Map(dependencies.map((file) => [file.id, file])) + for (const expected of prepared.manifest.dependencies) + requireSameRevision(currentById.get(expected.id), expected) + const source = context.file + if (!source) throw new OrchestrationError('not_found', 'File not found') + if (prepared.artifactKey) + await markFileSearchArtifactReadyInTx(tx, { + owner: context.owner, + file: { fileId: source.id, key: source.key, contentUpdatedAt: source.contentUpdatedAt }, + dependencies: dependencies.map((file) => ({ + fileId: file.id, + key: file.key, + sourceContentUpdatedAt: file.contentUpdatedAt, + })), + artifactKey: prepared.artifactKey, + }) + const evidence = await getBoundWorkspaceFileSecretProvenanceByMetadata(tx, [ + source, + ...dependencies, + ]) + const secretProvenance = mergeWorkspaceFileSecretProvenance(...evidence.values()) + if ( + input.forModel && + (secretProvenance.status !== 'exact' || secretProvenance.entries.length !== 0) + ) { + throw new OrchestrationError('forbidden', MODEL_UNSAFE_WORKSPACE_FILE_ERROR_MESSAGE) + } + const folders = source.folderId + ? await listFileFolders(context.owner, { scope: 'all' }, tx) + : [] + return { + file: mapFileRecord(source, context.owner, buildWorkspaceFileFolderPathMap(folders)), + buffer: prepared.buffer, + contentType: prepared.contentType, + dependsOnReferencedFiles: prepared.dependsOnReferencedFiles, + secretProvenance, + receipt: createFileReadReceipt(context.owner, [source, ...dependencies]), + } + }, + afterSuccess: ({ result }) => reportWorkspaceFileDelivery(result.secretProvenance), +}) diff --git a/apps/sim/lib/projects/files/application/authorization.ts b/apps/sim/lib/projects/files/application/authorization.ts new file mode 100644 index 00000000000..9a9ee656bf4 --- /dev/null +++ b/apps/sim/lib/projects/files/application/authorization.ts @@ -0,0 +1,294 @@ +import { type Principal, requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { + copilotChats, + permissions, + type WorkspaceFileRow, + workspace, + workspaceFiles, +} from '@sim/db/schema' +import { and, eq, isNull } from 'drizzle-orm' +import { requireOAuthOperationScope } from '@/lib/core/application/oauth-authorization' +import { requireOrganizationSubjectMembership } from '@/lib/core/application/organization-authorization' +import { requireResourceDelegation } from '@/lib/core/application/resource-delegation' +import { + PersonalApiKeysDisabledError, + PrincipalKindAuthorizationError, + requireCurrentHumanRole, + requireUserCredentialCapabilities, +} from '@/lib/core/application/workspace-authorization' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbTransaction } from '@/lib/db/types' +import { conversationModeSelection } from '@/lib/mothership/chat/intent' +import { assertWorkspaceCapability } from '@/lib/permission-groups/capability-assertions' +import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks' +import { loadProjectAccess } from '@/lib/projects/application/authorization' +import { resolveCopilotProjectScope } from '@/lib/projects/application/discovery' +import { + PROJECT_FILE_DELEGATION_TTL_MS, + type ProjectFileOperation, + type ProjectFilePrincipal, + projectFileOperations, +} from '@/lib/projects/files/application/operations' +import { requireProjectFileApiEnabled } from '@/lib/projects/rollout.server' +import { + requireCurrentFileSubject, + requireFileSubject, +} from '@/lib/workspace-files/application/subject' +import { matchesFileOwner, resolveFileOwner } from '@/lib/workspace-files/ownership' + +export interface ProjectFileTarget { + projectId: string + fileId?: string +} + +export interface ProjectFileAuthorizationContext { + projectId: string + organizationId: string | null + ownerUserId: string + owner: { entityType: 'project'; entityId: string } + canWrite: boolean + visibleWorkspaceIds: readonly string[] + file?: WorkspaceFileRow +} + +function requirePrincipal( + principal: Principal, + operation: ProjectFileOperation, + input: ProjectFileTarget +): asserts principal is ProjectFilePrincipal { + if (!Object.values(projectFileOperations).some((registered) => registered === operation)) { + throw new Error('Unregistered Project file operation') + } + if (!operation.principalKinds.some((kind) => kind === principal.kind)) { + throw new PrincipalKindAuthorizationError(principal.kind, operation.id) + } + requireOAuthOperationScope(principal, operation) + if (operation.target === 'file' && !input.fileId) { + throw new OrchestrationError('validation', 'File ID is required') + } + if (operation.target !== 'file' && input.fileId !== undefined) { + throw new OrchestrationError('validation', 'This operation targets the Project file collection') + } + if (principal.kind === 'resource_delegated') { + requireResourceDelegation(principal, { + audience: operation.delegationAudience, + services: operation.delegatedServices, + scope: + operation.target === 'collection_observation' + ? { + kind: 'file_collection_observation', + entityType: 'project', + entityId: input.projectId, + } + : { + kind: 'entity', + entityType: 'project', + entityId: input.projectId, + ...(input.fileId ? { fileId: input.fileId } : {}), + }, + maxTtlMs: PROJECT_FILE_DELEGATION_TTL_MS, + }) + } +} + +/** Rechecks canonical conversation ownership and authoring capability while authorization locks are held. */ +export async function requireCurrentCopilotProjectInvocation( + tx: DbTransaction, + principal: Extract, + access: Awaited>, + scope: Awaited> +) { + if (scope.organizationId !== access.record.organizationId) { + throw new OrchestrationError('not_found', 'Project not found in this conversation') + } + let workspaceId: string | null + if (principal.invocation.kind === 'chat') { + const [chat] = await tx + .select({ + userId: copilotChats.userId, + workspaceId: copilotChats.workspaceId, + organizationId: copilotChats.organizationId, + type: copilotChats.type, + mode: conversationModeSelection, + }) + .from(copilotChats) + .where(and(eq(copilotChats.id, principal.invocation.chatId), isNull(copilotChats.deletedAt))) + .for('share') + .limit(1) + if ( + !chat || + chat.userId !== principal.subjectUserId || + Boolean(chat.workspaceId) === Boolean(chat.organizationId) + ) { + throw new OrchestrationError('not_found', 'Chat not found') + } + if (chat.organizationId) { + if ( + chat.organizationId !== scope.organizationId || + chat.organizationId !== access.record.organizationId || + chat.type !== 'mothership' || + (chat.mode !== 'agent' && chat.mode !== 'plan') + ) { + throw new OrchestrationError('not_found', 'Project not found in this conversation') + } + await requireOrganizationSubjectMembership( + principal.subjectUserId, + chat.organizationId, + 'member', + 'copilot.use', + undefined, + { executor: tx } + ) + return + } + workspaceId = chat.workspaceId + } else { + workspaceId = principal.invocation.workspaceId + } + if (!workspaceId || workspaceId !== scope.workspaceId) { + throw new OrchestrationError('not_found', 'Project not found in this conversation') + } + const [origin] = await tx + .select({ + workspaceId: workspace.id, + workspaceOrganizationId: workspace.organizationId, + allowPersonalApiKeys: workspace.allowPersonalApiKeys, + }) + .from(workspace) + .where(and(eq(workspace.id, workspaceId), isNull(workspace.archivedAt))) + .for('share') + .limit(1) + if (!origin || origin.workspaceOrganizationId !== scope.organizationId) + throw new OrchestrationError('not_found', 'Workspace not found in this conversation') + // Hold origin grants without upgrading the target's existing SHARE locks. + await tx + .select({ id: permissions.id }) + .from(permissions) + .where( + and( + eq(permissions.userId, principal.subjectUserId), + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, workspaceId) + ) + ) + .for('share') + await requireCurrentHumanRole(principal.subjectUserId, origin, 'read', { executor: tx }) + await assertWorkspaceCapability( + principal.subjectUserId, + workspaceId, + 'copilot.use', + origin.workspaceOrganizationId, + tx + ) +} + +/** Shared owner policy keeps compound copies subject to the same live Project edit rule. */ +export async function requireProjectFileOwnerRole( + tx: DbTransaction, + principal: ProjectFilePrincipal, + access: Awaited>, + accessMode: 'read' | 'write' +): Promise { + if (access.record.archivedAt) throw new OrchestrationError('conflict', 'Project is archived') + await requireCurrentFileSubject(tx, principal) + const canWrite = + access.orgAdmin || + access.active.some((row) => row.permission === 'admin') || + (access.active.length > 0 && + access.active.every((row) => row.permission === 'write' || row.permission === 'admin')) + if (accessMode === 'write' && !canWrite) { + throw new OrchestrationError( + 'forbidden', + 'Project file editing requires admin access in an environment or write access in every active environment' + ) + } + return { + projectId: access.record.id, + organizationId: access.record.organizationId, + ownerUserId: access.record.ownerId, + owner: { entityType: 'project', entityId: access.record.id }, + canWrite, + visibleWorkspaceIds: access.visible.map((environment) => environment.id), + } +} + +/** Credential and Files capability checks apply to every currently visible environment. */ +export async function requireProjectFileOwnerCapabilities( + tx: DbTransaction, + principal: ProjectFilePrincipal, + access: Awaited>, + capability: ProjectFileOperation['capability'] +): Promise { + const userId = requirePrincipalSubjectUserId(principal) + for (const environment of access.visible) { + const context = { + workspaceId: environment.id, + workspaceOrganizationId: access.record.organizationId, + allowPersonalApiKeys: environment.allowPersonalApiKeys, + } + if (principal.kind === 'personal_api_key' || principal.kind === 'oauth_access_token') { + if (!context.allowPersonalApiKeys) throw new PersonalApiKeysDisabledError() + await requireUserCredentialCapabilities(principal, context, tx) + } + // permission-group-enforced: files.use — every accessible active environment applies, independent of Issues. + await assertWorkspaceCapability( + userId, + environment.id, + capability, + access.record.organizationId, + tx + ) + } +} + +/** Prepares trusted invocation outside the transaction; the returned closure rechecks authority inside it. */ +export async function createProjectFileAuthorizer( + principal: Principal, + operation: ProjectFileOperation, + input: ProjectFileTarget +): Promise<(tx: DbTransaction) => Promise> { + requirePrincipal(principal, operation, input) + await requireProjectFileApiEnabled() + const userId = await requireFileSubject(principal) + const invocationScope = + principal.kind === 'resource_delegated' && principal.serviceId === 'copilot' + ? await resolveCopilotProjectScope(principal) + : undefined + + return async (tx) => { + requirePrincipal(principal, operation, input) + const access = await loadProjectAccess(tx, userId, { projectId: input.projectId }) + const context = await requireProjectFileOwnerRole(tx, principal, access, operation.access) + let file: WorkspaceFileRow | undefined + if (operation.target === 'file') { + const query = tx + .select() + .from(workspaceFiles) + .where( + and( + eq(workspaceFiles.id, input.fileId ?? ''), + 'fileScope' in operation && operation.fileScope === 'all' + ? undefined + : isNull(workspaceFiles.deletedAt) + ) + ) + const rows = await (operation.access === 'write' ? query : query.for('share')).limit(1) + file = rows[0] + const owner = file ? resolveFileOwner(file) : null + if (!matchesFileOwner(owner, context.owner)) { + throw new OrchestrationError('not_found', 'File not found') + } + } + if (access.record.organizationId) + await acquirePermissionGroupOrgLock(tx, access.record.organizationId) + if (principal.kind === 'resource_delegated' && principal.serviceId === 'copilot') { + if (!invocationScope) throw new Error('Copilot Project invocation was not prepared') + await requireCurrentCopilotProjectInvocation(tx, principal, access, invocationScope) + } + await requireProjectFileOwnerCapabilities(tx, principal, access, operation.capability) + return { + ...context, + ...(file ? { file } : {}), + } + } +} diff --git a/apps/sim/lib/projects/files/application/authorized-use-case.ts b/apps/sim/lib/projects/files/application/authorized-use-case.ts new file mode 100644 index 00000000000..13431e568de --- /dev/null +++ b/apps/sim/lib/projects/files/application/authorized-use-case.ts @@ -0,0 +1,142 @@ +import type { Principal } from '@sim/auth/principal' +import { db } from '@sim/db' +import { createLogger } from '@sim/logger' +import { describeError, isPostgresCommitRejection } from '@sim/utils/errors' +import { + type AuthorizingUseCase, + recordProjectedUseCaseAuditEntries, + type WorkspaceUseCaseAuditEntry, +} from '@/lib/core/application/authorized-workspace-use-case' +import { runWithOutboundOrganization } from '@/lib/core/network/context.server' +import type { OrchestrationRequestContext } from '@/lib/core/orchestration/types' +import type { DbTransaction } from '@/lib/db/types' +import { + createProjectFileAuthorizer, + type ProjectFileAuthorizationContext, + type ProjectFileTarget, +} from '@/lib/projects/files/application/authorization' +import type { ProjectFileOperation } from '@/lib/projects/files/application/operations' +import { notifyFileListChanged } from '@/lib/realtime/notify' + +const logger = createLogger('ProjectFileUseCase') + +interface ProjectFileUseCaseContext { + principal: Principal + input: I + context: ProjectFileAuthorizationContext + request?: OrchestrationRequestContext +} + +/** Project file operations authorize and mutate under the same canonical Project transaction. */ +export function defineAuthorizedProjectFileUseCase< + const O extends ProjectFileOperation, + I extends ProjectFileTarget, + R, + P = undefined, +>(definition: { + operation: O + prepare?(args: ProjectFileUseCaseContext): Promise

+ execute(args: ProjectFileUseCaseContext & { tx: DbTransaction; prepared?: P }): Promise + onCommitFailure?( + args: ProjectFileUseCaseContext & { prepared: P; error: unknown } + ): Promise + projectAudit?( + args: ProjectFileUseCaseContext & { result: NoInfer } + ): WorkspaceUseCaseAuditEntry | WorkspaceUseCaseAuditEntry[] + afterSuccess?(args: ProjectFileUseCaseContext & { result: NoInfer }): void | Promise + invalidatesFileList?: + | boolean + | ((args: ProjectFileUseCaseContext & { result: NoInfer }) => boolean) +}): AuthorizingUseCase { + return { + operation: definition.operation, + delegationAudience: definition.operation.delegationAudience, + async authorize(args) { + const authorize = await createProjectFileAuthorizer( + args.principal, + definition.operation, + args.input + ) + await db.transaction(authorize) + }, + async execute(args) { + let prepared: P | undefined + let preparationContext: ProjectFileAuthorizationContext | undefined + const prepare = definition.prepare + if (prepare) { + const preflight = await createProjectFileAuthorizer( + args.principal, + definition.operation, + args.input + ) + const context = await db.transaction(preflight) + preparationContext = context + prepared = await runWithOutboundOrganization(context.organizationId, () => + prepare({ ...args, context }) + ) + } + let committed: { context: ProjectFileAuthorizationContext; result: R } + let callbackCompleted = false + try { + const authorize = await createProjectFileAuthorizer( + args.principal, + definition.operation, + args.input + ) + committed = await db.transaction(async (tx) => { + const context = await authorize(tx) + const result = await runWithOutboundOrganization(context.organizationId, () => + definition.execute({ ...args, context, tx, prepared }) + ) + callbackCompleted = true + return { context, result } + }) + } catch (error) { + if (callbackCompleted && !isPostgresCommitRejection(error)) { + logger.error('Project file commit outcome is uncertain; retaining prepared resources', { + operation: definition.operation.id, + projectId: args.input.projectId, + fileId: args.input.fileId, + error: describeError(error), + }) + } else if (prepared !== undefined && preparationContext) { + try { + await definition.onCommitFailure?.({ + ...args, + context: preparationContext, + prepared, + error, + }) + } catch (cleanupError) { + logger.error('Project file failure cleanup could not complete', { + operation: definition.operation.id, + owner: preparationContext.owner, + fileId: args.input.fileId, + error: describeError(cleanupError), + }) + } + } + throw error + } + const { context, result } = committed + const resultContext = { ...args, context, result } + const invalidates = definition.invalidatesFileList + if (typeof invalidates === 'function' ? invalidates(resultContext) : invalidates) { + await notifyFileListChanged(context.owner) + } + const audit = definition.projectAudit?.(resultContext) + if (audit !== undefined) { + recordProjectedUseCaseAuditEntries( + definition.operation, + null, + args.principal, + args.request, + Array.isArray(audit) ? audit : [audit], + context.organizationId ?? undefined + ) + } + await definition.afterSuccess?.(resultContext) + return result + }, + } +} diff --git a/apps/sim/lib/projects/files/application/collection-access.ts b/apps/sim/lib/projects/files/application/collection-access.ts new file mode 100644 index 00000000000..6910af58b82 --- /dev/null +++ b/apps/sim/lib/projects/files/application/collection-access.ts @@ -0,0 +1,10 @@ +import { defineAuthorizedProjectFileUseCase } from '@/lib/projects/files/application/authorized-use-case' +import { projectFileOperations } from '@/lib/projects/files/application/operations' + +/** Collection subscribers use current Project read policy without receiving any file authority. */ +export const getProjectFileListAccess = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.observeCollection, + async execute({ context }) { + return { projectId: context.projectId, canRead: true as const } + }, +}) diff --git a/apps/sim/lib/projects/files/application/content.ts b/apps/sim/lib/projects/files/application/content.ts new file mode 100644 index 00000000000..ce0d5557bf2 --- /dev/null +++ b/apps/sim/lib/projects/files/application/content.ts @@ -0,0 +1,305 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { type Principal, requirePrincipalSubjectUserId } from '@sim/auth/principal' +import type { PreparedCollabDocState } from '@/lib/collab-doc/collab-state' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbTransaction } from '@/lib/db/types' +import type { + ProjectFileAuthorizationContext, + ProjectFileTarget, +} from '@/lib/projects/files/application/authorization' +import { defineAuthorizedProjectFileUseCase } from '@/lib/projects/files/application/authorized-use-case' +import { projectFileOperations } from '@/lib/projects/files/application/operations' +import { + finishProjectFileWrite, + mapProjectFileResult, + prepareProjectFileAccounting, + recordProjectFileWriteEffects, +} from '@/lib/projects/files/application/write-effects' +import { + ContentVersionConflictError, + commitFileContentInTx, + commitFileCreateInTx, + discardStagedFileContent, + type StagedFileContent, + stageFileContent, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { + EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE, + getBoundWorkspaceFileSecretProvenanceByMetadata, + type WorkspaceFileSecretProvenance, +} from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' +import { downloadFile } from '@/lib/uploads/core/storage-service' +import { enqueueFileLiveDocReconciliation } from '@/lib/uploads/server/live-doc-outbox' +import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' +import { isMarkdownFile } from '@/lib/uploads/utils/file-utils' +import { + hasWorkspaceFileDeliveryObserver, + reportWorkspaceFileDelivery, +} from '@/lib/workspace-files/application/file-delivery-observer' +import { parseWorkspaceFileRevision } from '@/lib/workspace-files/application/file-revision' +import { resolveWorkspaceFileVersionWrite } from '@/lib/workspace-files/application/file-version-write' +import { MAX_WORKSPACE_FILE_CONTENT_BYTES } from '@/lib/workspace-files/orchestration' +import { SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile' +import { restoreSimPageSourceBuffer } from '@/lib/workspace-files/page-source-embed' + +interface ContentUseCaseArgs { + principal: Principal + input: I + context: ProjectFileAuthorizationContext + tx: DbTransaction + prepared?: P +} + +export interface CreateProjectFileInput extends ProjectFileTarget { + name: string + contentType: string + content: string + encoding: 'utf-8' | 'base64' + folderId?: string | null + folderPath?: string + exactName?: boolean + secretProvenance?: WorkspaceFileSecretProvenance +} + +export interface UpdateProjectFileContentInput extends ProjectFileTarget { + fileId: string + /** Private realtime state committed under the same file revision fence; absent from HTTP contracts. */ + collabDocState?: PreparedCollabDocState + content: string + encoding: 'utf-8' | 'base64' + contentType?: string + expectedRevision?: string + expectedUpdatedAt?: Date + provenanceMode?: 'replace_empty' | 'preserve' + secretProvenance?: WorkspaceFileSecretProvenance +} + +export interface ReadProjectFileContentInput extends ProjectFileTarget { + fileId: string + maxBytes?: number + includeSecretProvenance?: boolean +} + +function requireFile(context: ProjectFileAuthorizationContext) { + if (!context.file) throw new OrchestrationError('not_found', 'File not found') + return context.file +} + +function decodeContent(input: { content: string; encoding: 'utf-8' | 'base64' }) { + const content = Buffer.from(input.content, input.encoding) + if (content.length > MAX_WORKSPACE_FILE_CONTENT_BYTES) + throw new OrchestrationError( + 'payload_too_large', + 'File content exceeds the inline upload limit' + ) + return content +} + +function replacementProvenance( + principal: Principal, + provenance?: WorkspaceFileSecretProvenance +): WorkspaceFileSecretProvenance { + return ( + provenance ?? + (principal.kind === 'resource_delegated' + ? { status: 'unknown' } + : EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE) + ) +} + +export const createProjectFile = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.create, + invalidatesFileList: true, + async prepare({ + principal, + input, + context, + }: Omit, 'tx'>) { + const content = decodeContent(input) + const restored = restoreSimPageSourceBuffer(input.name, content) + return stageFileContent({ + owner: context.owner, + userId: requirePrincipalSubjectUserId(principal), + name: restored?.name ?? input.name, + contentType: restored ? SIM_PAGE_CONTENT_TYPE : input.contentType, + content: restored?.buffer ?? content, + }) + }, + async execute({ + principal, + input, + context, + tx, + prepared, + }: ContentUseCaseArgs) { + if (!prepared) throw new Error('File content was not staged') + const accounting = await prepareProjectFileAccounting(tx, context) + const file = await commitFileCreateInTx(tx, { + owner: context.owner, + staged: prepared, + userId: requirePrincipalSubjectUserId(principal), + folderId: input.folderId, + folderPath: input.folderPath, + exactName: input.exactName, + secretProvenance: replacementProvenance(principal, input.secretProvenance), + }) + const usage = await accounting.mutation.applyDelta(prepared.size) + const result = await mapProjectFileResult(tx, context, file) + recordProjectFileWriteEffects(result, { + billing: accounting.billing, + usage, + delta: prepared.size, + cleanupIds: [], + }) + return result + }, + onCommitFailure: ({ prepared }) => discardStagedFileContent(prepared), + afterSuccess: ({ result }) => finishProjectFileWrite(result), + projectAudit: ({ input, result }) => ({ + action: AuditAction.FILE_UPLOADED, + resourceType: AuditResourceType.FILE, + resourceId: result.file.id, + resourceName: result.file.name, + description: `Uploaded Project file "${result.file.name}"`, + metadata: { + projectId: input.projectId, + fileSize: result.file.size, + fileType: result.file.type, + }, + }), +}) + +export const updateProjectFileContent = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.updateContent, + invalidatesFileList: ({ context, result }) => context.file?.key !== result.file.key, + async prepare({ + principal, + input, + context, + }: Omit, 'tx'>) { + const file = requireFile(context) + return stageFileContent({ + owner: context.owner, + userId: requirePrincipalSubjectUserId(principal), + name: file.originalName, + contentType: input.contentType ?? file.contentType, + content: decodeContent(input), + }) + }, + async execute({ + principal, + input, + context, + tx, + prepared, + }: ContentUseCaseArgs) { + if (!prepared) throw new Error('File content was not staged') + const accounting = await prepareProjectFileAccounting(tx, context) + let committed + try { + committed = await commitFileContentInTx(tx, { + owner: context.owner, + fileId: input.fileId, + staged: prepared, + version: resolveWorkspaceFileVersionWrite(principal), + collabDocState: input.collabDocState, + expectedUpdatedAt: input.expectedRevision + ? parseWorkspaceFileRevision(input.expectedRevision, input.fileId) + : input.expectedUpdatedAt, + secretProvenancePolicy: + input.provenanceMode === 'preserve' + ? { mode: 'preserve' } + : { + mode: 'replace', + provenance: replacementProvenance(principal, input.secretProvenance), + }, + }) + } catch (error) { + if (error instanceof ContentVersionConflictError) + throw new OrchestrationError('conflict', error.message) + throw error + } + const liveDocEventId = + !input.collabDocState && + (isMarkdownFile({ + name: committed.previous.originalName, + type: committed.previous.contentType, + }) || + isMarkdownFile({ name: committed.file.originalName, type: committed.file.contentType })) + ? await enqueueFileLiveDocReconciliation(tx, { + owner: context.owner, + fileId: committed.file.id, + version: committed.file.contentUpdatedAt.getTime(), + }) + : undefined + const usage = await accounting.mutation.applyDelta(committed.sizeDiff) + const mapped = await mapProjectFileResult(tx, context, committed.file) + const result = { ...mapped, file: { ...mapped.file, currentVersion: committed.currentVersion } } + recordProjectFileWriteEffects(result, { + billing: accounting.billing, + usage, + delta: committed.sizeDiff, + cleanupIds: committed.storageCleanupEventIds, + liveDocEventId, + }) + return result + }, + onCommitFailure: ({ prepared }) => discardStagedFileContent(prepared), + afterSuccess: ({ result }) => finishProjectFileWrite(result), + projectAudit: ({ input, result }) => ({ + action: AuditAction.FILE_UPDATED, + resourceType: AuditResourceType.FILE, + resourceId: result.file.id, + resourceName: result.file.name, + description: `Updated Project file "${result.file.name}"`, + metadata: { projectId: input.projectId, contentSize: result.file.size }, + }), +}) + +interface PreparedFileRead { + key: string + revision: Date + content: Buffer +} + +export const readProjectFileContent = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.readContent, + async prepare({ + input, + context, + }: Omit, 'tx'>): Promise { + const file = requireFile(context) + const content = await downloadFile({ + key: file.key, + context: 'project', + maxBytes: input.maxBytes ?? MAX_BUFFERED_TRANSFER_BYTES, + }) + return { key: file.key, revision: file.contentUpdatedAt, content } + }, + async execute({ + input, + context, + tx, + prepared, + }: ContentUseCaseArgs) { + const file = requireFile(context) + if ( + !prepared || + file.key !== prepared.key || + file.contentUpdatedAt.getTime() !== prepared.revision.getTime() + ) + throw new OrchestrationError('conflict', 'File changed while its content was being read') + const provenance = + input.includeSecretProvenance || hasWorkspaceFileDeliveryObserver() + ? ((await getBoundWorkspaceFileSecretProvenanceByMetadata(tx, [file])).get(file.id) ?? { + status: 'unknown' as const, + }) + : undefined + return { + ...(await mapProjectFileResult(tx, context, file)), + content: prepared.content, + ...(provenance ? { secretProvenance: provenance } : {}), + } + }, + afterSuccess: ({ result }) => reportWorkspaceFileDelivery(result.secretProvenance), +}) diff --git a/apps/sim/lib/projects/files/application/document-lifecycle.ts b/apps/sim/lib/projects/files/application/document-lifecycle.ts index 2335d586dcb..f836c8479a1 100644 --- a/apps/sim/lib/projects/files/application/document-lifecycle.ts +++ b/apps/sim/lib/projects/files/application/document-lifecycle.ts @@ -1,7 +1,83 @@ +import { workspaceFiles } from '@sim/db/schema' +import { FILE_DOC_SEED } from '@sim/realtime-protocol/file-doc' +import { generateId } from '@sim/utils/id' import { isRecordLike } from '@sim/utils/object' -import type { OutboxHandlerRegistry } from '@/lib/core/outbox/service' +import { and, eq } from 'drizzle-orm' +import * as Y from 'yjs' +import { loadCollabDocState, saveCollabDocStateInTx } from '@/lib/collab-doc/collab-state' +import { + enqueueOutboxEvent, + type OutboxHandlerRegistry, + processOutboxEventById, +} from '@/lib/core/outbox/service' +import type { DbTransaction } from '@/lib/db/types' import { PROJECT_FILE_DOCUMENT_RETIRE_EVENT } from '@/lib/projects/files/outbox-events' -import { retireLiveProjectFileDoc } from '@/lib/realtime/notify' +import { retireLiveFileDoc } from '@/lib/realtime/notify' + +interface ProjectDocumentRetirement { + projectId: string + fileId: string + retiredDocId: string + replacementDocId: string +} + +function documentIdentity(state: Uint8Array): string { + const doc = new Y.Doc() + try { + Y.applyUpdate(doc, state) + const id = doc.getMap(FILE_DOC_SEED.configMap).get(FILE_DOC_SEED.docIdKey) + if (typeof id !== 'string' || !id || id.length > 128) + throw new Error('Project document has no valid cached identity') + return id + } finally { + doc.destroy() + } +} + +/** Read a bounded canonical identity while the caller holds the authorized file row lock. */ +export async function readProjectFileDocIdInTx(tx: DbTransaction, fileId: string) { + const cached = await loadCollabDocState(fileId, undefined, tx) + return cached ? documentIdentity(cached.docState) : null +} + +/** Archive and restore retire the cached history atomically with the file lifecycle mutation. */ +export async function rotateProjectFileDocInTx( + tx: DbTransaction, + target: { projectId: string; fileId: string } +): Promise<{ outboxEventId: string } | null> { + const [file] = await tx + .select({ id: workspaceFiles.id }) + .from(workspaceFiles) + .where( + and( + eq(workspaceFiles.id, target.fileId), + eq(workspaceFiles.projectId, target.projectId), + eq(workspaceFiles.context, 'project') + ) + ) + .for('update') + .limit(1) + if (!file) throw new Error('Project document lifecycle target does not match its owner') + const cached = await loadCollabDocState(target.fileId, undefined, tx) + if (!cached) return null + const retiredDocId = documentIdentity(cached.docState) + const replacementDocId = generateId() + const replacement = new Y.Doc() + try { + replacement.getMap(FILE_DOC_SEED.configMap).set(FILE_DOC_SEED.docIdKey, replacementDocId) + await saveCollabDocStateInTx(tx, target.fileId, { + docState: Y.encodeStateAsUpdate(replacement), + sourceHash: '', + expectedState: cached, + }) + } finally { + replacement.destroy() + } + const payload: ProjectDocumentRetirement = { ...target, retiredDocId, replacementDocId } + return { + outboxEventId: await enqueueOutboxEvent(tx, PROJECT_FILE_DOCUMENT_RETIRE_EVENT, payload), + } +} export const projectFileDocumentOutboxHandlers = { [PROJECT_FILE_DOCUMENT_RETIRE_EVENT]: async (payload, context) => { @@ -18,9 +94,9 @@ export const projectFileDocumentOutboxHandlers = { ) throw new Error('Invalid Project document retirement') context.signal.throwIfAborted() - await retireLiveProjectFileDoc( + await retireLiveFileDoc( { - projectId: payload.projectId, + owner: { entityType: 'project', entityId: payload.projectId }, fileId: payload.fileId, retiredDocId: payload.retiredDocId, replacementDocId: payload.replacementDocId, @@ -29,3 +105,8 @@ export const projectFileDocumentOutboxHandlers = { ) }, } satisfies OutboxHandlerRegistry + +/** Attempt committed retirement immediately; the durable worker retries unavailable relays. */ +export function processProjectFileDocRetirementNow(eventId: string) { + return processOutboxEventById(eventId, projectFileDocumentOutboxHandlers) +} diff --git a/apps/sim/lib/projects/files/application/documents.ts b/apps/sim/lib/projects/files/application/documents.ts new file mode 100644 index 00000000000..9c3b503929a --- /dev/null +++ b/apps/sim/lib/projects/files/application/documents.ts @@ -0,0 +1,244 @@ +import { db } from '@sim/db' +import { workspaceFiles } from '@sim/db/schema' +import { and, eq, isNull } from 'drizzle-orm' +import * as Y from 'yjs' +import { + assertCollabDocStateSize, + CollabDocStateConflictError, + hashMarkdown, + loadCollabDocState, + type PreparedCollabDocState, + saveCollabDocStateInTx, +} from '@/lib/collab-doc/collab-state' +import { yDocToFileMarkdown } from '@/lib/collab-doc/converter' +import { type PersistFileDocResult, preparePersistedState } from '@/lib/collab-doc/persist' +import { type FileDocSeed, prepareFileDocSeed } from '@/lib/collab-doc/seed' +import type { AuthorizingUseCase } from '@/lib/core/application/authorized-workspace-use-case' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { + createProjectFileAuthorizer, + type ProjectFileAuthorizationContext, +} from '@/lib/projects/files/application/authorization' +import { defineAuthorizedProjectFileUseCase } from '@/lib/projects/files/application/authorized-use-case' +import { + readProjectFileContent, + updateProjectFileContent, +} from '@/lib/projects/files/application/content' +import { readProjectFileDocIdInTx } from '@/lib/projects/files/application/document-lifecycle' +import { projectFileOperations } from '@/lib/projects/files/application/operations' +import { isMarkdownFile } from '@/lib/uploads/utils/file-utils' + +interface ProjectDocumentTarget { + projectId: string + fileId: string +} + +interface ProjectDocumentCacheInput extends ProjectDocumentTarget { + expectedVersion: number + state: PreparedCollabDocState +} + +interface ProjectDocumentPersistInput extends ProjectDocumentTarget { + docState: Uint8Array + expectedVersion?: number +} + +const MAX_SEED_BYTES = 5 * 1024 * 1024 +const MAX_ATTEMPTS = 3 + +function requireProjectDocument(context: ProjectFileAuthorizationContext) { + if (!context.file) throw new OrchestrationError('not_found', 'File not found') + if (!isMarkdownFile({ name: context.file.originalName, type: context.file.contentType })) { + throw new OrchestrationError('validation', 'Collaborative documents must be Markdown files') + } +} + +/** Current read membership and the separate write capability for a Project document subscriber. */ +export const getProjectFileDocAccess = defineAuthorizedProjectFileUseCase< + typeof projectFileOperations.readContent, + ProjectDocumentTarget, + ProjectDocumentTarget & { canRead: true; canWrite: boolean; docId: string | null } +>({ + operation: projectFileOperations.readContent, + async execute({ context, input, tx }) { + requireProjectDocument(context) + return { + ...input, + canRead: true as const, + canWrite: context.canWrite, + docId: await readProjectFileDocIdInTx(tx, input.fileId), + } + }, +}) + +function cacheUseCase( + operation: typeof projectFileOperations.readContent | typeof projectFileOperations.updateContent +) { + return defineAuthorizedProjectFileUseCase< + typeof operation, + ProjectDocumentCacheInput, + { version: number } + >({ + operation, + async execute({ input, tx, context }) { + requireProjectDocument(context) + const [file] = await tx + .select({ version: workspaceFiles.contentUpdatedAt }) + .from(workspaceFiles) + .where( + and( + eq(workspaceFiles.id, input.fileId), + eq(workspaceFiles.projectId, input.projectId), + eq(workspaceFiles.context, 'project'), + isNull(workspaceFiles.deletedAt) + ) + ) + .for('share') + .limit(1) + if (!file) throw new OrchestrationError('not_found', 'File not found') + if (file.version.getTime() !== input.expectedVersion) + throw new OrchestrationError('conflict', 'File changed while preparing its document') + await saveCollabDocStateInTx(tx, input.fileId, input.state) + return { version: file.version.getTime() } + }, + }) +} + +const seedCache = cacheUseCase(projectFileOperations.readContent) +const persistCache = cacheUseCase(projectFileOperations.updateContent) + +function isDocumentConflict(error: unknown) { + return ( + error instanceof CollabDocStateConflictError || + (error instanceof OrchestrationError && error.code === 'conflict') + ) +} + +/** The seed cache is a read projection, fenced against both durable bytes and cached CRDT history. */ +export const buildProjectFileDocSeed: AuthorizingUseCase< + typeof projectFileOperations.readContent, + ProjectDocumentTarget, + FileDocSeed +> = { + operation: projectFileOperations.readContent, + delegationAudience: projectFileOperations.readContent.delegationAudience, + authorize: async (args) => { + await getProjectFileDocAccess.execute(args) + }, + async execute(args) { + await getProjectFileDocAccess.execute(args) + for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { + try { + const read = await readProjectFileContent.execute({ + ...args, + input: { ...args.input, maxBytes: MAX_SEED_BYTES }, + }) + if (!isMarkdownFile(read.file)) { + throw new OrchestrationError( + 'validation', + 'Collaborative documents must be Markdown files' + ) + } + const cached = await loadCollabDocState(args.input.fileId) + const update = prepareFileDocSeed(args.input.fileId, read.content, cached) + const accepted = await seedCache.execute({ + ...args, + input: { + ...args.input, + expectedVersion: read.file.contentUpdatedAt.getTime(), + state: { + docState: update, + sourceHash: hashMarkdown(read.content), + expectedState: cached + ? { sourceHash: cached.sourceHash, stateHash: cached.stateHash } + : null, + }, + }, + }) + return { update, version: accepted.version } + } catch (error) { + if (!isDocumentConflict(error)) throw error + } + } + throw new OrchestrationError('conflict', 'File changed while preparing its document') + }, +} + +/** Commit a snapshot through the same content, provenance, version and billing transaction as edits. */ +export const persistProjectFileDoc: AuthorizingUseCase< + typeof projectFileOperations.updateContent, + ProjectDocumentPersistInput, + PersistFileDocResult +> = { + operation: projectFileOperations.updateContent, + delegationAudience: projectFileOperations.updateContent.delegationAudience, + async authorize(args) { + const authorize = await createProjectFileAuthorizer( + args.principal, + projectFileOperations.updateContent, + args.input + ) + requireProjectDocument(await db.transaction(authorize)) + }, + async execute(args) { + await this.authorize(args) + if (args.input.expectedVersion === undefined) return { status: 'deferred' } + assertCollabDocStateSize(args.input.docState) + const doc = new Y.Doc() + let markdown: Buffer + try { + Y.applyUpdate(doc, args.input.docState) + markdown = Buffer.from(yDocToFileMarkdown(doc), 'utf-8') + } finally { + doc.destroy() + } + for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { + try { + const read = await readProjectFileContent.execute(args) + const version = read.file.contentUpdatedAt.getTime() + if (!isMarkdownFile(read.file)) { + throw new OrchestrationError( + 'validation', + 'Collaborative documents must be Markdown files' + ) + } + const cached = await loadCollabDocState(args.input.fileId) + const sameContent = read.content.equals(markdown) + const prepared = preparePersistedState( + args.input.docState, + cached, + markdown, + version !== args.input.expectedVersion && !sameContent + ) + if (!prepared) return { status: 'conflict' } + if (sameContent) { + const accepted = await persistCache.execute({ + ...args, + input: { ...args.input, expectedVersion: version, state: prepared }, + }) + return { status: 'persisted', version: accepted.version } + } + if ( + version !== args.input.expectedVersion && + cached?.sourceHash !== hashMarkdown(read.content) + ) + return { status: 'conflict' } + const updated = await updateProjectFileContent.execute({ + ...args, + input: { + ...args.input, + content: markdown.toString('utf-8'), + encoding: 'utf-8', + expectedUpdatedAt: read.file.contentUpdatedAt, + provenanceMode: 'preserve', + collabDocState: prepared, + }, + }) + return { status: 'persisted', version: updated.file.contentUpdatedAt.getTime() } + } catch (error) { + if (!isDocumentConflict(error)) throw error + } + } + return { status: 'conflict' } + }, +} diff --git a/apps/sim/lib/projects/files/application/downloads.ts b/apps/sim/lib/projects/files/application/downloads.ts new file mode 100644 index 00000000000..4a85fe88843 --- /dev/null +++ b/apps/sim/lib/projects/files/application/downloads.ts @@ -0,0 +1,442 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { type Principal, requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { type WorkspaceFileRow, workspaceFiles } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { PASTE_LIMITS, utf8ByteLength } from '@sim/utils/paste' +import { compareStrings } from '@sim/utils/string' +import { and, asc, inArray, isNull, or } from 'drizzle-orm' +import JSZip from 'jszip' +import { asOrchestrationError, OrchestrationError } from '@/lib/core/orchestration/types' +import { assertKnownSizeWithinLimit } from '@/lib/core/utils/stream-limits' +import type { DbTransaction } from '@/lib/db/types' +import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' +import { assertWorkspaceCapability } from '@/lib/permission-groups/capability-assertions' +import { readProjectFileArtifact } from '@/lib/projects/files/application/artifacts' +import type { + ProjectFileAuthorizationContext, + ProjectFileTarget, +} from '@/lib/projects/files/application/authorization' +import { defineAuthorizedProjectFileUseCase } from '@/lib/projects/files/application/authorized-use-case' +import { projectFileOperations } from '@/lib/projects/files/application/operations' +import { + buildWorkspaceFileFolderPathMap, + loadActiveFileFolderPathIndex, + mapFileRecord, +} from '@/lib/uploads/contexts/workspace' +import { + mergeWorkspaceFileSecretProvenance, + type WorkspaceFileSecretProvenance, +} from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' +import { downloadFile } from '@/lib/uploads/core/storage-service' +import { extractEmbeddedFileRefs } from '@/lib/uploads/server/embedded-image-refs' +import { + createMarkdownExport, + MAX_EXPORT_ASSET_BYTES, + MAX_EXPORT_TOTAL_BYTES, + type MarkdownExportAsset, + type MarkdownExportResult, + MarkdownExportSizeError, +} from '@/lib/uploads/server/markdown-export' +import { bufferZipWithinLimit } from '@/lib/uploads/server/zip' +import { getWorkspaceFileSize } from '@/lib/uploads/shared/types' +import { storedFileId } from '@/lib/uploads/utils/embedded-image-ref' +import { + isMarkdownFile, + MAX_RENDERED_DOCUMENT_BYTES, + needsRenderedArtifact, +} from '@/lib/uploads/utils/file-utils' +import { buildZipEntryPaths } from '@/lib/uploads/zip-entry-path' +import { + observeWorkspaceFileDelivery, + reportWorkspaceFileDelivery, +} from '@/lib/workspace-files/application/file-delivery-observer' +import { + expandFileDownloadFolders, + normalizeFileDownloadSelection, +} from '@/lib/workspace-files/download-selection' +import { MAX_ZIP_DOWNLOAD_BYTES, MAX_ZIP_DOWNLOAD_FILES } from '@/lib/workspace-files/limits' +import { fileOwnerCondition } from '@/lib/workspace-files/ownership-query' +import { SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile' +import { + createFileReadReceipt, + type FileReadReceipt, + recheckFileReadReceipt, +} from '@/lib/workspace-files/read-receipt' + +const logger = createLogger('ProjectFileDownloads') + +export interface DownloadProjectFileItemsInput extends ProjectFileTarget { + fileIds: string[] + folderIds: string[] +} + +interface DownloadResult { + buffer: Buffer + fileName: string + contentType: 'application/zip' + fileCount: number + secretProvenance: WorkspaceFileSecretProvenance +} + +export interface ExportProjectFileSnapshotInput extends ProjectFileTarget { + fileId: string + content: string + /** Host evidence for an agent's visible snapshot; never accepted by HTTP contracts. */ + secretProvenance?: WorkspaceFileSecretProvenance +} + +async function requireDownloadCapability( + tx: DbTransaction, + principal: Principal, + context: ProjectFileAuthorizationContext, + input: DownloadProjectFileItemsInput +) { + const selection = normalizeFileDownloadSelection(input) + if (selection.fileIds.length === 1 && !selection.folderIds.length) return selection + for (const workspaceId of context.visibleWorkspaceIds) { + // permission-group-enforced: files.bulk_download — all accessible active environments govern an archive. + await assertWorkspaceCapability( + requirePrincipalSubjectUserId(principal), + workspaceId, + 'files.bulk_download', + context.organizationId, + tx + ) + } + return selection +} + +async function snapshotSelection( + tx: DbTransaction, + context: ProjectFileAuthorizationContext, + input: DownloadProjectFileItemsInput +) { + const selection = normalizeFileDownloadSelection(input) + const index = await loadActiveFileFolderPathIndex(context.owner, tx, { + maxRows: MAX_FOLDERS_PER_WORKSPACE, + }) + const folders = [...index.rowById.values()] + const paths = buildWorkspaceFileFolderPathMap(folders) + if (selection.folderIds.some((id) => !index.rowById.has(id))) + throw new OrchestrationError('not_found', 'Folder not found') + const folderIds = expandFileDownloadFolders(selection, folders, paths) + const files = await tx + .select() + .from(workspaceFiles) + .where( + and( + fileOwnerCondition(context.owner), + isNull(workspaceFiles.deletedAt), + or( + selection.fileIds.length ? inArray(workspaceFiles.id, selection.fileIds) : undefined, + folderIds.size ? inArray(workspaceFiles.folderId, [...folderIds]) : undefined + ) + ) + ) + .orderBy(asc(workspaceFiles.id)) + .limit(MAX_ZIP_DOWNLOAD_FILES + 1) + .for('share') + if (files.length > MAX_ZIP_DOWNLOAD_FILES) + throw new OrchestrationError( + 'validation', + `Too many files selected for download. Select ${MAX_ZIP_DOWNLOAD_FILES} or fewer files.` + ) + const ids = new Set(files.map((file) => file.id)) + if (selection.fileIds.some((id) => !ids.has(id))) + throw new OrchestrationError('not_found', 'File not found') + if (!files.length) throw new OrchestrationError('validation', 'No files selected for download') + const bytes = files.reduce((total, file) => total + getWorkspaceFileSize(file), 0) + assertKnownSizeWithinLimit(bytes, MAX_ZIP_DOWNLOAD_BYTES, 'selected files') + const identity = JSON.stringify({ + folders: folders + .filter((folder) => folderIds.has(folder.id)) + .sort((left, right) => compareStrings(left.id, right.id)) + .map((folder) => [folder.id, folder.name, folder.parentId]), + files: files.map((file) => [ + file.id, + file.key, + file.contentUpdatedAt.getTime(), + file.originalName, + file.folderId, + file.folderId ? paths.get(file.folderId) : null, + ]), + }) + return { files, paths, identity } +} + +const snapshotDownload = defineAuthorizedProjectFileUseCase< + typeof projectFileOperations.downloadItems, + DownloadProjectFileItemsInput, + Awaited> +>({ + operation: projectFileOperations.downloadItems, + async execute({ principal, input, context, tx }) { + await requireDownloadCapability(tx, principal, context, input) + return snapshotSelection(tx, context, input) + }, +}) + +interface PreparedDownload { + identity: string + receipts: FileReadReceipt[] + buffer: Buffer + fileCount: number +} + +/** A bounded archive is published only after the selected tree and all rendered inputs remain current. */ +export const downloadProjectFileItems = defineAuthorizedProjectFileUseCase< + typeof projectFileOperations.downloadItems, + DownloadProjectFileItemsInput, + DownloadResult, + PreparedDownload +>({ + operation: projectFileOperations.downloadItems, + async prepare({ principal, input, context }) { + try { + const selection = await snapshotDownload.execute({ principal, input }) + const zip = new JSZip() + const receipts: FileReadReceipt[] = [] + const paths = buildZipEntryPaths( + selection.files.map((file) => ({ + name: file.originalName, + contentType: file.contentType, + folderPath: file.folderId ? selection.paths.get(file.folderId) : null, + })) + ) + let remaining = MAX_ZIP_DOWNLOAD_BYTES + for (const [index, file] of selection.files.entries()) { + let buffer: Buffer + if ( + needsRenderedArtifact(file.contentType, file.originalName) || + file.contentType === SIM_PAGE_CONTENT_TYPE || + file.originalName.toLowerCase().endsWith('.html') + ) { + const rendered = await observeWorkspaceFileDelivery( + async () => {}, + () => + readProjectFileArtifact.execute({ + principal, + input: { + projectId: context.projectId, + fileId: file.id, + maxBytes: Math.min(remaining, MAX_RENDERED_DOCUMENT_BYTES), + }, + }) + ) + buffer = rendered.buffer + receipts.push(rendered.receipt) + } else { + buffer = await downloadFile({ key: file.key, context: 'project', maxBytes: remaining }) + receipts.push(createFileReadReceipt(context.owner, [file])) + } + assertKnownSizeWithinLimit(buffer.length, remaining, 'selected files') + remaining -= buffer.length + zip.file(paths[index], buffer) + } + return { + identity: selection.identity, + receipts, + fileCount: selection.files.length, + buffer: await bufferZipWithinLimit(zip, MAX_ZIP_DOWNLOAD_BYTES), + } + } catch (error) { + throw asOrchestrationError(error) ?? error + } + }, + async execute({ principal, input, context, tx, prepared }) { + if (!prepared) throw new Error('Prepared archive is unavailable') + await requireDownloadCapability(tx, principal, context, input) + const selection = await snapshotSelection(tx, context, input) + if (selection.identity !== prepared.identity) + throw new OrchestrationError( + 'conflict', + 'File selection changed while preparing the download' + ) + const evidence: WorkspaceFileSecretProvenance[] = [] + for (const receipt of prepared.receipts) + evidence.push(await recheckFileReadReceipt(tx, receipt)) + return { + buffer: prepared.buffer, + fileName: 'project-files.zip', + contentType: 'application/zip', + fileCount: prepared.fileCount, + secretProvenance: mergeWorkspaceFileSecretProvenance(...evidence), + } + }, + afterSuccess: ({ result }) => reportWorkspaceFileDelivery(result.secretProvenance), + projectAudit: ({ context, result }) => ({ + action: AuditAction.FILE_DOWNLOADED, + resourceType: AuditResourceType.FILE, + description: `Downloaded ${result.fileCount} Project files as zip`, + metadata: { + projectId: context.projectId, + fileCount: result.fileCount, + totalBytes: result.buffer.length, + }, + }), +}) + +interface SnapshotAssets { + source: WorkspaceFileRow + assets: { reference: string; file: WorkspaceFileRow }[] +} + +const snapshotMarkdownAssets = defineAuthorizedProjectFileUseCase< + typeof projectFileOperations.exportSnapshot, + ExportProjectFileSnapshotInput, + SnapshotAssets +>({ + operation: projectFileOperations.exportSnapshot, + async execute({ input, context, tx }) { + const source = context.file + if (!source) throw new OrchestrationError('not_found', 'File not found') + if ( + !isMarkdownFile({ name: source.originalName, type: source.contentType }) && + source.contentType !== 'text/x-markdown' + ) + throw new OrchestrationError('validation', 'Only Markdown files support snapshot export') + if ( + utf8ByteLength(input.content, PASTE_LIMITS.RICH_MARKDOWN_BYTES) > + PASTE_LIMITS.RICH_MARKDOWN_BYTES + ) + throw new OrchestrationError('validation', 'Markdown snapshot is too large') + const references = extractEmbeddedFileRefs(input.content, context.owner) + if (!references.ids.length && !references.keys.length) return { source, assets: [] } + const rows = await tx + .select() + .from(workspaceFiles) + .where( + and( + fileOwnerCondition(context.owner), + isNull(workspaceFiles.deletedAt), + or( + references.ids.length + ? inArray(workspaceFiles.id, references.ids.map(storedFileId)) + : undefined, + references.keys.length ? inArray(workspaceFiles.key, references.keys) : undefined + ) + ) + ) + .orderBy(asc(workspaceFiles.id)) + .for('share') + const byId = new Map(rows.map((file) => [file.id, file])) + const byKey = new Map(rows.map((file) => [file.key, file])) + const assets: SnapshotAssets['assets'] = [] + for (const reference of references.ids) { + const file = byId.get(storedFileId(reference)) + if (file) assets.push({ reference, file }) + } + for (const reference of references.keys) { + const file = byKey.get(reference) + if (file) assets.push({ reference, file }) + } + return { source, assets } + }, +}) + +interface PreparedSnapshot { + receipt: FileReadReceipt + export: MarkdownExportResult +} + +/** Exports the visible snapshot without advancing the durable file or collaborative document. */ +export const exportProjectFileSnapshot = defineAuthorizedProjectFileUseCase< + typeof projectFileOperations.exportSnapshot, + ExportProjectFileSnapshotInput, + MarkdownExportResult & { + file: ReturnType + secretProvenance: WorkspaceFileSecretProvenance + }, + PreparedSnapshot +>({ + operation: projectFileOperations.exportSnapshot, + async prepare({ principal, input, context }) { + const snapshot = await snapshotMarkdownAssets.execute({ principal, input }) + const content = Buffer.from(input.content) + const assets: MarkdownExportAsset[] = [] + const consumed: WorkspaceFileRow[] = [snapshot.source] + let actualBytes = content.length + try { + assertKnownSizeWithinLimit( + content.length + + snapshot.assets.reduce((total, { file }) => total + getWorkspaceFileSize(file), 0), + MAX_EXPORT_TOTAL_BYTES, + 'Markdown export' + ) + for (const asset of snapshot.assets) { + let buffer: Buffer + try { + buffer = await downloadFile({ + key: asset.file.key, + context: 'project', + maxBytes: MAX_EXPORT_ASSET_BYTES, + }) + } catch (error) { + logger.warn('Skipped unavailable Markdown export asset', { fileId: asset.file.id, error }) + continue + } + actualBytes += buffer.length + assertKnownSizeWithinLimit(actualBytes, MAX_EXPORT_TOTAL_BYTES, 'Markdown export') + assets.push({ + imageId: asset.reference, + key: asset.file.key, + context: 'project', + originalName: asset.file.originalName, + size: buffer.length, + buffer, + }) + consumed.push(asset.file) + } + return { + receipt: createFileReadReceipt(context.owner, consumed), + export: await createMarkdownExport({ + content, + fileName: snapshot.source.originalName, + owner: context.owner, + assets, + }), + } + } catch (error) { + if (error instanceof MarkdownExportSizeError) + throw new OrchestrationError('validation', error.message) + throw asOrchestrationError(error) ?? error + } + }, + async execute({ principal, input, context, tx, prepared }) { + if (!prepared || !context.file) throw new Error('Prepared Markdown export is unavailable') + const evidence = await recheckFileReadReceipt(tx, prepared.receipt) + const secretProvenance = mergeWorkspaceFileSecretProvenance( + evidence, + input.secretProvenance ?? + (principal.kind === 'resource_delegated' + ? { status: 'unknown' } + : { status: 'exact', entries: [] }) + ) + const index = await loadActiveFileFolderPathIndex(context.owner, tx, { + maxRows: MAX_FOLDERS_PER_WORKSPACE, + }) + return { + ...prepared.export, + secretProvenance, + file: mapFileRecord( + context.file, + context.owner, + buildWorkspaceFileFolderPathMap([...index.rowById.values()]) + ), + } + }, + afterSuccess: ({ result }) => reportWorkspaceFileDelivery(result.secretProvenance), + projectAudit: ({ context, result }) => ({ + action: AuditAction.FILE_DOWNLOADED, + resourceType: AuditResourceType.FILE, + resourceId: result.file.id, + resourceName: result.file.name, + description: `Exported Project file "${result.file.name}"`, + metadata: { + projectId: context.projectId, + bytes: result.buffer.length, + format: result.format, + assetCount: result.assetCount, + }, + }), +}) diff --git a/apps/sim/lib/projects/files/application/extract.ts b/apps/sim/lib/projects/files/application/extract.ts new file mode 100644 index 00000000000..a24cb041412 --- /dev/null +++ b/apps/sim/lib/projects/files/application/extract.ts @@ -0,0 +1,227 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { db } from '@sim/db' +import { workspaceFiles } from '@sim/db/schema' +import { and, eq, isNull } from 'drizzle-orm' +import { maybeNotifyStorageLimitForBillingContext } from '@/lib/billing/storage/tracking' +import { + type AuthorizingUseCase, + recordProjectedUseCaseAuditEntries, +} from '@/lib/core/application/authorized-workspace-use-case' +import { runWithOutboundOrganization } from '@/lib/core/network/context.server' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbTransaction } from '@/lib/db/types' +import { + createProjectFileAuthorizer, + type ProjectFileAuthorizationContext, +} from '@/lib/projects/files/application/authorization' +import { projectFileOperations } from '@/lib/projects/files/application/operations' +import { prepareProjectFileAccounting } from '@/lib/projects/files/application/write-effects' +import { notifyFileListChanged } from '@/lib/realtime/notify' +import { + archiveFolderName, + MAX_ARCHIVE_BYTES, + prepareArchiveExtraction, +} from '@/lib/uploads/archive' +import { resolveFileFolderTarget } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' +import { getBoundWorkspaceFileSecretProvenanceByMetadata } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' +import { downloadFile } from '@/lib/uploads/core/storage-service' +import { getWorkspaceFileSize } from '@/lib/uploads/shared/types' +import { isArchiveFileName } from '@/lib/uploads/utils/file-utils' +import { + commitFileArchiveInTx, + discardFileArchive, + stageFileArchive, +} from '@/lib/workspace-files/archive-extraction' +import { + FILE_EXTRACTION_BUDGET_MS, + requireFileExtractionLeaseInTx, + withFileExtractionLease, +} from '@/lib/workspace-files/extraction-lease' +import { parseWorkspaceFileFolderDisplayPath } from '@/lib/workspace-files/folder-display-path' +import { lockFileDirectories } from '@/lib/workspace-files/locks' +import { fileOwnerCondition } from '@/lib/workspace-files/ownership-query' + +interface ExtractProjectFileInput { + projectId: string + fileId: string +} + +async function snapshotArchive( + tx: DbTransaction, + context: ProjectFileAuthorizationContext, + fileId: string +) { + await lockFileDirectories(tx, [context.owner]) + const [file] = await tx + .select() + .from(workspaceFiles) + .where( + and( + fileOwnerCondition(context.owner), + eq(workspaceFiles.id, fileId), + isNull(workspaceFiles.deletedAt) + ) + ) + .for('update') + .limit(1) + if (!file) throw new OrchestrationError('not_found', 'File not found') + const provenance = (await getBoundWorkspaceFileSecretProvenanceByMetadata(tx, [file])).get( + file.id + ) + if (!provenance) throw new Error('Archive provenance is unavailable') + const parent = await resolveFileFolderTarget(context.owner, { folderId: file.folderId }, tx) + const parentSegments = parent ? parseWorkspaceFileFolderDisplayPath(parent.path) : [] + return { + file, + provenance, + parentSegments, + identity: JSON.stringify({ + key: file.key, + name: file.originalName, + size: getWorkspaceFileSize(file), + contentType: file.contentType, + contentUpdatedAt: file.contentUpdatedAt, + updatedAt: file.updatedAt, + folderId: file.folderId, + parentSegments, + provenance, + }), + } +} + +/** Stages bounded archive members, then atomically publishes their tree under fresh owner authority. */ +export const extractProjectFile: AuthorizingUseCase< + typeof projectFileOperations.extractArchive, + ExtractProjectFileInput, + Awaited> +> = { + operation: projectFileOperations.extractArchive, + delegationAudience: projectFileOperations.extractArchive.delegationAudience, + async authorize(args) { + const authorize = await createProjectFileAuthorizer( + args.principal, + projectFileOperations.extractArchive, + args.input + ) + await db.transaction(authorize) + }, + async execute(args) { + const input = structuredClone(args.input) + const authorize = await createProjectFileAuthorizer( + args.principal, + projectFileOperations.extractArchive, + input + ) + // actorless-unsupported: Project file authorization requires the original human subject. + const userId = requirePrincipalSubjectUserId(args.principal) + const prepared = await db.transaction(async (tx) => { + const context = await authorize(tx) + return { context, snapshot: await snapshotArchive(tx, context, input.fileId) } + }) + const { file } = prepared.snapshot + if (!isArchiveFileName(file.originalName)) + throw new OrchestrationError('validation', 'Only .zip files can be unzipped') + if (getWorkspaceFileSize(file) > MAX_ARCHIVE_BYTES) + throw new OrchestrationError('payload_too_large', 'Archive exceeds the unzip limit') + return withFileExtractionLease(prepared.context.owner, file.id, async (lease) => { + const deadline = AbortSignal.timeout(FILE_EXTRACTION_BUDGET_MS) + const signal = args.request?.signal + ? AbortSignal.any([deadline, args.request.signal]) + : deadline + try { + const staged = await runWithOutboundOrganization( + prepared.context.organizationId, + async () => { + const bytes = await downloadFile({ + key: file.key, + context: 'project', + maxBytes: MAX_ARCHIVE_BYTES, + signal, + }) + if (bytes.length !== getWorkspaceFileSize(file)) + throw new OrchestrationError('conflict', 'Archive bytes changed; retry') + const plan = await prepareArchiveExtraction(bytes, { + rootFolderSegments: [ + ...prepared.snapshot.parentSegments, + archiveFolderName(file.originalName), + ], + includeRootFolder: true, + skipNoiseEntries: true, + signal, + }) + if (!plan.entryCount) + throw new OrchestrationError( + 'validation', + 'No files could be unzipped from this archive' + ) + return stageFileArchive(plan, { owner: prepared.context.owner, userId, signal }) + } + ) + let committed + try { + committed = await db.transaction(async (tx) => { + signal.throwIfAborted() + const context = await authorize(tx) + await requireFileExtractionLeaseInTx(tx, lease) + const accounting = await prepareProjectFileAccounting(tx, context) + const current = await snapshotArchive(tx, context, file.id) + if (current.identity !== prepared.snapshot.identity) + throw new OrchestrationError('conflict', 'Archive or destination changed; retry') + const result = await commitFileArchiveInTx(tx, { + owner: context.owner, + userId, + rootName: archiveFolderName(current.file.originalName), + parentId: current.file.folderId, + parentSegments: current.parentSegments, + staged, + secretProvenance: current.provenance, + signal, + }) + const usage = await accounting.mutation.applyDelta(staged.bytes) + signal.throwIfAborted() + await requireFileExtractionLeaseInTx(tx, lease) + return { context, result, billing: accounting.billing, usage } + }) + } catch (error) { + try { + await discardFileArchive(staged) + } catch (cleanupError) { + throw new AggregateError( + [error, cleanupError], + 'Archive publication and cleanup failed' + ) + } + throw error + } + await notifyFileListChanged(committed.context.owner) + recordProjectedUseCaseAuditEntries( + projectFileOperations.extractArchive, + null, + args.principal, + args.request, + [ + { + action: AuditAction.FILE_UPDATED, + resourceType: AuditResourceType.FILE, + resourceId: file.id, + resourceName: file.originalName, + description: `Unzipped Project file "${file.originalName}"`, + metadata: { projectId: committed.context.projectId, ...committed.result }, + }, + ], + committed.context.organizationId ?? undefined + ) + await maybeNotifyStorageLimitForBillingContext(committed.billing, committed.usage) + return committed.result + } catch (error) { + if (deadline.aborted && error === deadline.reason) + throw new OrchestrationError( + 'payload_too_large', + 'Unzipping took too long and was cancelled. Try a smaller archive.' + ) + throw error + } + }) + }, +} diff --git a/apps/sim/lib/projects/files/application/folders.ts b/apps/sim/lib/projects/files/application/folders.ts new file mode 100644 index 00000000000..91bda9df0cb --- /dev/null +++ b/apps/sim/lib/projects/files/application/folders.ts @@ -0,0 +1,109 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { type Principal, requirePrincipalSubjectUserId } from '@sim/auth/principal' +import type { DbTransaction } from '@/lib/db/types' +import type { + ProjectFileAuthorizationContext, + ProjectFileTarget, +} from '@/lib/projects/files/application/authorization' +import { defineAuthorizedProjectFileUseCase } from '@/lib/projects/files/application/authorized-use-case' +import { projectFileOperations } from '@/lib/projects/files/application/operations' +import { + createFileFolder, + listFileFolders, + updateFileFolder, +} from '@/lib/uploads/contexts/workspace' + +interface FolderUseCaseArgs { + principal: Principal + input: I + context: ProjectFileAuthorizationContext + tx: DbTransaction +} + +interface ListProjectFileFoldersInput extends ProjectFileTarget { + scope?: 'active' | 'archived' | 'all' +} + +interface CreateProjectFileFolderInput extends ProjectFileTarget { + name: string + parentId?: string | null +} + +interface UpdateProjectFileFolderInput extends ProjectFileTarget { + folderId: string + name?: string + parentId?: string | null + sortOrder?: number +} + +export const listProjectFileFolders = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.listFolders, + async execute({ input, context, tx }: FolderUseCaseArgs) { + return { + folders: await listFileFolders(context.owner, { scope: input.scope }, tx), + capabilities: { canRead: true as const, canWrite: context.canWrite }, + } + }, +}) + +export const createProjectFileFolder = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.createFolder, + invalidatesFileList: true, + async execute({ + principal, + input, + context, + tx, + }: FolderUseCaseArgs) { + return { + folder: await createFileFolder( + { + owner: context.owner, + userId: requirePrincipalSubjectUserId(principal), + name: input.name, + parentId: input.parentId, + }, + tx + ), + } + }, + projectAudit({ input, result }) { + return { + action: AuditAction.FOLDER_CREATED, + resourceType: AuditResourceType.FOLDER, + resourceId: result.folder.id, + resourceName: result.folder.name, + description: `Created Project file folder "${result.folder.name}"`, + metadata: { projectId: input.projectId }, + } + }, +}) + +export const updateProjectFileFolder = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.updateFolder, + invalidatesFileList: true, + async execute({ input, context, tx }: FolderUseCaseArgs) { + return { + folder: await updateFileFolder( + { + owner: context.owner, + folderId: input.folderId, + name: input.name, + parentId: input.parentId, + sortOrder: input.sortOrder, + }, + tx + ), + } + }, + projectAudit({ input, result }) { + return { + action: input.parentId === undefined ? AuditAction.FOLDER_UPDATED : AuditAction.FOLDER_MOVED, + resourceType: AuditResourceType.FOLDER, + resourceId: result.folder.id, + resourceName: result.folder.name, + description: `Updated Project file folder "${result.folder.name}"`, + metadata: { projectId: input.projectId }, + } + }, +}) diff --git a/apps/sim/lib/projects/files/application/index.ts b/apps/sim/lib/projects/files/application/index.ts new file mode 100644 index 00000000000..db351977f03 --- /dev/null +++ b/apps/sim/lib/projects/files/application/index.ts @@ -0,0 +1,37 @@ +export { readProjectFileArtifact } from './artifacts' +export { getProjectFileListAccess } from './collection-access' +export { createProjectFile, readProjectFileContent, updateProjectFileContent } from './content' +export { downloadProjectFileItems, exportProjectFileSnapshot } from './downloads' +export { extractProjectFile } from './extract' +export { createProjectFileFolder, listProjectFileFolders, updateProjectFileFolder } from './folders' +export { + archiveProjectFileItems, + moveProjectFileItems, + renameProjectFile, + restoreProjectFile, + restoreProjectFileFolder, +} from './lifecycle' +export { projectFileOperations } from './operations' +export { readProjectFileCsvPreview, readProjectInlineFile } from './previews' +export { + getProjectFileMetadata, + listProjectFileItems, + listProjectFiles, + resolveProjectFileReference, +} from './read' +export { searchProjectFileContent } from './search' +export { getProjectFileShare, updateProjectFileShare } from './shares' +export { + abortProjectFileUploadSession, + completeProjectFileUploadSession, + createProjectFileUploadSession, + getProjectFileUploadPartUrls, + getProjectFileUploadSession, +} from './uploads' +export { + deleteProjectFileVersion, + listProjectFileVersions, + readProjectFileVersion, + readProjectFileVersionContent, + revertProjectFileVersion, +} from './versions' diff --git a/apps/sim/lib/projects/files/application/lifecycle.ts b/apps/sim/lib/projects/files/application/lifecycle.ts new file mode 100644 index 00000000000..21ecc724423 --- /dev/null +++ b/apps/sim/lib/projects/files/application/lifecycle.ts @@ -0,0 +1,202 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import type { Principal } from '@sim/auth/principal' +import type { WorkspaceFileRow } from '@sim/db/schema' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbTransaction } from '@/lib/db/types' +import type { + ProjectFileAuthorizationContext, + ProjectFileTarget, +} from '@/lib/projects/files/application/authorization' +import { defineAuthorizedProjectFileUseCase } from '@/lib/projects/files/application/authorized-use-case' +import { + processProjectFileDocRetirementNow, + rotateProjectFileDocInTx, +} from '@/lib/projects/files/application/document-lifecycle' +import { projectFileOperations } from '@/lib/projects/files/application/operations' +import { + archiveFileItems, + buildWorkspaceFileFolderPathMap, + listFileFolders, + mapFileRecord, + moveFileItems, + renameFileInTx, + restoreFileFolder, + restoreFileInTx, +} from '@/lib/uploads/contexts/workspace' +import { MAX_WORKSPACE_FILE_BULK_REQUEST_IDS } from '@/lib/workspace-files/limits' + +interface LifecycleArgs { + principal: Principal + input: I + context: ProjectFileAuthorizationContext + tx: DbTransaction +} + +interface SelectionInput extends ProjectFileTarget { + fileIds?: string[] + folderIds?: string[] +} +interface MoveInput extends SelectionInput { + targetFolderId?: string | null + targetFolderPath?: string +} + +const retirementEffects = new WeakMap() + +async function retireDocuments( + tx: DbTransaction, + projectId: string, + fileIds: string[], + result: object +) { + const eventIds: string[] = [] + for (const fileId of [...new Set(fileIds)].sort()) { + const retired = await rotateProjectFileDocInTx(tx, { projectId, fileId }) + if (retired) eventIds.push(retired.outboxEventId) + } + retirementEffects.set(result, eventIds) +} + +async function finishRetirements(result: object) { + const eventIds = retirementEffects.get(result) ?? [] + retirementEffects.delete(result) + await Promise.all(eventIds.map(processProjectFileDocRetirementNow)) +} + +function boundedSelection(input: SelectionInput) { + const fileIds = [...new Set(input.fileIds ?? [])] + const folderIds = [...new Set(input.folderIds ?? [])] + if (fileIds.length + folderIds.length === 0) + throw new OrchestrationError('validation', 'At least one file or folder must be selected') + if ( + fileIds.length > MAX_WORKSPACE_FILE_BULK_REQUEST_IDS || + folderIds.length > MAX_WORKSPACE_FILE_BULK_REQUEST_IDS + ) + throw new OrchestrationError( + 'validation', + `Bulk file operations accept at most ${MAX_WORKSPACE_FILE_BULK_REQUEST_IDS} file and folder IDs` + ) + return { fileIds, folderIds } +} + +async function projectFileRecord( + tx: DbTransaction, + context: ProjectFileAuthorizationContext, + file: WorkspaceFileRow +) { + const folders = file.folderId ? await listFileFolders(context.owner, { scope: 'all' }, tx) : [] + return mapFileRecord(file, context.owner, buildWorkspaceFileFolderPathMap(folders)) +} + +export const renameProjectFile = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.rename, + invalidatesFileList: ({ context, result }) => context.file?.originalName !== result.file.name, + async execute({ + input, + context, + tx, + }: LifecycleArgs) { + const file = await renameFileInTx(tx, context.owner, input.fileId, input.name) + return { file: await projectFileRecord(tx, context, file) } + }, + projectAudit: ({ context, result }) => ({ + action: AuditAction.FILE_UPDATED, + resourceType: AuditResourceType.FILE, + resourceId: result.file.id, + resourceName: result.file.name, + description: `Renamed Project file "${result.file.name}"`, + metadata: { projectId: context.projectId }, + }), +}) + +export const archiveProjectFileItems = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.archiveItems, + invalidatesFileList: ({ result }) => result.deletedItems.files + result.deletedItems.folders > 0, + async execute({ input, context, tx }: LifecycleArgs) { + const archived = await archiveFileItems( + { owner: context.owner, ...boundedSelection(input) }, + tx + ) + const result = { + deletedItems: { files: archived.files, folders: archived.folders }, + affectedIds: { fileIds: archived.fileIds, folderIds: archived.folderIds }, + } + await retireDocuments(tx, context.projectId, archived.fileIds, result) + return result + }, + projectAudit: ({ context, result }) => ({ + action: AuditAction.FILE_DELETED, + resourceType: AuditResourceType.FILE, + description: 'Archived Project file items', + metadata: { projectId: context.projectId, ...result.affectedIds }, + }), + afterSuccess: ({ result }) => finishRetirements(result), +}) + +export const moveProjectFileItems = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.moveItems, + invalidatesFileList: ({ result }) => result.movedFiles + result.movedFolders > 0, + async execute({ input, context, tx }: LifecycleArgs) { + return moveFileItems( + { + owner: context.owner, + ...boundedSelection(input), + targetFolderId: input.targetFolderId, + targetFolderPath: input.targetFolderPath, + }, + tx + ) + }, + projectAudit: ({ context, result }) => ({ + action: AuditAction.FILE_MOVED, + resourceType: AuditResourceType.FILE, + description: 'Moved Project file items', + metadata: { + projectId: context.projectId, + fileIds: result.movedFileIds, + folderIds: result.movedFolderIds, + }, + }), +}) + +export const restoreProjectFile = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.restore, + invalidatesFileList: ({ context }) => Boolean(context.file?.deletedAt), + async execute({ input, context, tx }: LifecycleArgs) { + const restored = await restoreFileInTx(tx, context.owner, input.fileId) + const result = { restored: true as const, file: await projectFileRecord(tx, context, restored) } + if (context.file?.deletedAt) + await retireDocuments(tx, context.projectId, [input.fileId], result) + return result + }, + projectAudit: ({ context, result }) => ({ + action: AuditAction.FILE_RESTORED, + resourceType: AuditResourceType.FILE, + resourceId: result.file.id, + resourceName: result.file.name, + description: `Restored Project file "${result.file.name}"`, + metadata: { projectId: context.projectId }, + }), + afterSuccess: ({ result }) => finishRetirements(result), +}) + +export const restoreProjectFileFolder = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.restoreFolder, + invalidatesFileList: ({ result }) => + result.restoredItems.files + result.restoredItems.folders > 0, + async execute({ input, context, tx }: LifecycleArgs) { + const restored = await restoreFileFolder(context.owner, input.folderId, tx) + const result = { folder: restored.folder, restoredItems: restored.restoredItems } + await retireDocuments(tx, context.projectId, restored.restoredFileIds, result) + return result + }, + projectAudit: ({ context, result }) => ({ + action: AuditAction.FOLDER_RESTORED, + resourceType: AuditResourceType.FOLDER, + resourceId: result.folder.id, + resourceName: result.folder.name, + description: `Restored Project file folder "${result.folder.name}"`, + metadata: { projectId: context.projectId, restoredItems: result.restoredItems }, + }), + afterSuccess: ({ result }) => finishRetirements(result), +}) diff --git a/apps/sim/lib/projects/files/application/operations.ts b/apps/sim/lib/projects/files/application/operations.ts new file mode 100644 index 00000000000..9dfd0cf83fb --- /dev/null +++ b/apps/sim/lib/projects/files/application/operations.ts @@ -0,0 +1,341 @@ +import type { Principal, ResourceDelegatedPrincipal } from '@sim/auth/principal' +import { + type ApplicationOperation, + assertOperationCapability, + assertOperationOAuthPolicy, +} from '@/lib/core/application/operation' + +export type ProjectFilePrincipal = Extract< + Principal, + { kind: 'session' | 'personal_api_key' | 'oauth_access_token' | 'resource_delegated' } +> + +interface ProjectFilePolicy extends ApplicationOperation { + readonly access: 'read' | 'write' + readonly target: 'project' | 'file' | 'collection_observation' + readonly fileScope?: 'active' | 'all' + readonly principalKinds: readonly ProjectFilePrincipal['kind'][] + readonly delegatedServices: readonly ResourceDelegatedPrincipal['serviceId'][] + readonly delegationAudience: 'sim:project-files' | 'sim:file-list-observation' +} + +export const PROJECT_FILE_DELEGATION_TTL_MS = 60_000 + +function defineProjectFileOperation(operation: O): O { + assertOperationCapability(operation) + assertOperationOAuthPolicy(operation) + const observesCollection = operation.target === 'collection_observation' + if ( + observesCollection && + (operation.access !== 'read' || + operation.delegationAudience !== 'sim:file-list-observation' || + operation.principalKinds.length !== 1 || + operation.principalKinds[0] !== 'resource_delegated' || + operation.delegatedServices.length !== 1 || + operation.delegatedServices[0] !== 'realtime') + ) { + throw new Error('Collection observation requires an exclusive read-only realtime grant') + } + if ( + operation.target !== 'file' && + !observesCollection && + operation.delegatedServices.includes('realtime') + ) { + throw new Error('Realtime Project authority must target an existing file') + } + Object.freeze(operation.principalKinds) + Object.freeze(operation.delegatedServices) + return Object.freeze(operation) +} + +const PRINCIPALS = [ + 'session', + 'personal_api_key', + 'oauth_access_token', + 'resource_delegated', +] as const +const POLICY = { + principalKinds: PRINCIPALS, + delegationAudience: 'sim:project-files', + delegatedServices: ['copilot'], +} as const + +const HISTORY_POLICY = { + principalKinds: ['session', 'personal_api_key', 'oauth_access_token'], + delegationAudience: 'sim:project-files', + delegatedServices: [], +} as const + +export const projectFileOperations = { + observeCollection: defineProjectFileOperation({ + id: 'project_files.observe_collection', + capability: 'files.use', + access: 'read', + target: 'collection_observation', + principalKinds: ['resource_delegated'], + delegatedServices: ['realtime'], + delegationAudience: 'sim:file-list-observation', + }), + extractArchive: defineProjectFileOperation({ + ...POLICY, + id: 'project_files.extract_archive', + capability: 'files.use', + access: 'write', + target: 'file', + oauthScope: 'api:write', + }), + downloadItems: defineProjectFileOperation({ + ...POLICY, + id: 'project_files.download_items', + capability: 'files.use', + access: 'read', + target: 'project', + oauthScope: 'api:read', + }), + exportSnapshot: defineProjectFileOperation({ + ...POLICY, + id: 'project_files.export_snapshot', + capability: 'files.use', + access: 'read', + target: 'file', + oauthScope: 'api:read', + }), + searchContent: defineProjectFileOperation({ + ...POLICY, + id: 'project_files.search_content', + capability: 'files.use', + access: 'read', + target: 'project', + oauthScope: 'api:read', + }), + readShare: defineProjectFileOperation({ + ...POLICY, + id: 'project_files.share.read', + capability: 'files.use', + access: 'read', + target: 'file', + oauthScope: 'api:read', + }), + updateShare: defineProjectFileOperation({ + ...POLICY, + id: 'project_files.share.update', + capability: 'files.use', + access: 'write', + target: 'file', + oauthScope: 'api:write', + }), + listVersions: defineProjectFileOperation({ + ...HISTORY_POLICY, + id: 'project_files.versions.list', + capability: 'files.use', + access: 'read', + target: 'file', + oauthScope: 'api:read', + }), + readVersion: defineProjectFileOperation({ + ...HISTORY_POLICY, + id: 'project_files.versions.read', + capability: 'files.use', + access: 'read', + target: 'file', + oauthScope: 'api:read', + }), + readVersionContent: defineProjectFileOperation({ + ...HISTORY_POLICY, + id: 'project_files.versions.read_content', + capability: 'files.use', + access: 'read', + target: 'file', + oauthScope: 'api:read', + }), + revertVersion: defineProjectFileOperation({ + ...HISTORY_POLICY, + id: 'project_files.versions.revert', + capability: 'files.use', + access: 'write', + target: 'file', + oauthScope: 'api:write', + }), + deleteVersion: defineProjectFileOperation({ + ...HISTORY_POLICY, + id: 'project_files.versions.delete', + capability: 'files.use', + access: 'write', + target: 'file', + oauthScope: 'api:write', + }), + readInline: defineProjectFileOperation({ + ...POLICY, + id: 'project_files.read_inline', + capability: 'files.use', + access: 'read', + target: 'project', + oauthScope: 'api:read', + }), + uploadCreate: defineProjectFileOperation({ + ...POLICY, + id: 'project_file_uploads.create', + capability: 'files.use', + access: 'write', + target: 'project', + oauthScope: 'api:write', + }), + uploadRead: defineProjectFileOperation({ + ...POLICY, + id: 'project_file_uploads.read', + capability: 'files.use', + access: 'write', + target: 'project', + oauthScope: 'api:write', + }), + uploadParts: defineProjectFileOperation({ + ...POLICY, + id: 'project_file_uploads.parts', + capability: 'files.use', + access: 'write', + target: 'project', + oauthScope: 'api:write', + }), + uploadComplete: defineProjectFileOperation({ + ...POLICY, + id: 'project_file_uploads.complete', + capability: 'files.use', + access: 'write', + target: 'project', + oauthScope: 'api:write', + }), + uploadCancel: defineProjectFileOperation({ + ...POLICY, + id: 'project_file_uploads.cancel', + capability: 'files.use', + access: 'write', + target: 'project', + oauthScope: 'api:write', + }), + listFolders: defineProjectFileOperation({ + ...POLICY, + id: 'project_file_folders.list', + capability: 'files.use', + access: 'read', + target: 'project', + oauthScope: 'api:read', + }), + createFolder: defineProjectFileOperation({ + ...POLICY, + id: 'project_file_folders.create', + capability: 'files.use', + access: 'write', + target: 'project', + oauthScope: 'api:write', + }), + updateFolder: defineProjectFileOperation({ + ...POLICY, + id: 'project_file_folders.update', + capability: 'files.use', + access: 'write', + target: 'project', + oauthScope: 'api:write', + }), + list: defineProjectFileOperation({ + ...POLICY, + id: 'project_files.list', + capability: 'files.use', + access: 'read', + target: 'project', + oauthScope: 'api:read', + }), + resolveReference: defineProjectFileOperation({ + ...POLICY, + id: 'project_files.resolve_reference', + capability: 'files.use', + access: 'read', + target: 'project', + oauthScope: 'api:read', + }), + readMetadata: defineProjectFileOperation({ + ...POLICY, + id: 'project_files.read_metadata', + capability: 'files.use', + access: 'read', + target: 'file', + oauthScope: 'api:read', + }), + readArtifact: defineProjectFileOperation({ + ...POLICY, + id: 'project_files.read_artifact', + capability: 'files.use', + access: 'read', + target: 'file', + oauthScope: 'api:read', + }), + readContent: defineProjectFileOperation({ + ...POLICY, + id: 'project_files.read_content', + capability: 'files.use', + access: 'read', + target: 'file', + oauthScope: 'api:read', + delegatedServices: ['copilot', 'realtime'], + }), + create: defineProjectFileOperation({ + ...POLICY, + id: 'project_files.create', + capability: 'files.use', + access: 'write', + target: 'project', + oauthScope: 'api:write', + }), + updateContent: defineProjectFileOperation({ + ...POLICY, + id: 'project_files.update_content', + capability: 'files.use', + access: 'write', + target: 'file', + oauthScope: 'api:write', + delegatedServices: ['copilot', 'realtime'], + }), + rename: defineProjectFileOperation({ + ...POLICY, + id: 'project_files.rename', + capability: 'files.use', + access: 'write', + target: 'file', + oauthScope: 'api:write', + }), + archiveItems: defineProjectFileOperation({ + ...POLICY, + id: 'project_files.archive_items', + capability: 'files.use', + access: 'write', + target: 'project', + oauthScope: 'api:write', + }), + moveItems: defineProjectFileOperation({ + ...POLICY, + id: 'project_files.move_items', + capability: 'files.use', + access: 'write', + target: 'project', + oauthScope: 'api:write', + }), + restore: defineProjectFileOperation({ + ...POLICY, + id: 'project_files.restore', + capability: 'files.use', + access: 'write', + target: 'file', + fileScope: 'all', + oauthScope: 'api:write', + }), + restoreFolder: defineProjectFileOperation({ + ...POLICY, + id: 'project_file_folders.restore', + capability: 'files.use', + access: 'write', + target: 'project', + oauthScope: 'api:write', + }), +} as const + +export type ProjectFileOperation = + (typeof projectFileOperations)[keyof typeof projectFileOperations] diff --git a/apps/sim/lib/projects/files/application/previews.ts b/apps/sim/lib/projects/files/application/previews.ts new file mode 100644 index 00000000000..eea61d5fc2c --- /dev/null +++ b/apps/sim/lib/projects/files/application/previews.ts @@ -0,0 +1,145 @@ +import type { Principal } from '@sim/auth/principal' +import { workspaceFiles } from '@sim/db/schema' +import { and, eq, isNull } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbTransaction } from '@/lib/db/types' +import { getCsvPreviewSlice } from '@/lib/file-parsers/csv-preview-slice' +import type { + ProjectFileAuthorizationContext, + ProjectFileTarget, +} from '@/lib/projects/files/application/authorization' +import { defineAuthorizedProjectFileUseCase } from '@/lib/projects/files/application/authorized-use-case' +import { projectFileOperations } from '@/lib/projects/files/application/operations' +import { + buildWorkspaceFileFolderPathMap, + listFileFolders, + mapFileRecord, +} from '@/lib/uploads/contexts/workspace' +import { getBoundWorkspaceFileSecretProvenanceByMetadata } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' +import { downloadFile } from '@/lib/uploads/core/storage-service' +import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' +import { reportWorkspaceFileDelivery } from '@/lib/workspace-files/application/file-delivery-observer' +import { fileOwnerCondition } from '@/lib/workspace-files/ownership-query' + +interface InlineInput { + projectId: string + key?: string + referenceFileId?: string +} + +const resolveInlineRecord = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.readInline, + async execute({ + input, + context, + tx, + }: { + input: InlineInput + context: ProjectFileAuthorizationContext + tx: DbTransaction + }) { + if (Boolean(input.key) === Boolean(input.referenceFileId)) + throw new OrchestrationError('validation', 'Provide exactly one file reference') + const [file] = await tx + .select() + .from(workspaceFiles) + .where( + and( + fileOwnerCondition(context.owner), + isNull(workspaceFiles.deletedAt), + input.referenceFileId + ? eq(workspaceFiles.id, input.referenceFileId) + : eq(workspaceFiles.key, input.key ?? '') + ) + ) + .for('share') + .limit(1) + if (!file) throw new OrchestrationError('not_found', 'File not found') + return file + }, +}) + +/** Resolves a private object under its Project before reading, then fences its current head again. */ +const readInlineFile = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.readInline, + async prepare({ principal, input }: { principal: Principal; input: InlineInput }) { + const file = await resolveInlineRecord.execute({ principal, input }) + const content = await downloadFile({ + key: file.key, + context: 'project', + maxBytes: MAX_BUFFERED_TRANSFER_BYTES, + }) + return { file, content } + }, + async execute({ context, tx, prepared }) { + if (!prepared) throw new Error('Inline file bytes are unavailable') + const [file] = await tx + .select() + .from(workspaceFiles) + .where( + and( + fileOwnerCondition(context.owner), + eq(workspaceFiles.id, prepared.file.id), + isNull(workspaceFiles.deletedAt) + ) + ) + .for('share') + .limit(1) + if ( + !file || + file.key !== prepared.file.key || + file.contentUpdatedAt.getTime() !== prepared.file.contentUpdatedAt.getTime() + ) + throw new OrchestrationError('conflict', 'File changed while preparing its preview') + const evidence = await getBoundWorkspaceFileSecretProvenanceByMetadata(tx, [file]) + const folders = file.folderId ? await listFileFolders(context.owner, { scope: 'all' }, tx) : [] + return { + file: mapFileRecord(file, context.owner, buildWorkspaceFileFolderPathMap(folders)), + content: prepared.content, + secretProvenance: evidence.get(file.id), + } + }, + afterSuccess: ({ result }) => reportWorkspaceFileDelivery(result.secretProvenance), +}) + +/** HEAD resolves the canonical image without fetching its bytes. */ +export const readProjectInlineFile = { + ...readInlineFile, + async authorize(args: Parameters[0]) { + await resolveInlineRecord.execute(args) + }, +} + +interface CsvPreviewInput extends ProjectFileTarget { + fileId: string + key: string + signal?: AbortSignal +} + +/** Reads only the bounded CSV slice and rejects head changes before exposing its rows. */ +export const readProjectFileCsvPreview = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.readContent, + async prepare({ + input, + context, + }: { + input: CsvPreviewInput + context: ProjectFileAuthorizationContext + }) { + const file = context.file + if (!file || file.key !== input.key) throw new OrchestrationError('not_found', 'File not found') + return { + file, + slice: await getCsvPreviewSlice({ key: file.key, context: 'project', signal: input.signal }), + } + }, + async execute({ input, context, tx, prepared }) { + const file = context.file + if (!file || file.key !== input.key) throw new OrchestrationError('not_found', 'File not found') + if (!prepared || file.contentUpdatedAt.getTime() !== prepared.file.contentUpdatedAt.getTime()) + throw new OrchestrationError('conflict', 'File changed while preparing its preview') + const evidence = await getBoundWorkspaceFileSecretProvenanceByMetadata(tx, [file]) + return { success: true as const, ...prepared.slice, secretProvenance: evidence.get(file.id) } + }, + afterSuccess: ({ result }) => reportWorkspaceFileDelivery(result.secretProvenance), +}) diff --git a/apps/sim/lib/projects/files/application/read.ts b/apps/sim/lib/projects/files/application/read.ts new file mode 100644 index 00000000000..b87955454f5 --- /dev/null +++ b/apps/sim/lib/projects/files/application/read.ts @@ -0,0 +1,140 @@ +import type { CursorKey } from '@/lib/api/list-query' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' +import { resolveFolderPathFilter } from '@/lib/folders/queries' +import { resolveFolderScope } from '@/lib/folders/subtree' +import type { ProjectFileTarget } from '@/lib/projects/files/application/authorization' +import { defineAuthorizedProjectFileUseCase } from '@/lib/projects/files/application/authorized-use-case' +import { projectFileOperations } from '@/lib/projects/files/application/operations' +import { + buildWorkspaceFileFolderPathMap, + listFileFolders, + loadActiveFileFolderPathIndex, + mapFileRecord, + type OwnedFileRecord, + queryFileRecords, + resolveFileReference, + workspaceFileVfsPath, +} from '@/lib/uploads/contexts/workspace' +import { type FileBrowserQuery, queryFileBrowserItems } from '@/lib/workspace-files/browser-query' +import { fileOwnerVfsPath } from '@/lib/workspace-files/owner-paths' + +interface ListProjectFilesInput extends ProjectFileTarget { + scope?: 'active' | 'archived' + folderId?: string | null + folderPath?: string + recursive?: boolean + search?: string + sortBy: 'name' | 'size' | 'uploadedAt' | 'updatedAt' + sortOrder: 'asc' | 'desc' + limit: number + after?: CursorKey[] +} + +interface ListProjectFilesResult { + files: OwnedFileRecord<{ entityType: 'project'; entityId: string }>[] + nextKeys: CursorKey[] | null + capabilities: { canRead: true; canWrite: boolean } +} + +export const listProjectFiles = defineAuthorizedProjectFileUseCase< + typeof projectFileOperations.list, + ListProjectFilesInput, + ListProjectFilesResult +>({ + operation: projectFileOperations.list, + async execute({ input, context, tx }) { + if (!Number.isInteger(input.limit) || input.limit < 1 || input.limit > 1000) { + throw new OrchestrationError('validation', 'File page limit must be between 1 and 1000') + } + if (input.folderPath !== undefined && input.folderId !== undefined) { + throw new OrchestrationError('validation', 'Specify either folderPath or folderId, not both') + } + let folderId: string | null | string[] | undefined = input.folderId + if (input.folderPath !== undefined) { + const index = await loadActiveFileFolderPathIndex(context.owner, tx, { + maxRows: MAX_FOLDERS_PER_WORKSPACE, + }) + const filter = resolveFolderPathFilter(index, input.folderPath) + if (filter.kind === 'noMatch') { + return { + files: [], + nextKeys: null, + capabilities: { canRead: true, canWrite: context.canWrite }, + } + } + folderId = resolveFolderScope(index, filter, input.recursive) + } + const result = await queryFileRecords(context.owner, { ...input, folderId }, tx) + return { + ...result, + capabilities: { canRead: true as const, canWrite: context.canWrite }, + } + }, +}) + +export const getProjectFileMetadata = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.readMetadata, + async execute({ context, tx }) { + const file = context.file + if (!file) throw new OrchestrationError('not_found', 'File not found') + const folders = file.folderId ? await listFileFolders(context.owner, { scope: 'all' }, tx) : [] + return { + file: mapFileRecord(file, context.owner, buildWorkspaceFileFolderPathMap(folders)), + capabilities: { canRead: true as const, canWrite: context.canWrite }, + } + }, +}) + +/** Resolves metadata under collection authority before an adapter requests an exact file capability. */ +export const resolveProjectFileReference = defineAuthorizedProjectFileUseCase< + typeof projectFileOperations.resolveReference, + ProjectFileTarget & { fileReference: string }, + { + file: OwnedFileRecord<{ entityType: 'project'; entityId: string }> + vfsPath: string + capabilities: { canRead: true; canWrite: boolean } + } +>({ + operation: projectFileOperations.resolveReference, + async execute({ input, context, tx }) { + const file = await resolveFileReference(context.owner, input.fileReference, tx) + if (!file) throw new OrchestrationError('not_found', 'File not found') + return { + file, + vfsPath: fileOwnerVfsPath(context.owner, workspaceFileVfsPath(file)), + capabilities: { canRead: true as const, canWrite: context.canWrite }, + } + }, +}) + +/** The browser pages folders and files together without changing the file-only API listing. */ +export const listProjectFileItems = defineAuthorizedProjectFileUseCase< + typeof projectFileOperations.list, + ProjectFileTarget & FileBrowserQuery, + Awaited> & ListProjectFilesResult +>({ + operation: projectFileOperations.list, + async execute({ input, context, tx }) { + if (!Number.isInteger(input.limit) || input.limit < 1 || input.limit > 1000) + throw new OrchestrationError('validation', 'File page limit must be between 1 and 1000') + const page = await queryFileBrowserItems(context.owner, input, tx) + const fileIds = page.items.filter((item) => item.kind === 'file').map((item) => item.id) + const files = fileIds.length + ? ( + await queryFileRecords( + context.owner, + { + scope: input.scope, + fileIds, + sortBy: 'name', + sortOrder: 'asc', + limit: fileIds.length, + }, + tx + ) + ).files + : [] + return { ...page, files, capabilities: { canRead: true as const, canWrite: context.canWrite } } + }, +}) diff --git a/apps/sim/lib/projects/files/application/search.ts b/apps/sim/lib/projects/files/application/search.ts new file mode 100644 index 00000000000..dda0d73b021 --- /dev/null +++ b/apps/sim/lib/projects/files/application/search.ts @@ -0,0 +1,73 @@ +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { defineAuthorizedProjectFileUseCase } from '@/lib/projects/files/application/authorized-use-case' +import { listProjectFileFolders } from '@/lib/projects/files/application/folders' +import { projectFileOperations } from '@/lib/projects/files/application/operations' +import { listFileFolders } from '@/lib/uploads/contexts/workspace' +import type { WorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' +import { reportWorkspaceFileDelivery } from '@/lib/workspace-files/application/file-delivery-observer' +import type { SearchWorkspaceFileContentInput } from '@/lib/workspace-files/application/search-workspace-file-content' +import { + loadFileSearchDelivery, + resolveFileSearchFolderScope, +} from '@/lib/workspace-files/search/delivery' +import { WorkspaceFileSearchUnavailableError } from '@/lib/workspace-files/search/errors' +import { + compileFileSearchPattern, + FileSearchPatternError, +} from '@/lib/workspace-files/search/pattern' +import { type FileSearchResult, searchFileIndex } from '@/lib/workspace-files/search/repository' + +export interface SearchProjectFileContentInput + extends Omit { + projectId: string +} + +export const searchProjectFileContent = defineAuthorizedProjectFileUseCase< + typeof projectFileOperations.searchContent, + SearchProjectFileContentInput, + FileSearchResult & { secretProvenance: WorkspaceFileSecretProvenance }, + FileSearchResult +>({ + operation: projectFileOperations.searchContent, + async prepare({ input, principal, context, request }) { + const signal = input.signal ?? request?.signal + signal?.throwIfAborted() + const folders = + input.folderPaths === undefined + ? [] + : ( + await listProjectFileFolders.execute({ + principal, + input: { projectId: context.projectId }, + }) + ).folders + try { + return await searchFileIndex({ + owner: context.owner, + pattern: compileFileSearchPattern(input.query, input.mode), + maxResults: input.maxResults, + folderScope: resolveFileSearchFolderScope(folders, input), + signal, + }) + } catch (error) { + if (error instanceof FileSearchPatternError) + throw new OrchestrationError('validation', error.message) + if (error instanceof WorkspaceFileSearchUnavailableError) + throw new OrchestrationError('locked', error.message) + throw error + } + }, + async execute({ tx, input, context, prepared, request }) { + if (!prepared) throw new Error('Search snapshot is missing') + const folders = + input.folderPaths === undefined ? [] : await listFileFolders(context.owner, {}, tx) + const secretProvenance = await loadFileSearchDelivery(tx, { + owner: context.owner, + prepared, + scope: resolveFileSearchFolderScope(folders, input), + signal: input.signal ?? request?.signal, + }) + return { ...prepared, secretProvenance } + }, + afterSuccess: ({ result }) => reportWorkspaceFileDelivery(result.secretProvenance), +}) diff --git a/apps/sim/lib/projects/files/application/shares.ts b/apps/sim/lib/projects/files/application/shares.ts new file mode 100644 index 00000000000..30fbac2014b --- /dev/null +++ b/apps/sim/lib/projects/files/application/shares.ts @@ -0,0 +1,137 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { type Principal, requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbTransaction } from '@/lib/db/types' +import { CAPABILITY_RULES, refuseCapability } from '@/lib/permission-groups/capabilities' +import { resolvePermissionGroupConfig } from '@/lib/permission-groups/config-scope.server' +import { getUserPermissionConfigForOrganization } from '@/lib/permission-groups/resolve.server' +import type { + ProjectFileAuthorizationContext, + ProjectFileTarget, +} from '@/lib/projects/files/application/authorization' +import { defineAuthorizedProjectFileUseCase } from '@/lib/projects/files/application/authorized-use-case' +import { projectFileOperations } from '@/lib/projects/files/application/operations' +import { + type FileShareUpdate, + getOwnedFileShare, + ShareValidationError, + upsertOwnedFileShare, +} from '@/lib/public-shares/share-manager' + +interface ShareTarget extends ProjectFileTarget { + fileId: string +} +interface UpdateShareInput extends ShareTarget, Omit {} + +interface ProjectFileSharePolicy { + canPublish: boolean + allowedAuthTypes: NonNullable[] +} + +async function resolveProjectFileSharePolicy( + principal: Principal, + context: ProjectFileAuthorizationContext, + tx: DbTransaction +): Promise { + const userId = requirePrincipalSubjectUserId(principal) + const configs: Awaited>[] = [] + for (const workspaceId of context.visibleWorkspaceIds) { + configs.push( + await resolvePermissionGroupConfig(userId, workspaceId, context.organizationId, tx) + ) + } + if (configs.length === 0 && context.organizationId) + configs.push(await getUserPermissionConfigForOrganization(context.organizationId, tx)) + const modes = ['public', 'password', 'email', 'sso'] as const + return { + canPublish: configs.every( + (config) => !config || !CAPABILITY_RULES['file_share.publish'].deniedBy(config) + ), + allowedAuthTypes: modes.filter((mode) => + configs.every( + (config) => !config || !CAPABILITY_RULES['file_share.auth_mode'].deniedBy(config, mode) + ) + ), + } +} + +export const getProjectFileShare = defineAuthorizedProjectFileUseCase< + typeof projectFileOperations.readShare, + ShareTarget, + { + share: Awaited> + policy: ProjectFileSharePolicy + capabilities: { canRead: true; canWrite: boolean } + } +>({ + operation: projectFileOperations.readShare, + async execute({ + principal, + input, + context, + tx, + }: { + principal: Principal + input: ShareTarget + context: ProjectFileAuthorizationContext + tx: DbTransaction + }) { + return { + share: await getOwnedFileShare(tx, context.owner, input.fileId), + policy: await resolveProjectFileSharePolicy(principal, context, tx), + capabilities: { canRead: true, canWrite: context.canWrite }, + } + }, +}) + +export const updateProjectFileShare = defineAuthorizedProjectFileUseCase< + typeof projectFileOperations.updateShare, + UpdateShareInput, + { share: Awaited> } +>({ + operation: projectFileOperations.updateShare, + invalidatesFileList: ({ result }) => result.share !== null, + async execute({ + principal, + input, + context, + tx, + }: { + principal: Principal + input: UpdateShareInput + context: ProjectFileAuthorizationContext + tx: DbTransaction + }) { + const existing = await getOwnedFileShare(tx, context.owner, input.fileId) + const effectiveAuthType = input.authType ?? existing?.authType ?? 'public' + if (input.isActive) { + const policy = await resolveProjectFileSharePolicy(principal, context, tx) + // permission-group-enforced: file_share.publish — the read projection and write gate share every applicable environment policy. + if (!policy.canPublish) refuseCapability('file_share.publish') + // permission-group-enforced: file_share.auth_mode — only the intersection of applicable modes may be published. + if (!policy.allowedAuthTypes.includes(effectiveAuthType)) + refuseCapability('file_share.auth_mode') + } + + try { + return { + share: await upsertOwnedFileShare(tx, context.owner, { + ...input, + userId: requirePrincipalSubjectUserId(principal), + }), + } + } catch (error) { + if (error instanceof ShareValidationError) + throw new OrchestrationError('validation', error.message) + throw error + } + }, + projectAudit: ({ input, context }) => ({ + action: input.isActive ? AuditAction.FILE_SHARED : AuditAction.FILE_SHARE_DISABLED, + resourceType: AuditResourceType.FILE, + resourceId: input.fileId, + resourceName: context.file?.originalName, + description: `${input.isActive ? 'Enabled' : 'Disabled'} public share for Project file`, + metadata: { projectId: context.projectId }, + }), +}) diff --git a/apps/sim/lib/projects/files/application/uploads.ts b/apps/sim/lib/projects/files/application/uploads.ts new file mode 100644 index 00000000000..7e4a26b9714 --- /dev/null +++ b/apps/sim/lib/projects/files/application/uploads.ts @@ -0,0 +1,384 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { type Principal, requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { db } from '@sim/db' +import { workspaceFiles } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq, isNull } from 'drizzle-orm' +import { resolveProjectStorageBillingContext } from '@/lib/billing/storage/context' +import { checkStorageQuotaForBillingContext } from '@/lib/billing/storage/limits' +import type { AuthorizingUseCase } from '@/lib/core/application/authorized-workspace-use-case' +import { runWithOutboundOrganization } from '@/lib/core/network/context.server' +import { + OrchestrationError, + type OrchestrationRequestContext, +} from '@/lib/core/orchestration/types' +import type { DbTransaction } from '@/lib/db/types' +import { + createProjectFileAuthorizer, + type ProjectFileAuthorizationContext, + type ProjectFileTarget, +} from '@/lib/projects/files/application/authorization' +import { defineAuthorizedProjectFileUseCase } from '@/lib/projects/files/application/authorized-use-case' +import { + type ProjectFileOperation, + projectFileOperations, +} from '@/lib/projects/files/application/operations' +import { + finishProjectFileWrite, + mapProjectFileResult, + prepareProjectFileAccounting, + recordProjectFileWriteEffects, +} from '@/lib/projects/files/application/write-effects' +import { + projectUploadCleanupAvailableAt, + queueRetiredProjectUploadCleanup, +} from '@/lib/projects/files/prefix-cleanup' +import { resolveFileFolderTarget } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' +import { + adoptVerifiedUploadSession, + commitFileCreateInTx, + discardStagedFileContent, + type StagedFileContent, + stageFileContent, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import type { WorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' +import { enqueueWorkspaceFileStorageCleanups } from '@/lib/uploads/contexts/workspace/workspace-file-storage-cleanup-outbox' +import { downloadFile } from '@/lib/uploads/core/storage-service' +import { requestOrigin } from '@/lib/uploads/upload-session/application' +import { assertProjectFileUploadBinding } from '@/lib/uploads/upload-session/project-file-binding' +import { readProjectFileUploadProvenance } from '@/lib/uploads/upload-session/project-file-provenance' +import { + abortUploadSession, + type CreatedUploadSession, + completeUploadSession, + createUploadPartUrls, + createUploadSession, + getOwnedUploadSession, + lockUploadSessionRegistrationInTx, + type UploadSessionRecord, +} from '@/lib/uploads/upload-session/service' +import type { WorkspaceFileUploadSource } from '@/lib/uploads/upload-session/workspace-file-provenance' +import { SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile' +import { + MAX_SIM_PAGE_UPLOAD_SNIFF_BYTES, + restoreSimPageSourceBuffer, +} from '@/lib/workspace-files/page-source-embed' + +const logger = createLogger('ProjectFileUploads') + +export interface CreateProjectFileUploadInput extends ProjectFileTarget { + fileName: string + contentType: string + fileSize: number + folderId?: string | null + folderPath?: string + exactName?: boolean + localOrigin?: string + /** Host-owned classification; public upload contracts never accept this field. */ + secretProvenance?: WorkspaceFileUploadSource +} + +export interface ProjectFileUploadControlInput extends ProjectFileTarget { + uploadId: string + uploadToken: string + partNumbers?: number[] + localOrigin?: string + /** Trusted evidence for a pending streamed upload; never request-body claims. */ + secretProvenance?: WorkspaceFileSecretProvenance +} + +interface UploadArgs { + principal: Principal + input: I + context: ProjectFileAuthorizationContext + request?: OrchestrationRequestContext + tx: DbTransaction + prepared?: P +} + +async function loadRegisteredFile( + tx: DbTransaction, + context: ProjectFileAuthorizationContext, + fileId: string +) { + const [row] = await tx + .select() + .from(workspaceFiles) + .where( + and( + eq(workspaceFiles.id, fileId), + eq(workspaceFiles.projectId, context.projectId), + eq(workspaceFiles.context, 'project'), + isNull(workspaceFiles.deletedAt) + ) + ) + .for('share') + .limit(1) + return row ? mapProjectFileResult(tx, context, row) : null +} + +export const createProjectFileUploadSession = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.uploadCreate, + async prepare({ + principal, + input, + context, + request, + }: Omit, 'tx'>) { + const folder = await resolveFileFolderTarget(context.owner, input) + const billing = await resolveProjectStorageBillingContext({ + projectId: context.projectId, + ownerId: context.ownerUserId, + organizationId: context.organizationId, + }) + const quota = await checkStorageQuotaForBillingContext(billing, input.fileSize) + if (!quota.allowed) + throw new OrchestrationError('payload_too_large', quota.error ?? 'Storage limit exceeded') + return createUploadSession({ + purpose: 'project_file', + projectId: context.projectId, + userId: requirePrincipalSubjectUserId(principal), + principal, + fileName: input.fileName, + contentType: input.contentType, + fileSize: input.fileSize, + localOrigin: input.localOrigin ?? (request ? requestOrigin(request) : undefined), + secretProvenance: input.secretProvenance, + metadata: { folderId: folder?.id ?? null, exactName: input.exactName === true }, + }) + }, + async execute({ + principal, + context, + tx, + prepared, + }: UploadArgs) { + if (!prepared) throw new Error('Upload session was not prepared') + assertProjectFileUploadBinding(prepared, principal, context.projectId) + await resolveFileFolderTarget(context.owner, { folderId: uploadFolderId(prepared) }, tx) + return prepared + }, + async onCommitFailure({ prepared }) { + try { + await abortUploadSession(prepared) + } catch (error) { + logger.warn('Upload cancellation will be retried by expiry cleanup', { + uploadId: prepared.id, + error, + }) + } finally { + await queueRetiredProjectUploadCleanup(prepared) + } + }, +}) + +function uploadFolderId(session: UploadSessionRecord) { + const id = session.metadata.folderId + if (id !== null && typeof id !== 'string') + throw new OrchestrationError('conflict', 'Upload folder binding is invalid') + return id +} + +async function loadControl( + principal: Principal, + operation: ProjectFileOperation, + input: ProjectFileUploadControlInput +) { + const authorize = await createProjectFileAuthorizer(principal, operation, input) + return db.transaction(async (tx) => { + const context = await authorize(tx) + const session = await getOwnedUploadSession({ + uploadId: input.uploadId, + uploadToken: input.uploadToken, + purpose: 'project_file', + principal, + executor: tx, + }) + assertProjectFileUploadBinding(session, principal, context.projectId) + const file = session.completedFileId + ? await loadRegisteredFile(tx, context, session.completedFileId) + : null + return { session, context, file } + }) +} + +function controlUseCase( + operation: O, + execute: (args: { + principal: Principal + input: ProjectFileUploadControlInput + loaded: Awaited> + request?: OrchestrationRequestContext + }) => Promise +): AuthorizingUseCase { + return { + operation, + delegationAudience: operation.delegationAudience, + async authorize({ principal, input }) { + await loadControl(principal, operation, input) + }, + async execute({ principal, input, request }) { + const loaded = await loadControl(principal, operation, input) + return runWithOutboundOrganization(loaded.context.organizationId, () => + execute({ principal, input, loaded, request }) + ) + }, + } +} + +export const getProjectFileUploadSession = controlUseCase( + projectFileOperations.uploadRead, + async ({ loaded }) => ({ session: loaded.session, file: loaded.file?.file ?? null }) +) + +export const getProjectFileUploadPartUrls = controlUseCase( + projectFileOperations.uploadParts, + async ({ principal, input, loaded, request }) => { + const localOrigin = input.localOrigin ?? (request ? requestOrigin(request) : undefined) + if (!localOrigin) + throw new OrchestrationError('validation', 'Upload part URLs require an origin') + const parts = await createUploadPartUrls({ + session: loaded.session, + partNumbers: input.partNumbers ?? [], + localOrigin, + }) + const current = await loadControl(principal, projectFileOperations.uploadParts, input) + if ( + current.session.status !== 'uploading' || + current.session.completedFileId || + current.session.expiresAt.getTime() <= Date.now() + ) + throw new OrchestrationError('conflict', 'Upload session no longer accepts bytes') + return { parts } + } +) + +export const abortProjectFileUploadSession = controlUseCase( + projectFileOperations.uploadCancel, + async ({ loaded }) => abortUploadSession(loaded.session) +) + +interface RegisterUploadInput extends ProjectFileTarget { + session: UploadSessionRecord +} +interface PreparedUploadContent { + staged: StagedFileContent + restored: boolean +} +const registerProjectUpload = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.uploadComplete, + invalidatesFileList: true, + async prepare({ + principal, + input, + context, + }: Omit, 'tx'>): Promise { + assertProjectFileUploadBinding(input.session, principal, context.projectId) + const adopted = adoptVerifiedUploadSession(input.session, context.owner) + if ( + adopted.name.toLowerCase().endsWith('.html') && + adopted.size > 0 && + adopted.size <= MAX_SIM_PAGE_UPLOAD_SNIFF_BYTES + ) { + const bytes = await downloadFile({ key: adopted.key, context: 'project' }) + if (bytes.length !== adopted.size) + throw new OrchestrationError('conflict', 'Upload bytes changed before registration') + const restored = restoreSimPageSourceBuffer(adopted.name, bytes) + if (restored) + return { + staged: await stageFileContent({ + owner: context.owner, + userId: requirePrincipalSubjectUserId(principal), + name: restored.name, + contentType: SIM_PAGE_CONTENT_TYPE, + content: restored.buffer, + }), + restored: true, + } + } + return { staged: adopted, restored: false } + }, + async execute({ + principal, + input, + context, + tx, + prepared, + }: UploadArgs) { + if (!prepared) throw new Error('Upload bytes were not prepared') + assertProjectFileUploadBinding(input.session, principal, context.projectId) + const { billing, mutation: accounting } = await prepareProjectFileAccounting(tx, context) + const registration = await lockUploadSessionRegistrationInTx(tx, input.session) + const file = await commitFileCreateInTx(tx, { + owner: context.owner, + staged: prepared.staged, + userId: requirePrincipalSubjectUserId(principal), + folderId: uploadFolderId(input.session), + exactName: input.session.metadata.exactName === true, + secretProvenance: readProjectFileUploadProvenance(input.session.metadata, context.projectId), + }) + const usage = await accounting.applyDelta(prepared.staged.size) + await registration.registerFile(file.id) + const cleanupIds = prepared.restored + ? await enqueueWorkspaceFileStorageCleanups(tx, [input.session.finalKey], 'project', { + availableAt: projectUploadCleanupAvailableAt(), + }) + : [] + const result = await mapProjectFileResult(tx, context, file) + recordProjectFileWriteEffects(result, { + billing, + usage, + delta: prepared.staged.size, + cleanupIds, + cleanupReason: 'restored editable page source', + }) + return result + }, + async onCommitFailure({ prepared }) { + if (prepared.restored) await discardStagedFileContent(prepared.staged) + }, + async afterSuccess({ result }) { + await finishProjectFileWrite(result) + }, + projectAudit: ({ input, result }) => ({ + action: AuditAction.FILE_UPLOADED, + resourceType: AuditResourceType.FILE, + resourceId: result.file.id, + resourceName: result.file.name, + description: `Uploaded Project file "${result.file.name}"`, + metadata: { + projectId: input.projectId, + fileSize: result.file.size, + fileType: result.file.type, + }, + }), +}) + +export const completeProjectFileUploadSession = controlUseCase( + projectFileOperations.uploadComplete, + async ({ principal, input, loaded }) => { + if (loaded.session.status === 'completed' && !loaded.session.completedFileId) + throw new OrchestrationError('conflict', 'Completed upload has no registered file') + try { + return await completeUploadSession({ + session: loaded.session, + secretProvenance: input.secretProvenance, + async loadCompleted() { + const current = await loadControl(principal, projectFileOperations.uploadComplete, input) + if (!current.file) throw new OrchestrationError('not_found', 'Uploaded file not found') + return current.file + }, + async finalize(session) { + const value = await registerProjectUpload.execute({ + principal, + input: { projectId: loaded.context.projectId, session }, + }) + return { value, completedFileId: value.file.id } + }, + }) + } catch (error) { + await queueRetiredProjectUploadCleanup(loaded.session) + throw error + } + } +) diff --git a/apps/sim/lib/projects/files/application/versions.ts b/apps/sim/lib/projects/files/application/versions.ts new file mode 100644 index 00000000000..0737c8e3281 --- /dev/null +++ b/apps/sim/lib/projects/files/application/versions.ts @@ -0,0 +1,437 @@ +import { AuditAction, AuditResourceType } from '@sim/audit' +import { type Principal, requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { type WorkspaceFileRow, workspaceFiles } from '@sim/db/schema' +import { and, eq, isNull } from 'drizzle-orm' +import type { CursorKey, ListSortOrder } from '@/lib/api/list-query' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbTransaction } from '@/lib/db/types' +import type { + ProjectFileAuthorizationContext, + ProjectFileTarget, +} from '@/lib/projects/files/application/authorization' +import { defineAuthorizedProjectFileUseCase } from '@/lib/projects/files/application/authorized-use-case' +import { + type ProjectFileOperation, + projectFileOperations, +} from '@/lib/projects/files/application/operations' +import { + finishProjectFileWrite, + mapProjectFileResult, + prepareProjectFileAccounting, + recordProjectFileWriteEffects, +} from '@/lib/projects/files/application/write-effects' +import { + ContentVersionConflictError, + commitFileContentInTx, + discardStagedFileContent, + mapFileRecord, + type StagedFileContent, + stageFileContent, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { + snapshotWorkspaceFileSecretProvenanceInTx, + type WorkspaceFileSecretProvenanceSnapshot, + workspaceFileSecretProvenanceFromSnapshot, +} from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' +import { + enqueueWorkspaceFileStorageCleanups, + processWorkspaceFileStorageCleanupsNow, +} from '@/lib/uploads/contexts/workspace/workspace-file-storage-cleanup-outbox' +import { + deleteWorkspaceFileVersionInTx, + getCurrentWorkspaceFileVersion, + getWorkspaceFileVersion, + getWorkspaceFileVersionProvenance, + queryWorkspaceFileVersions, + type WorkspaceFileVersionRecord, +} from '@/lib/uploads/contexts/workspace/workspace-file-versions' +import { downloadFile } from '@/lib/uploads/core/storage-service' +import { enqueueFileLiveDocReconciliation } from '@/lib/uploads/server/live-doc-outbox' +import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' +import { isMarkdownFile } from '@/lib/uploads/utils/file-utils' +import { reportWorkspaceFileDelivery } from '@/lib/workspace-files/application/file-delivery-observer' +import { workspaceFileRevisionField } from '@/lib/workspace-files/application/file-revision' +import { resolveWorkspaceFileVersionWrite } from '@/lib/workspace-files/application/file-version-write' +import { projectFileVersionAuthors } from '@/lib/workspace-files/application/version-authors' +import { + assertFileVersionRevision, + readFileVersionObject, +} from '@/lib/workspace-files/application/version-content' +import { fileOwnerCondition } from '@/lib/workspace-files/ownership-query' + +interface VersionTarget extends ProjectFileTarget { + fileId: string + version: number +} +interface RevertInput extends VersionTarget { + expectedCurrentVersion?: number + expectedRevision?: string +} +interface VersionArgs { + principal: Principal + input: I + context: ProjectFileAuthorizationContext + tx: DbTransaction + prepared?: P +} +interface VersionSnapshot { + file: WorkspaceFileRow + current: WorkspaceFileVersionRecord + version: WorkspaceFileVersionRecord + provenance: WorkspaceFileSecretProvenanceSnapshot +} +interface PreparedVersionRead { + snapshot: VersionSnapshot + content: Buffer +} +interface PreparedRevert { + snapshot: VersionSnapshot + staged?: StagedFileContent +} + +function requireFile(context: ProjectFileAuthorizationContext) { + if (!context.file) throw new OrchestrationError('not_found', 'File not found') + return context.file +} + +async function projectVersionAuthor(tx: DbTransaction, version: WorkspaceFileVersionRecord) { + const [result] = await projectFileVersionAuthors([version], tx) + return result +} + +async function lockFile(tx: DbTransaction, context: ProjectFileAuthorizationContext) { + const [file] = await tx + .select() + .from(workspaceFiles) + .where( + and( + fileOwnerCondition(context.owner), + eq(workspaceFiles.id, requireFile(context).id), + isNull(workspaceFiles.deletedAt) + ) + ) + .for('update') + .limit(1) + if (!file) throw new OrchestrationError('not_found', 'File not found') + return { ...context, file } +} + +function versionNumber(version: number) { + if (!Number.isSafeInteger(version) || version < 1) + throw new OrchestrationError('validation', 'Invalid file version') +} + +async function loadVersion( + tx: DbTransaction, + context: ProjectFileAuthorizationContext, + version: number +) { + versionNumber(version) + const file = mapFileRecord(requireFile(context), context.owner, new Map()) + const target = await getWorkspaceFileVersion(file, version, tx) + if (!target) throw new OrchestrationError('not_found', `Version ${version} not found`) + return target +} + +async function captureVersion( + tx: DbTransaction, + context: ProjectFileAuthorizationContext, + input: RevertInput +): Promise { + const file = requireFile(context) + const mapped = mapFileRecord(file, context.owner, new Map()) + const current = await getCurrentWorkspaceFileVersion(mapped, tx) + if ( + input.expectedCurrentVersion !== undefined && + current.version !== input.expectedCurrentVersion + ) + throw new OrchestrationError('conflict', 'The current file version changed') + assertFileVersionRevision( + file, + input.expectedRevision, + 'The file changed since the revision you read' + ) + const version = await loadVersion(tx, context, input.version) + const provenance = version.isCurrent + ? await snapshotWorkspaceFileSecretProvenanceInTx( + tx, + file.id, + file.contentUpdatedAt, + file.secretProvenanceVersion + ) + : await getWorkspaceFileVersionProvenance(file.id, version.version, version.key, tx) + if (!provenance) throw new OrchestrationError('not_found', `Version ${version.version} not found`) + return { + file, + current, + version, + provenance: provenance.status === null ? { status: 'unknown', entries: [] } : provenance, + } +} + +function snapshotUseCase(operation: O) { + return defineAuthorizedProjectFileUseCase({ + operation, + execute: ({ tx, context, input }) => captureVersion(tx, context, input), + }) +} + +const snapshotRead = snapshotUseCase(projectFileOperations.readVersionContent) +const snapshotRevert = snapshotUseCase(projectFileOperations.revertVersion) + +async function readVersionBytes( + version: WorkspaceFileVersionRecord, + maxBytes = MAX_BUFFERED_TRANSFER_BYTES +) { + if (!Number.isSafeInteger(maxBytes) || maxBytes < 1 || maxBytes > MAX_BUFFERED_TRANSFER_BYTES) + throw new OrchestrationError('validation', 'Invalid file byte limit') + return readFileVersionObject(version.version, () => + downloadFile({ key: version.key, context: 'project', maxBytes }) + ) +} + +function requireVersionMatch( + current: VersionSnapshot, + expected: VersionSnapshot, + requireHead: boolean +) { + if ( + current.version.key !== expected.version.key || + current.version.updatedAt.getTime() !== expected.version.updatedAt.getTime() || + (requireHead && + (current.file.key !== expected.file.key || + current.file.contentUpdatedAt.getTime() !== expected.file.contentUpdatedAt.getTime())) + ) + throw new OrchestrationError( + 'conflict', + 'The file or selected version changed during the operation' + ) +} + +export const listProjectFileVersions = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.listVersions, + async execute({ + input, + context, + tx, + }: VersionArgs< + ProjectFileTarget & { + fileId: string + sortOrder: ListSortOrder + limit: number + after?: CursorKey[] + } + >) { + if (!Number.isInteger(input.limit) || input.limit < 1 || input.limit > 1000) + throw new OrchestrationError('validation', 'Invalid version page size') + const page = await queryWorkspaceFileVersions( + mapFileRecord(requireFile(context), context.owner, new Map()), + input, + tx + ) + return { + ...page, + ...workspaceFileRevisionField(requireFile(context)), + versions: await projectFileVersionAuthors(page.versions, tx), + } + }, +}) + +export const readProjectFileVersion = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.readVersion, + async execute({ input, context, tx }: VersionArgs) { + return { + version: await projectVersionAuthor(tx, await loadVersion(tx, context, input.version)), + } + }, +}) + +const readVersionContent = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.readVersionContent, + async prepare({ + principal, + input, + }: Omit, 'tx'>): Promise { + const snapshot = await snapshotRead.execute({ principal, input }) + return { snapshot, content: await readVersionBytes(snapshot.version, input.maxBytes) } + }, + async execute({ + input, + context, + tx, + prepared, + }: VersionArgs) { + if (!prepared) throw new Error('Version read was not prepared') + const current = await captureVersion(tx, context, input) + requireVersionMatch(current, prepared.snapshot, false) + return { + ...(await mapProjectFileResult(tx, context, current.file)), + version: await projectVersionAuthor(tx, current.version), + content: prepared.content, + secretProvenance: workspaceFileSecretProvenanceFromSnapshot(current.provenance), + } + }, + afterSuccess: ({ result }) => reportWorkspaceFileDelivery(result.secretProvenance), +}) + +/** HEAD validates retention and the selected version under the same operation without storage I/O. */ +export const readProjectFileVersionContent = { + ...readVersionContent, + async authorize(args: Parameters[0]) { + await snapshotRead.execute(args) + }, +} + +export const revertProjectFileVersion = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.revertVersion, + invalidatesFileList: ({ result }) => result.reverted, + async prepare({ + principal, + input, + context, + }: Omit, 'tx'>): Promise { + const snapshot = await snapshotRevert.execute({ principal, input }) + if (snapshot.version.isCurrent) return { snapshot } + const content = await readVersionBytes(snapshot.version) + const staged = await stageFileContent({ + owner: context.owner, + userId: requirePrincipalSubjectUserId(principal), + name: snapshot.file.originalName, + contentType: snapshot.version.contentType, + content, + }) + return { snapshot, staged } + }, + async execute({ + principal, + input, + context, + tx, + prepared, + }: VersionArgs) { + if (!prepared) throw new Error('Revert was not prepared') + const accounting = prepared.staged ? await prepareProjectFileAccounting(tx, context) : undefined + const canonical = await lockFile(tx, context) + const current = await captureVersion(tx, canonical, input) + requireVersionMatch(current, prepared.snapshot, true) + if (!prepared.staged || !accounting) { + return { + ...(await mapProjectFileResult(tx, canonical, current.file)), + version: await projectVersionAuthor(tx, current.version), + reverted: false, + revertedFrom: current.current.version, + } + } + let committed + try { + committed = await commitFileContentInTx(tx, { + owner: canonical.owner, + fileId: input.fileId, + staged: prepared.staged, + expectedUpdatedAt: prepared.snapshot.file.contentUpdatedAt, + version: resolveWorkspaceFileVersionWrite(principal, { + source: 'revert', + restoredFromVersion: current.version.version, + }), + secretProvenancePolicy: { mode: 'reinstate', snapshot: current.provenance }, + }) + } catch (error) { + if (error instanceof ContentVersionConflictError) + throw new OrchestrationError('conflict', error.message) + throw error + } + const liveDocEventId = + isMarkdownFile({ name: current.file.originalName, type: current.file.contentType }) || + isMarkdownFile({ name: committed.file.originalName, type: committed.file.contentType }) + ? await enqueueFileLiveDocReconciliation(tx, { + owner: canonical.owner, + fileId: input.fileId, + version: committed.file.contentUpdatedAt.getTime(), + }) + : undefined + const usage = await accounting.mutation.applyDelta(committed.sizeDiff) + const result = { + ...(await mapProjectFileResult(tx, canonical, committed.file)), + version: await projectVersionAuthor( + tx, + await getCurrentWorkspaceFileVersion( + mapFileRecord(committed.file, canonical.owner, new Map()), + tx + ) + ), + reverted: true, + revertedFrom: current.current.version, + } + recordProjectFileWriteEffects(result, { + billing: accounting.billing, + usage, + delta: committed.sizeDiff, + cleanupIds: committed.storageCleanupEventIds, + liveDocEventId, + }) + return result + }, + async onCommitFailure({ prepared }) { + if (prepared.staged) await discardStagedFileContent(prepared.staged) + }, + async afterSuccess({ result }) { + if (result.reverted) await finishProjectFileWrite(result) + }, + projectAudit: ({ input, result }) => + result.reverted + ? { + action: AuditAction.FILE_REVERTED, + resourceType: AuditResourceType.FILE, + resourceId: result.file.id, + resourceName: result.file.name, + description: `Reverted Project file "${result.file.name}"`, + metadata: { + projectId: input.projectId, + previousVersion: result.revertedFrom, + restoredVersion: input.version, + newVersion: result.version.version, + }, + } + : [], +}) + +const deletionEffects = new WeakMap() + +export const deleteProjectFileVersion = defineAuthorizedProjectFileUseCase({ + operation: projectFileOperations.deleteVersion, + async execute({ input, context, tx }: VersionArgs) { + const canonical = await lockFile(tx, context) + const version = await loadVersion(tx, canonical, input.version) + if (version.isCurrent) + throw new OrchestrationError('conflict', 'The current version cannot be deleted') + const deletion = await deleteWorkspaceFileVersionInTx(tx, input.fileId, input.version) + if (deletion.status === 'not_found') + throw new OrchestrationError('not_found', 'Version not found') + if (deletion.status === 'newest') + throw new OrchestrationError('conflict', 'The newest version cannot be deleted') + const events = await enqueueWorkspaceFileStorageCleanups(tx, [deletion.key], 'project') + const result = { + ...(await mapProjectFileResult(tx, canonical, canonical.file)), + version: version.version, + } + deletionEffects.set(result, events) + return result + }, + async afterSuccess({ result, input }) { + const events = deletionEffects.get(result) ?? [] + deletionEffects.delete(result) + await processWorkspaceFileStorageCleanupsNow(events, { + projectId: input.projectId, + fileId: input.fileId, + reason: 'deleted version', + }) + }, + projectAudit: ({ input, result }) => ({ + action: AuditAction.FILE_VERSION_DELETED, + resourceType: AuditResourceType.FILE, + resourceId: result.file.id, + resourceName: result.file.name, + description: `Deleted version ${result.version} of Project file "${result.file.name}"`, + metadata: { projectId: input.projectId, version: result.version }, + }), +}) diff --git a/apps/sim/lib/projects/files/application/write-effects.ts b/apps/sim/lib/projects/files/application/write-effects.ts new file mode 100644 index 00000000000..1a3d823c6b8 --- /dev/null +++ b/apps/sim/lib/projects/files/application/write-effects.ts @@ -0,0 +1,64 @@ +import type { WorkspaceFileRow } from '@sim/db/schema' +import { prepareFileAccountingInTx } from '@/lib/billing/storage/accounting' +import type { ProjectStorageBillingContext } from '@/lib/billing/storage/context' +import { maybeNotifyStorageLimitForBillingContext } from '@/lib/billing/storage/tracking' +import type { DbTransaction } from '@/lib/db/types' +import type { ProjectFileAuthorizationContext } from '@/lib/projects/files/application/authorization' +import { + buildWorkspaceFileFolderPathMap, + listFileFolders, +} from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' +import { mapFileRecord } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { finishFileContentEffects } from '@/lib/uploads/server/content-effects' + +interface CommittedWriteEffects { + billing: ProjectStorageBillingContext + usage: number + delta: number + cleanupIds: string[] + liveDocEventId?: string + cleanupReason?: string +} + +const committedWriteEffects = new WeakMap() + +/** Attaches private postcommit work to an authoritative result without adding wire fields. */ +export function recordProjectFileWriteEffects(result: object, effects: CommittedWriteEffects) { + committedWriteEffects.set(result, effects) +} + +/** Applies notifications and durable cleanup only after the metadata transaction commits. */ +export async function finishProjectFileWrite(result: object) { + const effects = committedWriteEffects.get(result) + if (!effects) throw new Error('Committed file effects are unavailable') + committedWriteEffects.delete(result) + await maybeNotifyStorageLimitForBillingContext(effects.billing, effects.usage, effects.delta < 0) + await finishFileContentEffects(effects, { + projectId: effects.billing.projectId, + reason: effects.cleanupReason ?? 'released version', + }) +} + +/** Projects owner-aware metadata using the caller's authorized transaction. */ +export async function mapProjectFileResult( + tx: DbTransaction, + context: ProjectFileAuthorizationContext, + file: WorkspaceFileRow +) { + const folders = file.folderId ? await listFileFolders(context.owner, { scope: 'all' }, tx) : [] + return { + file: { + ...mapFileRecord(file, context.owner, buildWorkspaceFileFolderPathMap(folders)), + contentUpdatedAt: file.contentUpdatedAt, + }, + capabilities: { canRead: true as const, canWrite: context.canWrite }, + } +} + +/** Locks the canonical Project payer before content, directory, and history mutation locks. */ +export async function prepareProjectFileAccounting( + tx: DbTransaction, + context: ProjectFileAuthorizationContext +) { + return prepareFileAccountingInTx(tx, { entityType: 'project', entityId: context.projectId }) +} diff --git a/apps/sim/lib/projects/files/prefix-cleanup.ts b/apps/sim/lib/projects/files/prefix-cleanup.ts index a001f752b9d..f4bf6dbe044 100644 --- a/apps/sim/lib/projects/files/prefix-cleanup.ts +++ b/apps/sim/lib/projects/files/prefix-cleanup.ts @@ -80,7 +80,7 @@ export async function recoverProjectStorageReconciliation(now = new Date()): Pro } /** Expired signatures stop new transfers, but a transfer already in progress may finish later. */ -function projectUploadCleanupAvailableAt(now = new Date()): Date { +export function projectUploadCleanupAvailableAt(now = new Date()): Date { return new Date(now.getTime() + UPLOAD_URL_TTL_MS + CLOCK_SKEW_MS) } diff --git a/apps/sim/lib/projects/files/retention.ts b/apps/sim/lib/projects/files/retention.ts index 865a4dd2fc8..fa7299ab29e 100644 --- a/apps/sim/lib/projects/files/retention.ts +++ b/apps/sim/lib/projects/files/retention.ts @@ -1,10 +1,10 @@ import { dbFor } from '@sim/db' -import { folder, organization, project, workspaceFiles, workspaceFileVersion } from '@sim/db/schema' -import { and, asc, count, eq, gt, inArray, isNotNull, isNull, lt, min, or, sql } from 'drizzle-orm' +import { organization, project, workspaceFiles, workspaceFileVersion } from '@sim/db/schema' +import { and, asc, count, eq, gt, inArray, isNotNull, lt, min, or, sql } from 'drizzle-orm' import { CLEANUP_CONFIG } from '@/lib/billing/cleanup-dispatcher' import { getPlanType } from '@/lib/billing/plan-helpers' import { resolveProjectStorageBillingContext } from '@/lib/billing/storage/context' -import { prepareProjectStorageMutationInTx } from '@/lib/billing/storage/tracking' +import { prepareFileStorageMutationInTx } from '@/lib/billing/storage/tracking' import { consumeRowBudget, DEFAULT_DELETE_CHUNK_SIZE, @@ -12,22 +12,24 @@ import { type RowBudget, } from '@/lib/cleanup/batch-delete' import { isBillingEnabled } from '@/lib/core/config/env-flags' -import { generateRestoreName } from '@/lib/core/utils/restore-name' import type { DbTransaction } from '@/lib/db/types' -import { acquireFolderMutationLock } from '@/lib/folders/locks' -import { deduplicateFolderNameInScope } from '@/lib/folders/naming' +import { cleanupExpiredFileFolderInTx } from '@/lib/file-retention/folders' +import { + FILES_PER_QUERY, + KEEP_SUPERSEDED, + releaseExpiredFileVersions, + selectExpiredFileVersions, +} from '@/lib/file-retention/versions' import { lockProject } from '@/lib/projects/membership' -import { workspaceFileNameFolderCondition } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' import { enqueueWorkspaceFileStorageCleanups } from '@/lib/uploads/contexts/workspace/workspace-file-storage-cleanup-outbox' import { MAX_SUPERSEDED_FILE_VERSIONS, releaseWorkspaceFileVersionsForPurgeInTx, } from '@/lib/uploads/contexts/workspace/workspace-file-versions' -import { fileFolderOwnerCondition, fileOwnerCondition } from '@/lib/workspace-files/ownership-query' +import { lockFileDirectories } from '@/lib/workspace-files/locks' +import { fileOwnerCondition } from '@/lib/workspace-files/ownership-query' const cleanupDb = dbFor('cleanup') -const KEEP_SUPERSEDED = 9 -const FILES_PER_QUERY = 500 type FileRetentionJob = 'cleanup-file-versions' | 'cleanup-soft-deletes' @@ -41,7 +43,7 @@ async function prepareRetention(tx: DbTransaction, projectId: string, job: FileR tx ) if (isBillingEnabled && owner.organizationId && billing.plan === null) return null - const mutation = await prepareProjectStorageMutationInTx(tx, billing) + const mutation = await prepareFileStorageMutationInTx(tx, billing) const plan = isBillingEnabled ? getPlanType(billing.plan) : 'enterprise' const config = CLEANUP_CONFIG[job] let hours: number | null = config.defaults[plan] @@ -112,58 +114,18 @@ export async function cleanupProjectFileVersions( ) .orderBy(asc(workspaceFiles.id)) .for('update') - const ranked = tx - .select({ - id: workspaceFileVersion.id, - supersededAt: workspaceFileVersion.supersededAt, - rank: sql`row_number() over (partition by ${workspaceFileVersion.fileId} order by ${workspaceFileVersion.version} desc)`.as( - 'rank' - ), - }) - .from(workspaceFileVersion) - .where( - and( - inArray( - workspaceFileVersion.fileId, - candidates.map((row) => row.id) - ), - isNotNull(workspaceFileVersion.supersededAt) - ) - ) - .as('ranked') const versionLimit = Math.min(DEFAULT_DELETE_CHUNK_SIZE, limit - released) - const expired = await tx - .select({ id: ranked.id }) - .from(ranked) - .where( - and( - gt(ranked.rank, KEEP_SUPERSEDED), - or(lt(ranked.supersededAt, policy.cutoff), gt(ranked.rank, maximum)) - ) - ) - .limit(versionLimit) - const removed = expired.length - ? await tx - .delete(workspaceFileVersion) - .where( - and( - inArray( - workspaceFileVersion.id, - expired.map((row) => row.id) - ), - isNotNull(workspaceFileVersion.supersededAt) - ) - ) - .returning({ key: workspaceFileVersion.key }) - : [] - await enqueueWorkspaceFileStorageCleanups( + const expired = await selectExpiredFileVersions( tx, - removed.map((row) => row.key), - 'project' + candidates.map(({ id }) => id), + policy.cutoff, + maximum, + versionLimit ) + const removed = await releaseExpiredFileVersions(tx, expired, 'project') return { - lastId: removed.length === versionLimit ? afterId : candidates[candidates.length - 1].id, - removed: removed.length, + lastId: removed === versionLimit ? afterId : candidates[candidates.length - 1].id, + removed, } }) if (!batch) break @@ -183,7 +145,7 @@ export async function cleanupArchivedProjectFiles( const removed = await cleanupDb.transaction(async (tx) => { const policy = await prepareRetention(tx, projectId, 'cleanup-soft-deletes') if (!policy) return 0 - await acquireFolderMutationLock(tx, `project:${projectId}`, 'file') + await lockFileDirectories(tx, [{ entityType: 'project', entityId: projectId }]) const rows = await tx .select({ id: workspaceFiles.id, @@ -235,88 +197,22 @@ export async function cleanupArchivedProjectFiles( return total } -/** Surviving children are re-rooted under the directory lock before an expired folder is removed. */ +/** Surviving children are re-rooted atomically under current Project retention policy. */ export async function cleanupArchivedProjectFileFolders( projectId: string, budget?: RowBudget ): Promise { let total = 0 - const owner = { entityType: 'project' as const, entityId: projectId } - const folderScope = fileFolderOwnerCondition(owner) for (let batch = 0; batch < DEFAULT_MAX_BATCHES_PER_TABLE && budget?.remaining !== 0; batch++) { const removed = await cleanupDb.transaction(async (tx) => { const policy = await prepareRetention(tx, projectId, 'cleanup-soft-deletes') if (!policy) return 0 - await acquireFolderMutationLock(tx, `project:${projectId}`, 'file') - const [expired] = await tx - .select({ id: folder.id }) - .from(folder) - .where(and(folderScope, isNotNull(folder.deletedAt), lt(folder.deletedAt, policy.cutoff))) - .orderBy(asc(folder.id)) - .limit(1) - .for('update') - if (!expired) return 0 - consumeRowBudget(budget, 1) - for (;;) { - const children = await tx - .select({ id: folder.id, name: folder.name }) - .from(folder) - .where(and(folderScope, eq(folder.parentId, expired.id), isNull(folder.deletedAt))) - .orderBy(asc(folder.id)) - .limit(FILES_PER_QUERY) - .for('update') - if (!children.length) break - for (const child of children) { - const name = await deduplicateFolderNameInScope(tx, folderScope, null, child.name) - await tx - .update(folder) - .set({ parentId: null, name, updatedAt: new Date() }) - .where(and(folderScope, eq(folder.id, child.id))) - } - } - for (;;) { - const children = await tx - .select({ id: workspaceFiles.id, name: workspaceFiles.originalName }) - .from(workspaceFiles) - .where( - and( - projectFiles(projectId), - eq(workspaceFiles.folderId, expired.id), - isNull(workspaceFiles.deletedAt) - ) - ) - .orderBy(asc(workspaceFiles.id)) - .limit(FILES_PER_QUERY) - .for('update') - if (!children.length) break - for (const child of children) { - const originalName = await generateRestoreName( - child.name, - async (name) => { - const [existing] = await tx - .select({ id: workspaceFiles.id }) - .from(workspaceFiles) - .where( - and( - projectFiles(projectId), - eq(workspaceFiles.originalName, name), - workspaceFileNameFolderCondition(null), - isNull(workspaceFiles.deletedAt) - ) - ) - .limit(1) - return Boolean(existing) - }, - { hasExtension: true } - ) - await tx - .update(workspaceFiles) - .set({ folderId: null, originalName, updatedAt: new Date() }) - .where(and(projectFiles(projectId), eq(workspaceFiles.id, child.id))) - } - } - await tx.delete(folder).where(and(folderScope, eq(folder.id, expired.id))) - return 1 + return cleanupExpiredFileFolderInTx( + tx, + { entityType: 'project', entityId: projectId }, + policy.cutoff, + budget + ) }) total += removed if (!removed) break diff --git a/apps/sim/lib/projects/rollout.server.ts b/apps/sim/lib/projects/rollout.server.ts index 758f5c0b963..e729904da82 100644 --- a/apps/sim/lib/projects/rollout.server.ts +++ b/apps/sim/lib/projects/rollout.server.ts @@ -1,3 +1,4 @@ +import { envBoolean, getEnv } from '@/lib/core/config/env' import { isFeatureEnabled } from '@/lib/core/config/feature-flags' import { HttpError } from '@/lib/core/utils/http-error' @@ -11,3 +12,14 @@ class ProjectUnavailableError extends HttpError { export async function requireProjectApiEnabled(): Promise { if (!(await isFeatureEnabled('projects'))) throw new ProjectUnavailableError() } + +/** Deployment-wide cutover follows ownership backfill and compatible storage/worker rollout. */ +async function isProjectFileApiEnabled(): Promise { + return ( + (await isFeatureEnabled('projects')) && (envBoolean(getEnv('PROJECT_FILES_ENABLED')) ?? false) + ) +} + +export async function requireProjectFileApiEnabled(): Promise { + if (!(await isProjectFileApiEnabled())) throw new ProjectUnavailableError() +} diff --git a/apps/sim/lib/public-shares/api/credentials.ts b/apps/sim/lib/public-shares/api/credentials.ts new file mode 100644 index 00000000000..1b00ccdaaef --- /dev/null +++ b/apps/sim/lib/public-shares/api/credentials.ts @@ -0,0 +1,22 @@ +import type { SessionPrincipal } from '@sim/auth/principal' +import { getSession } from '@/lib/auth' + +/** Authenticate the optional browser session without substituting the file creator for its caller. */ +export async function publicFileShareCredential( + cookies: readonly { name: string; value: string }[], + clientIp?: string | null +) { + const session = await getSession() + let sessionPrincipal: SessionPrincipal | undefined + if (session?.user?.id) { + const sessionId = session.session?.id + if (!sessionId) throw new Error('Authenticated session is missing its session ID') + sessionPrincipal = { kind: 'session', userId: session.user.id, sessionId } + } + return { + method: 'GET' as const, + cookies: Object.fromEntries(cookies.map(({ name, value }) => [name, value])), + sessionPrincipal, + clientIp, + } +} diff --git a/apps/sim/lib/public-shares/api/index.ts b/apps/sim/lib/public-shares/api/index.ts new file mode 100644 index 00000000000..d1f19dddef0 --- /dev/null +++ b/apps/sim/lib/public-shares/api/index.ts @@ -0,0 +1,7 @@ +export { publicFileShareCredential } from './credentials' +export { + publicFileAuthDenied, + publicFileBinaryErrorResponse, + publicFileErrorResponse, +} from './responses' +export { readPublicFileSocialMetadata } from './social-metadata' diff --git a/apps/sim/lib/public-shares/api/responses.ts b/apps/sim/lib/public-shares/api/responses.ts new file mode 100644 index 00000000000..e47a7cd2e4f --- /dev/null +++ b/apps/sim/lib/public-shares/api/responses.ts @@ -0,0 +1,81 @@ +import { NextResponse } from 'next/server' +import { + asOrchestrationError, + messageForOrchestrationError, + statusForOrchestrationError, +} from '@/lib/core/orchestration/types' +import { FILE_CACHE_CONTROL } from '@/lib/uploads/server/delivery' + +/** Keep the public cookie endpoints' existing error envelope and retry header. */ +export function publicFileAuthDenied(result: { + error?: string + status?: number + retryAfterMs?: number +}) { + const response = NextResponse.json( + { error: result.error ?? 'auth_required_password' }, + { + status: result.status ?? 401, + headers: { 'Cache-Control': FILE_CACHE_CONTROL.noStore, 'X-Content-Type-Options': 'nosniff' }, + } + ) + if (result.status === 429 && result.retryAfterMs !== undefined) + response.headers.set('Retry-After', String(Math.ceil(result.retryAfterMs / 1000))) + return response +} + +function classifyPublicFileError(error: unknown, fallback: string) { + const classified = asOrchestrationError(error) + return { + code: classified?.code, + status: statusForOrchestrationError(classified?.code), + message: + classified?.code === 'not_found' + ? 'Not found' + : messageForOrchestrationError( + { errorCode: classified?.code, error: classified?.message }, + fallback + ), + } +} + +/** Conceal unavailable targets and driver failures while preserving classified domain failures. */ +export function publicFileErrorResponse(error: unknown, fallback: string) { + const result = classifyPublicFileError(error, fallback) + return NextResponse.json( + { error: result.message }, + { + status: result.status, + headers: { 'Cache-Control': FILE_CACHE_CONTROL.noStore, 'X-Content-Type-Options': 'nosniff' }, + } + ) +} + +/** Binary URLs retain the legacy file-error envelope; pending artifacts use their existing challenge. */ +export function publicFileBinaryErrorResponse(error: unknown, fallback: string) { + const result = classifyPublicFileError(error, fallback) + if (result.code === 'conflict') + return NextResponse.json( + { error: result.message }, + { + status: result.status, + headers: { + 'Cache-Control': FILE_CACHE_CONTROL.noStore, + 'X-Content-Type-Options': 'nosniff', + }, + } + ) + const name = + result.code === 'not_found' + ? 'FileNotFoundError' + : result.code === 'payload_too_large' + ? 'PayloadSizeLimitError' + : 'Error' + return NextResponse.json( + { error: name, message: result.message }, + { + status: result.status, + headers: { 'Cache-Control': FILE_CACHE_CONTROL.noStore, 'X-Content-Type-Options': 'nosniff' }, + } + ) +} diff --git a/apps/sim/lib/public-shares/api/social-metadata.ts b/apps/sim/lib/public-shares/api/social-metadata.ts new file mode 100644 index 00000000000..e78becc6f38 --- /dev/null +++ b/apps/sim/lib/public-shares/api/social-metadata.ts @@ -0,0 +1,14 @@ +import { asOrchestrationError } from '@/lib/core/orchestration/types' +import { authorizePublicFileShare, readPublicFileShare } from '@/lib/public-shares/application' + +/** Social previews always use anonymous credentials, even when the viewer has an auth cookie. */ +export async function readPublicFileSocialMetadata(token: string) { + try { + const auth = await authorizePublicFileShare({ token, credential: { method: 'GET' } }) + if (!auth.authorized) return { protected: true as const } + return { protected: false as const, metadata: await readPublicFileShare({ grant: auth.grant }) } + } catch (error) { + if (asOrchestrationError(error)?.code === 'not_found') return null + throw error + } +} diff --git a/apps/sim/lib/public-shares/application/authorization.ts b/apps/sim/lib/public-shares/application/authorization.ts new file mode 100644 index 00000000000..b502ae54dd3 --- /dev/null +++ b/apps/sim/lib/public-shares/application/authorization.ts @@ -0,0 +1,256 @@ +import type { Principal } from '@sim/auth/principal' +import { db } from '@sim/db' +import { publicShare, user, workspaceFiles } from '@sim/db/schema' +import { generateShortId } from '@sim/utils/id' +import { and, eq, isNull } from 'drizzle-orm' +import { isAccountBlocked } from '@/lib/auth/ban' +import { asOrchestrationError, OrchestrationError } from '@/lib/core/orchestration/types' +import { deploymentAuthCookieName } from '@/lib/core/security/deployment' +import { + type DeploymentAuthResult, + validateDeploymentCredentials, +} from '@/lib/core/security/deployment-credentials' +import type { DbTransaction } from '@/lib/db/types' +import { + type PublicFileReadOperation, + publicFileOperations, +} from '@/lib/public-shares/application/operations' +import { loadPublicFileOwner } from '@/lib/public-shares/application/owner-adapters' +import { resolveFileOwner } from '@/lib/workspace-files/ownership' +import { fileOwnerCondition } from '@/lib/workspace-files/ownership-query' + +const grantIdentity = Symbol('verified-public-file-share') +export interface VerifiedPublicFileShareGrant { + readonly [grantIdentity]: true +} +interface PublicFileCredential { + method: 'GET' | 'POST' + password?: string + email?: string + cookies?: Readonly> + sessionPrincipal?: Principal + clientIp?: string | null +} +interface SessionIdentity { + userId: string + email: string +} +interface GrantState { + token: string + policy: string + session?: SessionIdentity + authenticatedEmail?: string + expiresAt: number +} +const grants = new WeakMap() + +function unavailable(): never { + throw new OrchestrationError('not_found', 'File share not found') +} + +async function loadSnapshot(tx: DbTransaction, token: string) { + const [discovered] = await tx + .select({ share: publicShare, file: workspaceFiles }) + .from(publicShare) + .innerJoin(workspaceFiles, eq(publicShare.resourceId, workspaceFiles.id)) + .where(and(eq(publicShare.token, token), eq(publicShare.resourceType, 'file'))) + .limit(1) + if (!discovered) unavailable() + const owner = resolveFileOwner(discovered.file) + if ( + !owner || + discovered.share.entityType !== owner.entityType || + discovered.share.entityId !== owner.entityId + ) + unavailable() + const context = await loadPublicFileOwner(tx, owner) + const [file] = await tx + .select() + .from(workspaceFiles) + .where( + and( + eq(workspaceFiles.id, discovered.file.id), + fileOwnerCondition(context.owner), + isNull(workspaceFiles.deletedAt) + ) + ) + .for('share') + if (!file) unavailable() + const [share] = await tx + .select() + .from(publicShare) + .where( + and( + eq(publicShare.id, discovered.share.id), + eq(publicShare.token, token), + eq(publicShare.resourceType, 'file'), + eq(publicShare.resourceId, file.id), + eq(publicShare.entityType, context.owner.entityType), + eq(publicShare.entityId, context.owner.entityId), + eq(publicShare.isActive, true) + ) + ) + .for('share') + if ( + !share || + share.workspaceId !== (context.owner.entityType === 'workspace' ? context.owner.entityId : null) + ) + unavailable() + const [creator] = file.userId + ? await tx.select({ name: user.name }).from(user).where(eq(user.id, file.userId)).limit(1) + : [] + return { ...context, file, share, creatorName: creator?.name ?? null } +} +export type PublicFileShareSnapshot = Awaited> + +function policy(snapshot: PublicFileShareSnapshot) { + const { share, owner } = snapshot + return JSON.stringify([ + share.id, + share.token, + share.resourceId, + owner, + share.authType, + share.password, + share.allowedEmails, + ]) +} +async function loadSession( + tx: DbTransaction, + userId: string +): Promise { + const [row] = await tx + .select({ + id: user.id, + email: user.email, + banned: user.banned, + banExpires: user.banExpires, + suspendedAt: user.suspendedAt, + }) + .from(user) + .where(eq(user.id, userId)) + .for('share') + return row && !isAccountBlocked(row) ? { userId: row.id, email: row.email } : undefined +} + +/** Exchanges real credentials for an in-process, short-lived bearer grant bound to current sharing policy. */ +export async function authorizePublicFileShare({ + token, + credential, +}: { + token: string + credential: PublicFileCredential +}): Promise< + | (DeploymentAuthResult & { authorized: false; authType: string }) + | { + authorized: true + authType: string + grant: VerifiedPublicFileShareGrant + authenticatedEmail?: string + } +> { + try { + if (!/^[A-Za-z0-9_-]{16,64}$/.test(token)) unavailable() + const initial = await db.transaction(async (tx) => { + const snapshot = await loadSnapshot(tx, token) + const session = + snapshot.share.authType === 'sso' && credential.sessionPrincipal?.kind === 'session' + ? await loadSession(tx, credential.sessionPrincipal.userId) + : undefined + return { snapshot, session } + }) + const result = await validateDeploymentCredentials( + generateShortId(), + initial.snapshot.share, + { + method: credential.method, + authToken: + credential.cookies?.[deploymentAuthCookieName('file', initial.snapshot.share.id)], + clientIp: credential.clientIp, + sessionPresent: Boolean(initial.session), + sessionEmail: initial.session?.email, + }, + credential.method === 'POST' + ? { password: credential.password, email: credential.email } + : undefined, + 'file' + ) + if (!result.authorized) + return { ...result, authorized: false, authType: initial.snapshot.share.authType } + const state: GrantState = { + token, + policy: policy(initial.snapshot), + session: initial.session, + authenticatedEmail: result.authenticatedEmail, + expiresAt: Date.now() + 60_000, + } + await db.transaction((tx) => validateState(tx, state)) + const grant: VerifiedPublicFileShareGrant = Object.freeze({ [grantIdentity]: true as const }) + grants.set(grant, state) + return { + authorized: true, + grant, + authType: initial.snapshot.share.authType, + authenticatedEmail: result.authenticatedEmail, + } + } catch (error) { + throw asOrchestrationError(error) ?? error + } +} + +async function validateState(tx: DbTransaction, state: GrantState) { + if (state.expiresAt <= Date.now()) unavailable() + const snapshot = await loadSnapshot(tx, state.token) + if (policy(snapshot) !== state.policy) unavailable() + if (state.session) { + const session = await loadSession(tx, state.session.userId) + if (!session || session.email !== state.session.email) unavailable() + } + return snapshot +} + +/** A credential challenge authorizes only verification of this live token's declared authentication mode. */ +export async function preparePublicFileShareChallenge( + token: string, + authType: 'password' | 'email' | 'sso' +) { + if (!/^[A-Za-z0-9_-]{16,64}$/.test(token)) unavailable() + const initial = await db.transaction((tx) => loadSnapshot(tx, token)) + if (initial.share.authType !== authType) { + const message = + authType === 'sso' + ? 'This file is not configured for SSO' + : `This file does not use ${authType} authentication` + throw new OrchestrationError('validation', message) + } + const state: GrantState = { token, policy: policy(initial), expiresAt: Date.now() + 60_000 } + return Object.freeze({ + shareId: initial.share.id, + async withCurrentPolicy( + execute: (snapshot: PublicFileShareSnapshot) => Promise | T + ): Promise { + try { + return await db.transaction(async (tx) => execute(await validateState(tx, state))) + } catch (error) { + throw asOrchestrationError(error) ?? error + } + }, + }) +} + +/** Every bearer read rechecks current token, owner, file lifetime, and verified session policy. */ +export async function withPublicFileShareGrant( + grant: VerifiedPublicFileShareGrant, + operation: PublicFileReadOperation, + execute: (tx: DbTransaction, snapshot: PublicFileShareSnapshot) => Promise +): Promise { + try { + if (!Object.values(publicFileOperations).some((registered) => registered === operation)) + throw new Error('Unregistered public file operation') + const state = grants.get(grant) + if (!state) unavailable() + return await db.transaction(async (tx) => execute(tx, await validateState(tx, state))) + } catch (error) { + throw asOrchestrationError(error) ?? error + } +} diff --git a/apps/sim/lib/public-shares/application/credentials.ts b/apps/sim/lib/public-shares/application/credentials.ts new file mode 100644 index 00000000000..f0a83a7d31d --- /dev/null +++ b/apps/sim/lib/public-shares/application/credentials.ts @@ -0,0 +1,174 @@ +import { createLogger } from '@sim/logger' +import { safeCompare } from '@sim/security/compare' +import { normalizeEmail } from '@sim/utils/string' +import { getOtpSubject, renderOTPEmail } from '@/components/emails' +import { RateLimiter, type TokenBucketConfig } from '@/lib/core/rate-limiter' +import { createDeploymentAuthCookie, isEmailAllowed } from '@/lib/core/security/deployment' +import { + decodeOTPValue, + deleteOTP, + generateOTP, + getOTP, + incrementOTPAttempts, + MAX_OTP_ATTEMPTS, + OTP_EMAIL_RATE_LIMIT, + OTP_RESOURCE_RATE_LIMIT, + storeOTP, +} from '@/lib/core/security/otp' +import { sendEmail } from '@/lib/messaging/email/mailer' +import { + authorizePublicFileShare, + preparePublicFileShareChallenge, +} from '@/lib/public-shares/application/authorization' + +const logger = createLogger('PublicFileShareCredentials') +const rateLimiter = new RateLimiter() +const SSO_RESOURCE_RATE_LIMIT: TokenBucketConfig = { + maxTokens: 100, + refillRate: 100, + refillIntervalMs: 15 * 60_000, +} +const OTP_LOCKED_MESSAGE = 'Too many failed attempts. Please request a new code.' + +/** Exchanges a verified password for a cookie bound to the share's current password slot. */ +export async function authenticatePublicFileSharePassword({ + token, + password, + clientIp, +}: { + token: string + password: string + clientIp?: string | null +}) { + const challenge = await preparePublicFileShareChallenge(token, 'password') + const result = await authorizePublicFileShare({ + token, + credential: { method: 'POST', password, clientIp }, + }) + if (!result.authorized) return result + const resource = await challenge.withCurrentPolicy(({ share }) => share) + const cookie = await createDeploymentAuthCookie({ cookiePrefix: 'file', resource }) + await challenge.withCurrentPolicy(() => undefined) + return { authorized: true as const, authType: 'password' as const, cookie } +} + +/** Schedules code delivery without revealing whether the supplied email is on the allow-list. */ +export async function requestPublicFileShareOtp({ + token, + email, +}: { + token: string + email: string +}) { + const normalizedEmail = normalizeEmail(email) + const challenge = await preparePublicFileShareChallenge(token, 'email') + return { + async deliver(): Promise { + const allowed = await challenge.withCurrentPolicy(({ share }) => + isEmailAllowed(normalizedEmail, share.allowedEmails) + ) + if (!allowed) return + const resourceLimit = await rateLimiter.checkRateLimitDirect( + `file-otp:resource:${challenge.shareId}`, + OTP_RESOURCE_RATE_LIMIT, + { failClosed: true } + ) + if (!resourceLimit.allowed) return + const emailLimit = await rateLimiter.checkRateLimitDirect( + `file-otp:email:${challenge.shareId}:${normalizedEmail}`, + OTP_EMAIL_RATE_LIMIT, + { failClosed: true } + ) + if (!emailLimit.allowed) return + const otp = generateOTP() + await storeOTP('file', challenge.shareId, normalizedEmail, otp) + const html = await renderOTPEmail(otp, 'email-verification', 'a shared file') + await challenge.withCurrentPolicy(() => undefined) + const result = await sendEmail({ + to: normalizedEmail, + subject: getOtpSubject('a shared file'), + html, + }) + if (!result.success) + logger.error('Failed to send public file verification code', { + shareId: challenge.shareId, + message: result.message, + }) + }, + } +} + +/** Consumes the existing email code and issues a cookie only while its original sharing policy remains current. */ +export async function verifyPublicFileShareOtp({ + token, + email, + otp, +}: { + token: string + email: string + otp: string +}) { + const normalizedEmail = normalizeEmail(email) + const challenge = await preparePublicFileShareChallenge(token, 'email') + const allowed = await challenge.withCurrentPolicy(({ share }) => + isEmailAllowed(normalizedEmail, share.allowedEmails) + ) + if (!allowed) return { authorized: false as const, error: 'Email not authorized', status: 403 } + const value = await getOTP('file', challenge.shareId, normalizedEmail) + await challenge.withCurrentPolicy(() => undefined) + if (!value) + return { + authorized: false as const, + error: 'No verification code found, request a new one', + status: 400, + } + const stored = decodeOTPValue(value) + if (stored.attempts >= MAX_OTP_ATTEMPTS) { + await deleteOTP('file', challenge.shareId, normalizedEmail) + return { authorized: false as const, error: OTP_LOCKED_MESSAGE, status: 429 } + } + if (!safeCompare(stored.otp, otp)) { + const result = await incrementOTPAttempts('file', challenge.shareId, normalizedEmail, value) + return { + authorized: false as const, + error: result === 'locked' ? OTP_LOCKED_MESSAGE : 'Invalid verification code', + status: result === 'locked' ? 429 : 400, + } + } + await deleteOTP('file', challenge.shareId, normalizedEmail) + const resource = await challenge.withCurrentPolicy(({ share }) => share) + const cookie = await createDeploymentAuthCookie({ + cookiePrefix: 'file', + resource, + verifiedEmail: normalizedEmail, + }) + await challenge.withCurrentPolicy(() => undefined) + return { authorized: true as const, authType: 'email' as const, cookie } +} + +/** Checks allow-list eligibility for the SSO redirect without granting access or asserting session identity. */ +export async function getPublicFileShareSsoEligibility({ + token, + email, +}: { + token: string + email: string +}) { + const challenge = await preparePublicFileShareChallenge(token, 'sso') + const limit = await rateLimiter.checkRateLimitDirect( + `file-sso:resource:${challenge.shareId}`, + SSO_RESOURCE_RATE_LIMIT, + { failClosed: true } + ) + if (!limit.allowed) + return { + allowed: false as const, + error: 'Too many requests. Please try again later.', + status: 429, + retryAfterMs: limit.retryAfterMs ?? SSO_RESOURCE_RATE_LIMIT.refillIntervalMs, + } + const eligible = await challenge.withCurrentPolicy(({ share }) => + isEmailAllowed(normalizeEmail(email), share.allowedEmails) + ) + return { allowed: true as const, eligible } +} diff --git a/apps/sim/lib/public-shares/application/index.ts b/apps/sim/lib/public-shares/application/index.ts new file mode 100644 index 00000000000..b722ee4c0d5 --- /dev/null +++ b/apps/sim/lib/public-shares/application/index.ts @@ -0,0 +1,13 @@ +export { authorizePublicFileShare } from './authorization' +export { + authenticatePublicFileSharePassword, + getPublicFileShareSsoEligibility, + requestPublicFileShareOtp, + verifyPublicFileShareOtp, +} from './credentials' +export { + checkPublicFileShareContent, + readPublicFileShare, + readPublicFileShareContent, + readPublicFileShareInline, +} from './read' diff --git a/apps/sim/lib/public-shares/application/operations.ts b/apps/sim/lib/public-shares/application/operations.ts new file mode 100644 index 00000000000..740ab3d2938 --- /dev/null +++ b/apps/sim/lib/public-shares/application/operations.ts @@ -0,0 +1,35 @@ +import { + type ApplicationOperation, + assertOperationCapability, +} from '@/lib/core/application/operation' + +interface PublicFileOperation extends ApplicationOperation { + readonly authority: 'verified_public_file_share' +} +function definePublicFileOperation(operation: O): O { + assertOperationCapability(operation) + return Object.freeze(operation) +} + +export const publicFileOperations = { + // permission-group-exempt: verified share credentials authorize bearer reads; member publishing enforces sharing policy. + readMetadata: definePublicFileOperation({ + id: 'public_files.read_metadata', + authority: 'verified_public_file_share', + capability: 'none', + }), + // permission-group-exempt: verified share credentials authorize bearer reads; member publishing enforces sharing policy. + readContent: definePublicFileOperation({ + id: 'public_files.read_content', + authority: 'verified_public_file_share', + capability: 'none', + }), + // permission-group-exempt: the verified share grant covers only current same-owner images referenced by its file. + readInline: definePublicFileOperation({ + id: 'public_files.read_inline', + authority: 'verified_public_file_share', + capability: 'none', + }), +} as const +export type PublicFileReadOperation = + (typeof publicFileOperations)[keyof typeof publicFileOperations] diff --git a/apps/sim/lib/public-shares/application/owner-adapters.ts b/apps/sim/lib/public-shares/application/owner-adapters.ts new file mode 100644 index 00000000000..b8fc297ba58 --- /dev/null +++ b/apps/sim/lib/public-shares/application/owner-adapters.ts @@ -0,0 +1,103 @@ +import { project, type WorkspaceFileRow, workspace } from '@sim/db/schema' +import { eq } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbTransaction } from '@/lib/db/types' +import { lockProject, lockWorkspaceProject } from '@/lib/projects/membership' +import { requireProjectFileApiEnabled } from '@/lib/projects/rollout.server' +import { type E2BDocFormat, resolveServableDoc } from '@/lib/uploads/documents/compile' +import { loadCompiledDoc } from '@/lib/uploads/documents/compiled-store' +import { fileDocumentInputIdentity } from '@/lib/uploads/documents/input-identity' +import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' +import { + type FileOwnerAdapters, + requireFileOwnerAdapter, +} from '@/lib/workspace-files/owner-adapters' +import type { EditableFileOwner, FileOwner } from '@/lib/workspace-files/ownership' + +interface PublicFileOwnerContext { + owner: EditableFileOwner + organizationId: string | null + displayName: string + workspaceId: string | null +} +interface DocumentRead { + owner: EditableFileOwner + source: Buffer + sourceMime: string + fileName: string + format: E2BDocFormat + dependencies: readonly WorkspaceFileRow[] +} +interface PublicFileOwnerAdapter { + load(tx: DbTransaction, entityId: string): Promise + storageContext: 'workspace' | 'project' + pageOptions(entityId: string): { workspaceId?: string; projectId?: string } + readCompiled(input: DocumentRead): Promise<{ buffer: Buffer; contentType: string }> +} + +const adapters: FileOwnerAdapters = { + workspace: { + async load(tx, entityId) { + await lockWorkspaceProject(tx, entityId) + const [row] = await tx.select().from(workspace).where(eq(workspace.id, entityId)).for('share') + if (!row || row.archivedAt) throw new OrchestrationError('not_found', 'File share not found') + return { + owner: { entityType: 'workspace', entityId }, + organizationId: row.organizationId, + displayName: row.name, + workspaceId: entityId, + } + }, + storageContext: 'workspace', + pageOptions: (workspaceId) => ({ workspaceId }), + async readCompiled({ owner, source, sourceMime, fileName }) { + const artifact = await resolveServableDoc(owner.entityId, source, fileName, { + maxBytes: MAX_BUFFERED_TRANSFER_BYTES, + sourceMime, + }) + if (artifact.kind !== 'artifact') + throw new OrchestrationError( + 'conflict', + 'This document is still being prepared. Please try again shortly.' + ) + return artifact + }, + }, + project: { + async load(tx, entityId) { + await requireProjectFileApiEnabled() + await lockProject(tx, entityId) + const [row] = await tx.select().from(project).where(eq(project.id, entityId)).for('share') + if (!row || row.archivedAt) throw new OrchestrationError('not_found', 'File share not found') + return { + owner: { entityType: 'project', entityId }, + organizationId: row.organizationId, + displayName: row.name, + workspaceId: null, + } + }, + storageContext: 'project', + pageOptions: (projectId) => ({ projectId }), + async readCompiled({ owner, source, format, dependencies }) { + const buffer = await loadCompiledDoc( + owner, + source.toString('utf8'), + format.ext, + fileDocumentInputIdentity(owner, dependencies), + { maxBytes: MAX_BUFFERED_TRANSFER_BYTES } + ) + if (!buffer) + throw new OrchestrationError( + 'conflict', + 'This document is still being prepared. Please try again shortly.' + ) + return { buffer, contentType: format.contentType } + }, + }, +} + +/** Bearer policy requires a live canonical owner; it never substitutes a member or creator identity. */ +export async function loadPublicFileOwner(tx: DbTransaction, owner: FileOwner) { + const adapter = requireFileOwnerAdapter(adapters, owner) + return { ...(await adapter.load(tx, owner.entityId)), adapter } +} diff --git a/apps/sim/lib/public-shares/application/read.ts b/apps/sim/lib/public-shares/application/read.ts new file mode 100644 index 00000000000..a5df6fa571b --- /dev/null +++ b/apps/sim/lib/public-shares/application/read.ts @@ -0,0 +1,415 @@ +import { AuditAction, AuditResourceType, recordAudit } from '@sim/audit' +import { type WorkspaceFileRow, workspaceFiles } from '@sim/db/schema' +import { and, asc, eq, inArray, isNull } from 'drizzle-orm' +import { isDocSandboxEnabled } from '@/lib/core/config/env-flags' +import { + asOrchestrationError, + OrchestrationError, + type OrchestrationRequestContext, +} from '@/lib/core/orchestration/types' +import { assertKnownSizeWithinLimit, isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' +import type { DbTransaction } from '@/lib/db/types' +import { + type PublicFileShareSnapshot, + type VerifiedPublicFileShareGrant, + withPublicFileShareGrant, +} from '@/lib/public-shares/application/authorization' +import { + type PublicFileReadOperation, + publicFileOperations, +} from '@/lib/public-shares/application/operations' +import { + getBoundWorkspaceFileSecretProvenanceByMetadata, + mergeWorkspaceFileSecretProvenance, +} from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' +import { downloadFile, headObject } from '@/lib/uploads/core/storage-service' +import { + collectReferencedFileIds, + getDocumentSourceLanguage, + getE2BDocFormat, + isCompiledDocumentBuffer, +} from '@/lib/uploads/documents/compile' +import { hasEmbeddedFileRef } from '@/lib/uploads/server/embedded-image-refs' +import { resolveServableImageBytes } from '@/lib/uploads/server/image-derivative' +import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' +import { resolveEffectiveMimeType } from '@/lib/uploads/utils/file-utils' +import { sniffImageContentType } from '@/lib/uploads/utils/validation' +import { reportWorkspaceFileDelivery } from '@/lib/workspace-files/application/file-delivery-observer' +import { fileOwnerCondition } from '@/lib/workspace-files/ownership-query' +import { isSimPageSource, SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile' +import { renderSimPageDocument } from '@/lib/workspace-files/page-document' +import { + collectSimPageFileReferences, + inlineSimPageImages, +} from '@/lib/workspace-files/page-document.server' + +interface PublicReadInput { + grant: VerifiedPublicFileShareGrant + request?: OrchestrationRequestContext +} +interface PublicManifest { + snapshot: PublicFileShareSnapshot + dependencies: WorkspaceFileRow[] + pageHtml?: string +} + +function present(snapshot: PublicFileShareSnapshot) { + const { file } = snapshot + return { + owner: snapshot.owner, + ownerName: snapshot.creatorName, + workspaceName: snapshot.displayName, + file: { + id: file.id, + originalName: file.originalName, + contentType: file.contentType, + sizeBytes: file.sizeBytes, + uploadedBy: file.userId, + updatedAt: file.updatedAt, + }, + } +} +function requireRevision(current: WorkspaceFileRow, expected: WorkspaceFileRow) { + if ( + current.id !== expected.id || + current.key !== expected.key || + current.contentUpdatedAt.getTime() !== expected.contentUpdatedAt.getTime() + ) + throw new OrchestrationError('conflict', 'Shared file changed during the read') +} +async function dependencies( + tx: DbTransaction, + snapshot: PublicFileShareSnapshot, + ids: readonly string[] +) { + if (ids.length > 500) + throw new OrchestrationError('payload_too_large', 'Too many document inputs') + if (ids.length === 0) return [] + const rows = await tx + .select() + .from(workspaceFiles) + .where( + and( + fileOwnerCondition(snapshot.owner), + inArray(workspaceFiles.id, [...ids]), + isNull(workspaceFiles.deletedAt) + ) + ) + .orderBy(asc(workspaceFiles.id)) + .for('share') + if (rows.length !== ids.length) + throw new OrchestrationError('not_found', 'Document input not found') + return rows +} +async function finish( + grant: VerifiedPublicFileShareGrant, + manifest: PublicManifest, + operation: PublicFileReadOperation, + buffer: Buffer, + contentType: string, + request?: OrchestrationRequestContext, + servedFile: WorkspaceFileRow = manifest.snapshot.file +) { + assertKnownSizeWithinLimit(buffer.length, MAX_BUFFERED_TRANSFER_BYTES, 'shared file') + const result = await withPublicFileShareGrant(grant, operation, async (tx, snapshot) => { + requireRevision(snapshot.file, manifest.snapshot.file) + const current = await dependencies( + tx, + snapshot, + manifest.dependencies.map((file) => file.id) + ) + const expected = new Map(manifest.dependencies.map((file) => [file.id, file])) + for (const file of current) { + const prior = expected.get(file.id) + if (!prior) throw new OrchestrationError('not_found', 'Document input not found') + requireRevision(file, prior) + } + const evidence = await getBoundWorkspaceFileSecretProvenanceByMetadata(tx, [ + snapshot.file, + ...current, + ]) + return { + ...present(snapshot), + buffer, + contentType, + servedFileName: servedFile.originalName, + secretProvenance: mergeWorkspaceFileSecretProvenance(...evidence.values()), + organizationId: snapshot.organizationId, + } + }) + await reportWorkspaceFileDelivery(result.secretProvenance) + recordAudit({ + actorId: null, + workspaceId: manifest.snapshot.workspaceId, + action: AuditAction.FILE_DOWNLOADED, + resourceType: AuditResourceType.FILE, + resourceId: servedFile.id, + resourceName: servedFile.originalName, + description: `Public share download of "${servedFile.originalName}"`, + request, + metadata: { + organizationId: result.organizationId, + operation: operation.id, + sharedDocumentId: result.file.id, + sharedByUserId: manifest.snapshot.file.userId, + access: 'public_share', + anonymous: true, + owner: result.owner, + bytes: buffer.length, + }, + }) + return result +} + +/** Metadata is disclosed only after the current token policy has produced a verified bearer grant. */ +export function readPublicFileShare({ grant }: PublicReadInput) { + return withPublicFileShareGrant(grant, publicFileOperations.readMetadata, async (_tx, snapshot) => + present(snapshot) + ) +} + +/** Checks cached representation availability without rendering or recording a download. */ +export async function checkPublicFileShareContent( + grant: VerifiedPublicFileShareGrant +): Promise { + const operation = publicFileOperations.readContent + try { + const initial = await withPublicFileShareGrant( + grant, + operation, + async (_tx, snapshot) => snapshot + ) + const stored = await headObject(initial.file.key, initial.adapter.storageContext) + if (!stored) throw new OrchestrationError('not_found', 'File not found') + assertKnownSizeWithinLimit(stored.size, MAX_BUFFERED_TRANSFER_BYTES, 'File') + const format = await getE2BDocFormat(initial.file.originalName) + let inputs: WorkspaceFileRow[] = [] + if (format) { + const source = await downloadFile({ + key: initial.file.key, + context: initial.adapter.storageContext, + maxBytes: MAX_BUFFERED_TRANSFER_BYTES, + }) + if (!isCompiledDocumentBuffer(initial.file.originalName, source)) { + const text = source.toString('utf8') + const ids = [ + ...new Set( + collectReferencedFileIds( + text, + isDocSandboxEnabled + ? getDocumentSourceLanguage(text, format, initial.file.contentType) + : 'javascript' + ) + ), + ] + inputs = await withPublicFileShareGrant(grant, operation, async (tx, snapshot) => { + requireRevision(snapshot.file, initial.file) + return dependencies(tx, snapshot, ids) + }) + const artifact = await initial.adapter.readCompiled({ + owner: initial.owner, + source, + sourceMime: initial.file.contentType, + fileName: initial.file.originalName, + format, + dependencies: inputs, + }) + assertKnownSizeWithinLimit( + artifact.buffer.length, + MAX_BUFFERED_TRANSFER_BYTES, + 'shared file' + ) + } + } + await withPublicFileShareGrant(grant, operation, async (tx, snapshot) => { + requireRevision(snapshot.file, initial.file) + const current = await dependencies( + tx, + snapshot, + inputs.map((file) => file.id) + ) + for (let index = 0; index < current.length; index++) { + requireRevision(current[index], inputs[index]) + } + }) + } catch (error) { + throw asOrchestrationError(error) ?? error + } +} + +/** A public read never executes generated document code; it serves current cached artifacts only. */ +export async function readPublicFileShareContent({ + grant, + preview, + request, +}: PublicReadInput & { preview?: boolean }) { + const operation = publicFileOperations.readContent + try { + const initial = await withPublicFileShareGrant( + grant, + operation, + async (_tx, snapshot) => snapshot + ) + const raw = await downloadFile({ + key: initial.file.key, + context: initial.adapter.storageContext, + maxBytes: MAX_BUFFERED_TRANSFER_BYTES, + }) + const format = await getE2BDocFormat(initial.file.originalName) + const generated = Boolean(format && !isCompiledDocumentBuffer(initial.file.originalName, raw)) + const source = raw.toString('utf8') + const manifest = await withPublicFileShareGrant( + grant, + operation, + async (tx, snapshot): Promise => { + requireRevision(snapshot.file, initial.file) + const pageHtml = + (snapshot.file.contentType === SIM_PAGE_CONTENT_TYPE || + snapshot.file.originalName.toLowerCase().endsWith('.html')) && + isSimPageSource(source) + ? renderSimPageDocument(source, snapshot.adapter.pageOptions(snapshot.owner.entityId)) + : undefined + const references = pageHtml ? collectSimPageFileReferences(pageHtml) : [] + if ( + references.some( + (ref) => + ref.projectId && + (snapshot.owner.entityType !== 'project' || ref.projectId !== snapshot.owner.entityId) + ) + ) + throw new OrchestrationError('not_found', 'Document input not found') + const ids = [ + ...new Set( + pageHtml + ? references.map((ref) => ref.fileId) + : generated && format + ? collectReferencedFileIds( + source, + isDocSandboxEnabled + ? getDocumentSourceLanguage(source, format, initial.file.contentType) + : 'javascript' + ) + : [] + ), + ] + return { snapshot, pageHtml, dependencies: await dependencies(tx, snapshot, ids) } + } + ) + let buffer = raw + let contentType = resolveEffectiveMimeType(undefined, initial.file.originalName) + if (manifest.pageHtml) { + const byId = new Map(manifest.dependencies.map((file) => [file.id, file])) + const page = await inlineSimPageImages( + manifest.pageHtml, + async ({ fileId }, maxBytes) => { + const file = byId.get(fileId) + if (!file) throw new OrchestrationError('not_found', 'Document input not found') + const bytes = await downloadFile({ + key: file.key, + context: initial.adapter.storageContext, + maxBytes, + }) + const mime = sniffImageContentType(bytes) + if (!mime) throw new OrchestrationError('not_found', 'Embedded image not found') + return { bytes, contentType: mime, identity: file.id } + }, + { strict: true } + ) + buffer = Buffer.from(page.html) + contentType = 'text/html' + } else if (generated && format) { + ;({ buffer, contentType } = await initial.adapter.readCompiled({ + owner: initial.owner, + source: raw, + sourceMime: initial.file.contentType, + fileName: initial.file.originalName, + format, + dependencies: manifest.dependencies, + })) + } else if (preview) { + const image = await resolveServableImageBytes(raw, initial.file.key, { owner: initial.owner }) + if (image) ({ buffer, contentType } = image) + } + return await finish(grant, manifest, operation, buffer, contentType, request) + } catch (error) { + throw asOrchestrationError(error) ?? error + } +} + +/** A share grants only current image embeds in its own document and canonical owner. */ +export async function readPublicFileShareInline({ + grant, + fileId, + key, + request, +}: PublicReadInput & { fileId?: string; key?: string }) { + const operation = publicFileOperations.readInline + try { + if (Boolean(fileId) === Boolean(key)) + throw new OrchestrationError('validation', 'Select one image reference') + const initial = await withPublicFileShareGrant( + grant, + operation, + async (_tx, snapshot) => snapshot + ) + const raw = await downloadFile({ + key: initial.file.key, + context: initial.adapter.storageContext, + maxBytes: 10 * 1024 * 1024, + }).catch((error: unknown) => { + if (isPayloadSizeLimitError(error)) + throw new OrchestrationError('not_found', 'Embedded image not found') + throw error + }) + const source = raw.toString('utf8') + const manifest = await withPublicFileShareGrant( + grant, + operation, + async (tx, snapshot): Promise => { + requireRevision(snapshot.file, initial.file) + const pageHtml = isSimPageSource(source) + ? renderSimPageDocument(source, snapshot.adapter.pageOptions(snapshot.owner.entityId)) + : undefined + const reference = fileId ? { fileId } : key ? { key } : null + const references = pageHtml ? collectSimPageFileReferences(pageHtml) : [] + const embedded = + reference && + (pageHtml + ? references.some( + (ref) => + ref.fileId === fileId && + (!ref.projectId || + (snapshot.owner.entityType === 'project' && + ref.projectId === snapshot.owner.entityId)) + ) + : hasEmbeddedFileRef(source, reference, snapshot.owner)) + if (!embedded) throw new OrchestrationError('not_found', 'Embedded image not found') + const [file] = await tx + .select() + .from(workspaceFiles) + .where( + and( + fileOwnerCondition(snapshot.owner), + isNull(workspaceFiles.deletedAt), + fileId ? eq(workspaceFiles.id, fileId) : eq(workspaceFiles.key, key ?? '') + ) + ) + .for('share') + if (!file) throw new OrchestrationError('not_found', 'Embedded image not found') + return { snapshot, dependencies: [file] } + } + ) + const target = manifest.dependencies[0] + if (!target) throw new OrchestrationError('not_found', 'Embedded image not found') + const buffer = await downloadFile({ + key: target.key, + context: initial.adapter.storageContext, + maxBytes: MAX_BUFFERED_TRANSFER_BYTES, + }) + const contentType = sniffImageContentType(buffer) + if (!contentType) throw new OrchestrationError('not_found', 'Embedded image not found') + return await finish(grant, manifest, operation, buffer, contentType, request, target) + } catch (error) { + throw asOrchestrationError(error) ?? error + } +} diff --git a/apps/sim/lib/public-shares/share-manager.ts b/apps/sim/lib/public-shares/share-manager.ts index 4cf1379bcdb..8fbf4b52b15 100644 --- a/apps/sim/lib/public-shares/share-manager.ts +++ b/apps/sim/lib/public-shares/share-manager.ts @@ -1,5 +1,5 @@ import { db } from '@sim/db' -import { publicShare, user, type WorkspaceFileRow, workspace, workspaceFiles } from '@sim/db/schema' +import { publicShare, workspaceFiles } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { generateId, generateShortId } from '@sim/utils/id' import { and, eq, inArray, isNull } from 'drizzle-orm' @@ -9,10 +9,12 @@ import type { ShareRecord, shareResourceTypeSchema, } from '@/lib/api/contracts/public-shares' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { encryptSecret } from '@/lib/core/security/encryption' import { getBaseUrl } from '@/lib/core/utils/urls' +import type { DbTransaction } from '@/lib/db/types' import { lockWorkspaceProject } from '@/lib/projects/membership' -import { resolveFileOwner } from '@/lib/workspace-files/ownership' +import type { EditableFileOwner } from '@/lib/workspace-files/ownership' import { fileOwnerCondition } from '@/lib/workspace-files/ownership-query' const logger = createLogger('PublicShareManager') @@ -141,8 +143,7 @@ export async function getWorkspaceShares( return result } -interface UpsertFileShareInput { - workspaceId: string +export interface FileShareUpdate { fileId: string userId: string isActive: boolean @@ -167,162 +168,146 @@ interface UpsertFileShareInput { * Disabling (going Private) always succeeds and preserves the stored config so a * later re-enable restores it. Validation failures throw {@link ShareValidationError}. */ -export async function upsertFileShare({ - workspaceId, - fileId, - userId, - isActive, - authType, - password, - allowedEmails, - token, -}: UpsertFileShareInput): Promise { +export async function upsertFileShare( + input: FileShareUpdate & { workspaceId: string } +): Promise { return db.transaction(async (tx) => { - await lockWorkspaceProject(tx, workspaceId) - const [file] = await tx - .select({ id: workspaceFiles.id }) - .from(workspaceFiles) - .where( - and( - eq(workspaceFiles.id, fileId), - fileOwnerCondition({ entityType: 'workspace', entityId: workspaceId }), - isNull(workspaceFiles.deletedAt) - ) - ) - .for('update') - if (!file) throw new ShareValidationError('File not found') - const [existing] = await tx - .select() - .from(publicShare) - .where(and(eq(publicShare.resourceType, 'file'), eq(publicShare.resourceId, fileId))) - .limit(1) - - const finalAuthType: ShareAuthType = - authType ?? (existing?.authType as ShareAuthType | undefined) ?? 'public' - const existingAllowedEmails = Array.isArray(existing?.allowedEmails) - ? (existing.allowedEmails as string[]) - : [] - - // Disabling preserves the stored config (and skips validation) so turning - // sharing off always succeeds; only enabling validates the chosen auth mode. - let finalPassword: string | null = existing?.password ?? null - let finalAllowedEmails: string[] = existingAllowedEmails - if (isActive) { - if (finalAuthType === 'password') { - if (password) { - finalPassword = (await encryptSecret(password)).encrypted - } else if (existing?.password) { - finalPassword = existing.password - } else { - throw new ShareValidationError('Password is required for password-protected shares') - } - finalAllowedEmails = [] - } else if (finalAuthType === 'email' || finalAuthType === 'sso') { - finalAllowedEmails = allowedEmails ?? existingAllowedEmails - if (finalAllowedEmails.length === 0) { - throw new ShareValidationError( - 'At least one allowed email is required for email/SSO shares' - ) - } - finalPassword = null - } else { - finalPassword = null - finalAllowedEmails = [] - } - } - - const [row] = await tx - .insert(publicShare) - .values({ - id: generateId(), - resourceType: 'file', - resourceId: fileId, - workspaceId, - entityType: 'workspace', - entityId: workspaceId, - createdBy: userId, - token: token ?? generateShortId(), - isActive, - authType: finalAuthType, - password: finalPassword, - allowedEmails: finalAllowedEmails, - }) - .onConflictDoUpdate({ - target: [publicShare.resourceType, publicShare.resourceId], - set: { - isActive, - authType: finalAuthType, - password: finalPassword, - allowedEmails: finalAllowedEmails, - updatedAt: new Date(), - }, - }) - .returning() - - logger.info('Upserted file share', { - fileId, - workspaceId, - isActive, - authType: finalAuthType, - }) - return mapShareRecord(row) + await lockWorkspaceProject(tx, input.workspaceId) + return upsertOwnedFileShare(tx, { entityType: 'workspace', entityId: input.workspaceId }, input) }) } -/** - * Resolve a public token to its active share and the underlying (non-deleted) - * file. Returns null if the token is unknown, the share is inactive, or the file - * is gone. The caller treats null as a 404 — the existence of a file is never - * leaked through this path. - */ -export interface ResolvedShare { - share: PublicShareRow & { workspaceId: string } - file: WorkspaceFileRow & { workspaceId: string; userId: string } - /** Owning workspace name, for provenance on the public page. */ - workspaceName: string | null - /** Display name of the file's uploader. */ - ownerName: string | null -} - -export async function resolveActiveShareByToken(token: string): Promise { - const [row] = await db - .select({ - share: publicShare, - file: workspaceFiles, - workspaceName: workspace.name, - ownerName: user.name, - }) +/** Reads a share only within its canonical file owner; authorization belongs to the caller. */ +export async function getOwnedFileShare( + tx: DbTransaction, + owner: EditableFileOwner, + fileId: string +): Promise { + const [row] = await tx + .select() .from(publicShare) - .innerJoin(workspaceFiles, eq(workspaceFiles.id, publicShare.resourceId)) - .leftJoin(workspace, eq(workspace.id, workspaceFiles.workspaceId)) - .leftJoin(user, eq(user.id, workspaceFiles.userId)) .where( and( - eq(publicShare.token, token), - eq(publicShare.isActive, true), eq(publicShare.resourceType, 'file'), + eq(publicShare.resourceId, fileId), + eq(publicShare.entityType, owner.entityType), + eq(publicShare.entityId, owner.entityId) + ) + ) + .limit(1) + return row ? mapShareRecord(row) : null +} + +/** Reuses share configuration and token lifetime for every registered editable owner. */ +export async function upsertOwnedFileShare( + tx: DbTransaction, + owner: EditableFileOwner, + { fileId, userId, isActive, authType, password, allowedEmails, token }: FileShareUpdate +): Promise { + const [file] = await tx + .select({ id: workspaceFiles.id }) + .from(workspaceFiles) + .where( + and( + fileOwnerCondition(owner), + eq(workspaceFiles.id, fileId), isNull(workspaceFiles.deletedAt) ) ) + .for('update') + .limit(1) + if (!file) throw new OrchestrationError('not_found', 'File not found') + const [existing] = await tx + .select() + .from(publicShare) + .where(and(eq(publicShare.resourceType, 'file'), eq(publicShare.resourceId, fileId))) .limit(1) - if (!row || !row.file.workspaceId || !row.file.userId) return null - const owner = resolveFileOwner(row.file) if ( - owner?.entityType !== 'workspace' || - owner.entityId !== row.file.workspaceId || - row.share.workspaceId !== owner.entityId || - !( - (row.share.entityType === null && row.share.entityId === null) || - (row.share.entityType === 'workspace' && row.share.entityId === owner.entityId) - ) + existing && + (existing.entityType !== owner.entityType || existing.entityId !== owner.entityId) ) - return null + throw new OrchestrationError('not_found', 'File share not found') - return { - share: { ...row.share, workspaceId: owner.entityId }, - file: { ...row.file, workspaceId: owner.entityId, userId: row.file.userId }, - workspaceName: row.workspaceName, - ownerName: row.ownerName, + const finalAuthType: ShareAuthType = + authType ?? (existing?.authType as ShareAuthType | undefined) ?? 'public' + const existingAllowedEmails = Array.isArray(existing?.allowedEmails) + ? (existing.allowedEmails as string[]) + : [] + + // Disabling preserves the stored config (and skips validation) so turning + // sharing off always succeeds; only enabling validates the chosen auth mode. + let finalPassword: string | null = existing?.password ?? null + let finalAllowedEmails: string[] = existingAllowedEmails + if (isActive) { + if (!['public', 'password', 'email', 'sso'].includes(finalAuthType)) + throw new ShareValidationError('Invalid share authentication mode') + if (password !== undefined && (password.length < 15 || password.length > 1024)) + throw new ShareValidationError('Password must be between 15 and 1024 characters') + if ( + allowedEmails && + (allowedEmails.length > 200 || + allowedEmails.some((email) => email.length < 1 || email.length > 320)) + ) + throw new ShareValidationError('Invalid allowed email list') + if (!existing && token !== undefined && !/^[A-Za-z0-9_-]{16,64}$/.test(token)) + throw new ShareValidationError('Invalid share token') + if (finalAuthType === 'password') { + if (password) { + finalPassword = (await encryptSecret(password)).encrypted + } else if (existing?.password) { + finalPassword = existing.password + } else { + throw new ShareValidationError('Password is required for password-protected shares') + } + finalAllowedEmails = [] + } else if (finalAuthType === 'email' || finalAuthType === 'sso') { + finalAllowedEmails = allowedEmails ?? existingAllowedEmails + if (finalAllowedEmails.length === 0) { + throw new ShareValidationError( + 'At least one allowed email is required for email/SSO shares' + ) + } + finalPassword = null + } else { + finalPassword = null + finalAllowedEmails = [] + } } + + const [row] = await tx + .insert(publicShare) + .values({ + id: generateId(), + resourceType: 'file', + resourceId: fileId, + workspaceId: owner.entityType === 'workspace' ? owner.entityId : null, + entityType: owner.entityType, + entityId: owner.entityId, + createdBy: userId, + token: token ?? generateShortId(), + isActive, + authType: finalAuthType, + password: finalPassword, + allowedEmails: finalAllowedEmails, + }) + .onConflictDoUpdate({ + target: [publicShare.resourceType, publicShare.resourceId], + set: { + isActive, + authType: finalAuthType, + password: finalPassword, + allowedEmails: finalAllowedEmails, + updatedAt: new Date(), + }, + }) + .returning() + + logger.info('Upserted file share', { + fileId, + owner, + isActive, + authType: finalAuthType, + }) + return mapShareRecord(row) } diff --git a/apps/sim/lib/realtime/notify.test.ts b/apps/sim/lib/realtime/notify.test.ts index fe2a2855744..1a7bd1c61be 100644 --- a/apps/sim/lib/realtime/notify.test.ts +++ b/apps/sim/lib/realtime/notify.test.ts @@ -5,6 +5,11 @@ import { applyEditToLiveFileDoc, invalidateLiveFileDoc } from '@/lib/realtime/no urlsMockFns.mockGetSocketServerUrl.mockReturnValue('http://realtime') setEnv({ INTERNAL_API_SECRET: 'secret' }) +const target = { + fileId: 'file-1', + owner: { entityType: 'workspace', entityId: 'workspace-1' }, +} as const + afterAll(() => { resetUrlsMock() resetEnvMock() @@ -13,7 +18,7 @@ afterAll(() => { describe('applyEditToLiveFileDoc', () => { it('throws when the realtime call fails so the outbox can retry', async () => { vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('socket pod down'))) - await expect(applyEditToLiveFileDoc('file-1', '# hello', { version: 42 })).rejects.toThrow( + await expect(applyEditToLiveFileDoc(target, '# hello', { version: 42 })).rejects.toThrow( 'socket pod down' ) }) @@ -25,7 +30,7 @@ describe('applyEditToLiveFileDoc', () => { vi.fn().mockResolvedValue(new Response(new ReadableStream({ cancel }), { status: 503 })) ) - await expect(applyEditToLiveFileDoc('file-1', '# hello', { version: 42 })).rejects.toThrow( + await expect(applyEditToLiveFileDoc(target, '# hello', { version: 42 })).rejects.toThrow( 'status 503' ) expect(cancel).toHaveBeenCalledOnce() @@ -40,7 +45,7 @@ describe('invalidateLiveFileDoc', () => { vi.fn().mockResolvedValue(new Response(new ReadableStream({ cancel }), { status })) ) - const result = invalidateLiveFileDoc('file-1', 42) + const result = invalidateLiveFileDoc(target, 42) if (status === 200) { await expect(result).resolves.toBeUndefined() } else { @@ -56,7 +61,7 @@ describe('invalidateLiveFileDoc', () => { vi.fn().mockResolvedValue(new Response(new ReadableStream({ cancel }), { status: 503 })) ) - await expect(invalidateLiveFileDoc('file-1', 42)).rejects.toThrow('status 503') + await expect(invalidateLiveFileDoc(target, 42)).rejects.toThrow('status 503') expect(cancel).toHaveBeenCalledOnce() }) @@ -64,18 +69,18 @@ describe('invalidateLiveFileDoc', () => { const fetchMock = vi.fn().mockResolvedValue({ ok: true }) vi.stubGlobal('fetch', fetchMock) - await invalidateLiveFileDoc('file-1', 42) + await invalidateLiveFileDoc(target, 42) expect(fetchMock).toHaveBeenCalledWith( 'http://realtime/api/file-doc/invalidate', expect.objectContaining({ method: 'POST', headers: expect.objectContaining({ 'x-api-key': 'secret' }), - body: JSON.stringify({ fileId: 'file-1', version: 42 }), + body: JSON.stringify({ ...target, version: 42 }), }) ) fetchMock.mockResolvedValueOnce({ ok: false, status: 503 }) - await expect(invalidateLiveFileDoc('file-1', 42)).rejects.toThrow('status 503') + await expect(invalidateLiveFileDoc(target, 42)).rejects.toThrow('status 503') }) }) diff --git a/apps/sim/lib/realtime/notify.ts b/apps/sim/lib/realtime/notify.ts index b06ff437082..6f51feb764d 100644 --- a/apps/sim/lib/realtime/notify.ts +++ b/apps/sim/lib/realtime/notify.ts @@ -1,9 +1,15 @@ import { createLogger } from '@sim/logger' import { FILE_DOC_TIMEOUTS } from '@sim/realtime-protocol/file-doc' +import { type FileDocTarget, fileDocOwnerWireFields } from '@sim/realtime-protocol/file-doc-target' import { getErrorMessage } from '@sim/utils/errors' import type { FolderResourceType } from '@/lib/api/contracts/folders' import { env } from '@/lib/core/config/env' import { getSocketServerUrl } from '@/lib/core/utils/urls' +import { + type FileOwnerAdapters, + requireFileOwnerAdapter, +} from '@/lib/workspace-files/owner-adapters' +import type { EditableFileOwner } from '@/lib/workspace-files/ownership' const logger = createLogger('RealtimeNotify') @@ -18,31 +24,34 @@ const NOTIFY_TIMEOUT_MS = 2000 const APPLY_EDIT_TIMEOUT_MS = FILE_DOC_TIMEOUTS.applyEditMs /** - * POST one workspace list-changed signal (`/api/workspace--changed`) to the realtime server, - * which fans it out to every socket in that workspace's live-list room so their browser refetches. + * POST one owner-scoped list-changed signal to the realtime server, which fans it out to + * every authorized socket in that owner's live-list room so its browser refetches. * Lossy — a dropped notification only degrades to stale-until-refetch. Never throws. Callers * `await` it (rather than fire-and-forget) so the fetch is guaranteed to dispatch before a Node * route handler returns — a floating promise can be dropped after the response is sent. It is a * normally-sub-millisecond local call, hard-bounded to {@link NOTIFY_TIMEOUT_MS}, so it adds that * latency only when the socket pod is unreachable. */ -async function postWorkspaceListChanged(endpoint: string, workspaceId: string): Promise { +async function postListChanged( + endpoint: string, + target: { workspaceId: string } | { projectId: string } +): Promise { try { const response = await fetch(`${getSocketServerUrl()}/api/${endpoint}`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-key': env.INTERNAL_API_SECRET }, - body: JSON.stringify({ workspaceId }), + body: JSON.stringify(target), signal: AbortSignal.timeout(NOTIFY_TIMEOUT_MS), }) if (!response.ok) { logger.warn(`${endpoint} notify failed`, { - workspaceId, + ...target, status: response.status, }) } } catch (error) { logger.warn(`${endpoint} notify error`, { - workspaceId, + ...target, error: getErrorMessage(error), }) } @@ -50,20 +59,34 @@ async function postWorkspaceListChanged(endpoint: string, workspaceId: string): /** * Best-effort fan-out that a workspace's file tree changed, so every viewer of that workspace's - * files refetches. See {@link postWorkspaceListChanged} for the shared lossy/never-throws contract. + * files refetches. See {@link postListChanged} for the shared lossy/never-throws contract. */ export function notifyWorkspaceFilesChanged(workspaceId: string): Promise { - return postWorkspaceListChanged('workspace-files-changed', workspaceId) + return postListChanged('workspace-files-changed', { workspaceId }) +} + +const FILE_LIST_NOTIFIERS: FileOwnerAdapters<(id: string) => Promise> = { + workspace: notifyWorkspaceFilesChanged, + project: (projectId) => postListChanged('project-files-changed', { projectId }), +} + +/** Lossy owner-scoped invalidation, called after the canonical file mutation commits. */ +export async function notifyFileListChanged(owner: EditableFileOwner): Promise { + try { + await requireFileOwnerAdapter(FILE_LIST_NOTIFIERS, owner)(owner.entityId) + } catch (error) { + logger.warn('File collection notify failed', { owner, error: getErrorMessage(error) }) + } } /** * Best-effort fan-out that a workspace's table list changed (a table was created, renamed, moved, * deleted, or restored), so every viewer of that workspace's tables refetches. Fires from the * shared table service, so it covers every surface (HTTP routes AND copilot). See - * {@link postWorkspaceListChanged} for the shared lossy/never-throws contract. + * {@link postListChanged} for the shared lossy/never-throws contract. */ export function notifyWorkspaceTablesChanged(workspaceId: string): Promise { - return postWorkspaceListChanged('workspace-tables-changed', workspaceId) + return postListChanged('workspace-tables-changed', { workspaceId }) } /** @@ -73,10 +96,10 @@ export function notifyWorkspaceTablesChanged(workspaceId: string): Promise * per-workflow editor notifications ({@link notifyWorkflowUpdated}): those only reach sockets with * that workflow's canvas open, while this reaches everyone in the workspace. Fires from the * workflow application use cases, so it covers every surface (UI, CLI, copilot, API). See - * {@link postWorkspaceListChanged} for the shared lossy/never-throws contract. + * {@link postListChanged} for the shared lossy/never-throws contract. */ export function notifyWorkspaceWorkflowsChanged(workspaceId: string): Promise { - return postWorkspaceListChanged('workspace-workflows-changed', workspaceId) + return postListChanged('workspace-workflows-changed', { workspaceId }) } /** Best-effort fan-out that invalidates open editors for one durably changed workflow. */ @@ -187,17 +210,21 @@ interface LiveFileDocMergeResponse { * failures to callers that own a retry policy, such as the transactional outbox. */ export async function applyEditToLiveFileDoc( - fileId: string, + target: FileDocTarget, markdown: string, order: LiveFileDocMergeOrder = {}, - signal?: AbortSignal, - owner?: { entityType: 'project'; entityId: string } + signal?: AbortSignal ): Promise { const timeoutSignal = AbortSignal.timeout(APPLY_EDIT_TIMEOUT_MS) const response = await fetch(`${getSocketServerUrl()}/api/file-doc/apply-edit`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-key': env.INTERNAL_API_SECRET }, - body: JSON.stringify({ fileId, markdown, version: order.version, ...(owner ? { owner } : {}) }), + body: JSON.stringify({ + fileId: target.fileId, + markdown, + version: order.version, + ...fileDocOwnerWireFields(target.owner), + }), signal: signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal, }) if (!response.ok) { @@ -226,16 +253,15 @@ export async function applyEditToLiveFileDoc( * editor. Unlike list notifications this is durability-sensitive and throws so the outbox retries. */ export async function invalidateLiveFileDoc( - fileId: string, + target: FileDocTarget, version: number, - signal?: AbortSignal, - owner?: { entityType: 'project'; entityId: string } + signal?: AbortSignal ): Promise { const timeoutSignal = AbortSignal.timeout(APPLY_EDIT_TIMEOUT_MS) const response = await fetch(`${getSocketServerUrl()}/api/file-doc/invalidate`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-key': env.INTERNAL_API_SECRET }, - body: JSON.stringify({ fileId, version, ...(owner ? { owner } : {}) }), + body: JSON.stringify({ ...target, version, ...fileDocOwnerWireFields(target.owner) }), signal: signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal, }) await response.body?.cancel().catch(() => {}) @@ -244,9 +270,9 @@ export async function invalidateLiveFileDoc( } } -/** Retire only a named Project document history, so delayed delivery cannot erase a restored file. */ -export async function retireLiveProjectFileDoc( - target: { projectId: string; fileId: string; retiredDocId: string; replacementDocId: string }, +/** Retire only a named document history, so delayed delivery cannot erase a restored file. */ +export async function retireLiveFileDoc( + target: FileDocTarget & { retiredDocId: string; replacementDocId: string }, signal?: AbortSignal ): Promise { const timeout = AbortSignal.timeout(APPLY_EDIT_TIMEOUT_MS) @@ -254,10 +280,9 @@ export async function retireLiveProjectFileDoc( const response = await fetch(`${getSocketServerUrl()}/api/file-doc/retire`, { method: 'POST', headers: { 'Content-Type': 'application/json', 'x-api-key': env.INTERNAL_API_SECRET }, - body: JSON.stringify(target), + body: JSON.stringify({ ...target, ...fileDocOwnerWireFields(target.owner) }), signal: signal ? AbortSignal.any([signal, timeout]) : timeout, }) await response.body?.cancel().catch(() => {}) - if (!response.ok) - throw new Error(`Project document retirement failed with status ${response.status}`) + if (!response.ok) throw new Error(`Document retirement failed with status ${response.status}`) } diff --git a/apps/sim/lib/uploads/archive.ts b/apps/sim/lib/uploads/archive.ts index 7762193915a..7d4615d79c0 100644 --- a/apps/sim/lib/uploads/archive.ts +++ b/apps/sim/lib/uploads/archive.ts @@ -154,17 +154,27 @@ type InflateResult = const inflateEntryWithinCaps = ( entry: JSZip.JSZipObject, remainingTotalBudget: number, - retain: boolean + retain: boolean, + signal?: AbortSignal ): Promise => new Promise((resolve, reject) => { + signal?.throwIfAborted() const chunks: Buffer[] = [] let size = 0 let settled = false const stream = entry.nodeStream() as Readable + const abort = () => { + if (settled) return + settled = true + stream.destroy() + reject(signal?.reason) + } + signal?.addEventListener('abort', abort, { once: true }) const settle = (result: InflateResult) => { if (settled) return settled = true + signal?.removeEventListener('abort', abort) stream.destroy() resolve(result) } @@ -187,6 +197,7 @@ const inflateEntryWithinCaps = ( stream.on('error', () => { if (settled) return settled = true + signal?.removeEventListener('abort', abort) stream.destroy() // A stream error here means the entry's compressed data is corrupt or // truncated (it passed the central-directory parse) — surface it under the @@ -258,70 +269,43 @@ function throwInflateCapError(reason: 'entry' | 'total', entryName: string): nev ) } -/** - * Decompress an archive buffer into workspace files under `rootFolderSegments` - * (default: the workspace root). Reuses the same caps and zip-slip / zip-bomb / - * symlink guards everywhere. Throws {@link ArchiveError} for an invalid archive - * or a cap violation; returns `{ extracted: [] }` when every entry was skipped. - * - * All-or-nothing: pass 1 inflates every entry through a discarding counting sink - * to enforce the per-entry and total caps on REAL inflated bytes (declared sizes - * can lie), and nothing is uploaded until the whole archive has passed — so a - * mid-archive violation never leaves a partial tree in the workspace. Pass 2 - * re-inflates and uploads one entry at a time. Peak memory stays ~one entry in - * both passes; the cost is inflating twice (CPU only, bounded by the caps). - * - * `signal` aborts between entries in both passes. Callers that hold a lease or run - * under a request deadline must pass one: the write loop is otherwise unbounded, and a - * process killed mid-pass-2 strands a partial tree that no `catch` can roll back. - * Aborting instead unwinds through the same all-or-nothing rollback as any other failure. - * - * When `prepareRootFolder` is provided it takes precedence over - * `rootFolderSegments`: it runs once, only after the caps have been proven and - * only when at least one safe entry exists, and extraction lands under the - * segments it returns. It must create exactly one folder, and must pass its - * final segments to the supplied validator before inserting it so the complete - * destination path is rejected before any folder mutation. That one folder is - * counted by the `maxMaterializedItems` pre-check (files + implied folders + - * root folder), which rejects an over-limit archive before the callback - * materializes anything. That cap always applies — it defaults to - * {@link MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS} — because the file count alone - * does not bound folder creation. - * - * Filesystem-noise entries (`__MACOSX/`, `.DS_Store`, `Thumbs.db`) are extracted - * verbatim unless `skipNoiseEntries` is set — the HTTP decompress route preserves - * them; the agent-facing extract path drops them. Decompression is not byte-preserving, - * so every extracted file inherits the archive's full candidate set. Runtime consumers redact - * decoded content; direct opaque delivery still refuses known secret contributions. - */ -export async function decompressArchiveBufferToWorkspaceFiles( +/** Derives a safe, human-readable extraction root from the archive name. */ +export function archiveFolderName(fileName: string): string { + const stripped = fileName + .replace(/\.zip$/i, '') + .normalize('NFC') + .replace(/[\x00-\x1f\x7f]/g, '') + .replace(/[/\\]/g, '-') + .trim() + return stripped && stripped !== '.' && stripped !== '..' ? stripped : 'archive' +} + +export interface PreparedArchiveExtraction { + readonly entryCount: number + readonly skipped: number + readonly skippedUnsafePaths: readonly string[] + validateRootFolderSegments(segments: readonly string[]): void + entries(signal?: AbortSignal): AsyncGenerator<{ segments: readonly string[]; buffer: Buffer }> +} + +/** Validates one bounded archive before any destination mutation or storage upload. */ +export async function prepareArchiveExtraction( buffer: Buffer, - opts: { - workspaceId: string - principal: Principal - rootFolderSegments?: string[] - prepareRootFolder?: ( - validateRootFolderSegments: (rootFolderSegments: string[]) => void - ) => Promise + options: { + rootFolderSegments?: readonly string[] + includeRootFolder?: boolean signal?: AbortSignal maxMaterializedItems?: number skipNoiseEntries?: boolean - secretProvenance?: WorkspaceFileSecretProvenance - notifyWorkspaceChange?: boolean - } -): Promise { + } = {} +): Promise { const { - workspaceId, - principal, rootFolderSegments = [], - prepareRootFolder, + includeRootFolder = false, signal, maxMaterializedItems = MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS, skipNoiseEntries = false, - secretProvenance = { status: 'unknown' }, - notifyWorkspaceChange = true, - } = opts - + } = options assertCentralDirWithinCaps(buffer) let zip: JSZip @@ -362,7 +346,7 @@ export async function decompressArchiveBufferToWorkspaceFiles( ) } - const validateRootFolderSegments = (candidateRootFolderSegments: string[]): void => { + const validateRootFolderSegments = (candidateRootFolderSegments: readonly string[]): void => { for (const { entry, segments } of safeEntries) { try { buildFolderPath([...candidateRootFolderSegments, ...segments.slice(0, -1)]) @@ -388,13 +372,13 @@ export async function decompressArchiveBufferToWorkspaceFiles( } /** * Bounds the whole output tree, not just the file count: 1000 entries nested 64 deep imply - * far more folders than files, and nothing else caps folder creation. `prepareRootFolder` + * far more folders than files, and nothing else caps folder creation. `includeRootFolder` * contributes exactly one more folder when it runs. */ const materializedItems = safeEntries.length + impliedFolderPaths.size + - (safeEntries.length > 0 && prepareRootFolder ? 1 : 0) + (safeEntries.length > 0 && includeRootFolder ? 1 : 0) if (materializedItems > maxMaterializedItems) { throw new ArchiveError( 'too_many_entries', @@ -420,14 +404,112 @@ export async function decompressArchiveBufferToWorkspaceFiles( const result = await inflateEntryWithinCaps( entry, MAX_ARCHIVE_TOTAL_BYTES - validatedTotal, - false + false, + signal ) if (!result.ok) throwInflateCapError(result.reason, entry.name) validatedTotal += result.size } + return Object.freeze({ + entryCount: safeEntries.length, + skipped, + skippedUnsafePaths: Object.freeze(skippedUnsafePaths), + validateRootFolderSegments, + async *entries(entrySignal = signal) { + let totalBytes = 0 + for (const { entry, segments } of safeEntries) { + entrySignal?.throwIfAborted() + const result = await inflateEntryWithinCaps( + entry, + MAX_ARCHIVE_TOTAL_BYTES - totalBytes, + true, + entrySignal + ) + if (!result.ok) throwInflateCapError(result.reason, entry.name) + if (!result.buffer) throw new Error('Archive entry bytes are unavailable') + totalBytes += result.size + yield { segments: Object.freeze(segments), buffer: result.buffer } + } + }, + }) +} + +/** + * Decompress an archive buffer into workspace files under `rootFolderSegments` + * (default: the workspace root). Reuses the same caps and zip-slip / zip-bomb / + * symlink guards everywhere. Throws {@link ArchiveError} for an invalid archive + * or a cap violation; returns `{ extracted: [] }` when every entry was skipped. + * + * All-or-nothing: pass 1 inflates every entry through a discarding counting sink + * to enforce the per-entry and total caps on REAL inflated bytes (declared sizes + * can lie), and nothing is uploaded until the whole archive has passed — so a + * mid-archive violation never leaves a partial tree in the workspace. Pass 2 + * re-inflates and uploads one entry at a time. Peak memory stays ~one entry in + * both passes; the cost is inflating twice (CPU only, bounded by the caps). + * + * `signal` aborts between entries in both passes. Callers that hold a lease or run + * under a request deadline must pass one: the write loop is otherwise unbounded, and a + * process killed mid-pass-2 strands a partial tree that no `catch` can roll back. + * Aborting instead unwinds through the same all-or-nothing rollback as any other failure. + * + * When `prepareRootFolder` is provided it takes precedence over + * `rootFolderSegments`: it runs once, only after the caps have been proven and + * only when at least one safe entry exists, and extraction lands under the + * segments it returns. It must create exactly one folder, and must pass its + * final segments to the supplied validator before inserting it so the complete + * destination path is rejected before any folder mutation. That one folder is + * counted by the `maxMaterializedItems` pre-check (files + implied folders + + * root folder), which rejects an over-limit archive before the callback + * materializes anything. That cap always applies — it defaults to + * {@link MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS} — because the file count alone + * does not bound folder creation. + * + * Filesystem-noise entries (`__MACOSX/`, `.DS_Store`, `Thumbs.db`) are extracted + * verbatim unless `skipNoiseEntries` is set — the HTTP decompress route preserves + * them; the agent-facing extract path drops them. Decompression is not byte-preserving, + * so every extracted file inherits the archive's full candidate set. Runtime consumers redact + * decoded content; direct opaque delivery still refuses known secret contributions. + */ +export async function decompressArchiveBufferToWorkspaceFiles( + buffer: Buffer, + opts: { + workspaceId: string + principal: Principal + rootFolderSegments?: string[] + prepareRootFolder?: ( + validateRootFolderSegments: (rootFolderSegments: string[]) => void + ) => Promise + signal?: AbortSignal + maxMaterializedItems?: number + skipNoiseEntries?: boolean + secretProvenance?: WorkspaceFileSecretProvenance + notifyWorkspaceChange?: boolean + } +): Promise { + const { + workspaceId, + principal, + rootFolderSegments = [], + prepareRootFolder, + signal, + maxMaterializedItems = MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS, + skipNoiseEntries = false, + secretProvenance = { status: 'unknown' }, + notifyWorkspaceChange = true, + } = opts + + const prepared = await prepareArchiveExtraction(buffer, { + rootFolderSegments, + includeRootFolder: Boolean(prepareRootFolder), + signal, + maxMaterializedItems, + skipNoiseEntries, + }) + const { validateRootFolderSegments, skipped } = prepared + const skippedUnsafePaths = [...prepared.skippedUnsafePaths] const resolvedRootFolderSegments = - safeEntries.length > 0 && prepareRootFolder + prepared.entryCount > 0 && prepareRootFolder ? await prepareRootFolder(validateRootFolderSegments) : rootFolderSegments // Re-check what the callback actually returned; identical segments were proven above. @@ -447,14 +529,9 @@ export async function decompressArchiveBufferToWorkspaceFiles( const createdFolderIds: string[] = [] const createdFiles: WorkspaceFileRecord[] = [] const extracted: UserFile[] = [] - let totalBytes = 0 try { - for (const { entry, segments } of safeEntries) { + for await (const { segments, buffer: entryBuffer } of prepared.entries(signal)) { signal?.throwIfAborted() - const result = await inflateEntryWithinCaps(entry, MAX_ARCHIVE_TOTAL_BYTES - totalBytes, true) - if (!result.ok) throwInflateCapError(result.reason, entry.name) - totalBytes += result.size - const entryBuffer = result.buffer as Buffer const leafName = segments[segments.length - 1] const folderSegments = [...resolvedRootFolderSegments, ...segments.slice(0, -1)] diff --git a/apps/sim/lib/uploads/contexts/workspace/__integration__/file-versions.integration.ts b/apps/sim/lib/uploads/contexts/workspace/__integration__/file-versions.integration.ts index 17c005940ba..9ade25cb041 100644 --- a/apps/sim/lib/uploads/contexts/workspace/__integration__/file-versions.integration.ts +++ b/apps/sim/lib/uploads/contexts/workspace/__integration__/file-versions.integration.ts @@ -229,7 +229,7 @@ describe('workspace file version history in PostgreSQL', () => { phase === 'storage' ? null : vi - .spyOn(storageBilling, 'incrementStorageUsageForBillingContextInTx') + .spyOn(storageBilling, 'prepareFileAccountingInTx') .mockRejectedValueOnce(operationError) try { const operation = diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts index 08ef27bbc8c..34a290d271a 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 @@ -570,7 +570,7 @@ async function getFileFolder( } /** Resolves one active folder by ID or path within the authorized owner. */ -async function resolveFileFolderTarget( +export async function resolveFileFolderTarget( owner: O, target: { folderId?: string | null; folderPath?: string }, client: DbOrTx = db @@ -605,7 +605,7 @@ export async function resolveWorkspaceFileFolderTarget( } /** Checks an active destination without treating a foreign folder ID as a root selection. */ -async function assertFileFolderTarget( +export async function assertFileFolderTarget( owner: EditableFileOwner, folderId?: string | null, client: DbOrTx = db @@ -666,7 +666,7 @@ export async function createWorkspaceFileFolder( } /** Creates one folder under the caller's owner transaction and the shared hierarchy lock. */ -async function createFileFolder( +export async function createFileFolder( params: CreateFileFolderParams, tx: DbTransaction ): Promise> { @@ -910,7 +910,7 @@ export async function updateWorkspaceFileFolder( } /** Renames or moves one folder while preserving its canonical owner and subtree identity. */ -async function updateFileFolder( +export async function updateFileFolder( params: UpdateFileFolderParams, tx: DbTransaction ): Promise> { @@ -1018,7 +1018,7 @@ export async function moveWorkspaceFileItems(params: { ) } -async function moveFileItems( +export async function moveFileItems( params: { owner: EditableFileOwner fileIds?: string[] @@ -1255,7 +1255,7 @@ export async function restoreWorkspaceFileFolder( } /** Restores the folder's archive batch, keeping independently archived descendants unchanged. */ -async function restoreFileFolder( +export async function restoreFileFolder( owner: O, folderId: string, tx: DbTransaction @@ -1421,7 +1421,7 @@ export async function bulkArchiveWorkspaceFileItems(params: { ) } -async function archiveFileItems( +export async function archiveFileItems( params: { owner: EditableFileOwner fileIds?: string[] @@ -1519,7 +1519,7 @@ async function archiveFileItems( } /** Loads a complete canonical owner tree, refusing a partial index when a bound is supplied. */ -async function loadActiveFileFolderPathIndex( +export async function loadActiveFileFolderPathIndex( owner: EditableFileOwner, client: DbOrTx = db, options?: { maxRows?: number } 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 abb63a8ff85..b7742b153a7 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts @@ -41,11 +41,8 @@ import { timestampKey, } from '@/lib/api/list-query' import { - decrementStorageUsageForBillingContextInTx, - incrementStorageUsageForBillingContextInTx, - lockWorkspaceStorageForMutationInTx, maybeNotifyStorageLimitForBillingContext, - resolveStorageBillingContext, + prepareFileAccountingInTx, } from '@/lib/billing/storage' import { CollabDocStateConflictError, @@ -57,7 +54,8 @@ import { generateRequestId } from '@/lib/core/utils/request' import { generateRestoreName } from '@/lib/core/utils/restore-name' import { isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import type { DbOrTx, DbTransaction } from '@/lib/db/types' -import { parseFolderPath } from '@/lib/folders/paths' +import { MAX_FOLDERS_PER_WORKSPACE } from '@/lib/folders/constants' +import { buildFolderPath, parseFolderPath } from '@/lib/folders/paths' import { loadActiveFolderPathIndex, resolveFolderPathFromIndex } from '@/lib/folders/queries' import type { FolderIdScope } from '@/lib/folders/scope' import { normalizeVfsSegment } from '@/lib/mothership/vfs/normalize-segment' @@ -66,10 +64,6 @@ import { lockWorkspaceProject } from '@/lib/projects/membership' import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' import { getServePathPrefix } from '@/lib/uploads' import type { WorkspaceFileFolderRecord } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' -import { - enqueueWorkspaceFileLiveDocReconciliation, - processWorkspaceFileLiveDocReconciliationNow, -} from '@/lib/uploads/contexts/workspace/workspace-file-live-doc-outbox' import { applyWorkspaceFileSecretProvenancePolicyInTx, EXACT_EMPTY_WORKSPACE_FILE_SECRET_PROVENANCE, @@ -101,7 +95,13 @@ import { headObject, uploadFile, } from '@/lib/uploads/core/storage-service' +import { finishFileContentEffects } from '@/lib/uploads/server/content-effects' +import { enqueueFileLiveDocReconciliation } from '@/lib/uploads/server/live-doc-outbox' import { getWorkspaceFileSize, MAX_WORKSPACE_FILE_SIZE } from '@/lib/uploads/shared/types' +import { + getVerifiedUploadSessionObject, + type UploadSessionRecord, +} from '@/lib/uploads/upload-session/service' import { isMarkdownFile } from '@/lib/uploads/utils/file-utils' import type { ServableFile } from '@/lib/uploads/utils/file-utils.server' import { displaySegmentPattern } from '@/lib/vfs/path' @@ -109,7 +109,7 @@ import { lockFileDirectories } from '@/lib/workspace-files/locks' import { type EditableFileOwner, editableFileOwnerColumns, - type FileOwner, + matchesFileOwner, resolveFileOwner, } from '@/lib/workspace-files/ownership' import { fileFolderOwnerCondition, fileOwnerCondition } from '@/lib/workspace-files/ownership-query' @@ -129,7 +129,9 @@ import { getWorkspaceFileFolderPath, listFileFolders, listWorkspaceFileFolders, + loadActiveFileFolderPathIndex, normalizeWorkspaceFileItemName, + resolveFileFolderTarget, resolveWorkspaceFileFolderTarget, workspaceFileNameFolderCondition, } from './workspace-file-folder-manager' @@ -192,7 +194,7 @@ export interface VersionedWorkspaceFileRecord extends WorkspaceFileRecord { } /** Shared file metadata never substitutes an environment ID for its owning Project. */ -interface OwnedFileRecord +export interface OwnedFileRecord extends Omit { owner: O folderId: string | null @@ -460,7 +462,7 @@ export async function allocateUniqueWorkspaceFileName( return withCopySuffix(baseName, generateShortId(8)) } -interface StagedFileContent { +export interface StagedFileContent { readonly owner: Readonly readonly key: string readonly name: string @@ -470,15 +472,60 @@ interface StagedFileContent { } const stagedFileContents = new WeakSet() +const adoptedUploadContents = new WeakSet() + +export interface PlannedFileIdentity { + readonly id: string +} + +const plannedFileOwners = new WeakMap>() + +/** Reserves an in-process destination identity so a compound copy can rewrite selected references. */ +export function planFileIdentity(owner: EditableFileOwner): PlannedFileIdentity { + const identity = Object.freeze({ id: `wf_${generateShortId()}` }) + plannedFileOwners.set(identity, Object.freeze({ ...owner })) + return identity +} + +/** Adopts verified session bytes without making generic staging cleanup their owner. */ +export function adoptVerifiedUploadSession( + session: UploadSessionRecord, + owner: EditableFileOwner +): StagedFileContent { + const object = getVerifiedUploadSessionObject(session) + if ( + owner.entityType !== 'project' || + object.purpose !== 'project_file' || + object.projectId !== owner.entityId || + object.workspaceId !== null || + object.storageContext !== 'project' || + !object.finalKey.startsWith(`project/${owner.entityId}/`) + ) + throw new Error('Verified upload does not belong to this file owner') + const staged = Object.freeze({ + owner: Object.freeze({ ...owner }), + key: object.finalKey, + name: normalizeWorkspaceFileItemName(object.fileName, 'File'), + size: object.fileSize, + contentType: object.contentType, + contentHash: null, + }) + stagedFileContents.add(staged) + adoptedUploadContents.add(staged) + return staged +} + /** Writes private bytes before the owner-authorized metadata transaction begins. */ -async function stageFileContent(args: { +export async function stageFileContent(args: { owner: EditableFileOwner userId: string name: string contentType: string content: Buffer folderId?: string | null + signal?: AbortSignal }): Promise { + args.signal?.throwIfAborted() const name = normalizeWorkspaceFileItemName(args.name, 'File') const owner = Object.freeze({ ...args.owner }) const key = @@ -513,6 +560,7 @@ async function stageFileContent(args: { ...(args.folderId ? { folderId: args.folderId } : {}), }, persistMetadata: false, + signal: args.signal, }) if (uploaded.key !== key) throw new Error('Storage returned an unexpected file key') } catch (error) { @@ -534,8 +582,10 @@ function assertStagedFileOwner(owner: EditableFileOwner, staged: StagedFileConte } /** Discards definitely uncommitted bytes without replacing the operation's original failure. */ -async function discardStagedFileContent(staged: StagedFileContent): Promise { +export async function discardStagedFileContent(staged: StagedFileContent): Promise { assertStagedFileOwner(staged.owner, staged) + if (adoptedUploadContents.has(staged)) + throw new Error('Staged bytes are owned by their upload session') let events: string[] try { events = await enqueueWorkspaceFileStorageCleanups(db, [staged.key], staged.owner.entityType) @@ -591,6 +641,81 @@ async function finalizeStagedFileContent( } } +/** Commits new canonical metadata and provenance under the owner's directory lock. */ +export async function commitFileCreateInTx( + tx: DbTransaction, + args: { + owner: EditableFileOwner + staged: StagedFileContent + userId: string + folderId?: string | null + folderPath?: string + exactName?: boolean + identity?: PlannedFileIdentity + secretProvenance?: WorkspaceFileSecretProvenance + } +): Promise { + assertStagedFileOwner(args.owner, args.staged) + if (args.identity) { + const plannedOwner = plannedFileOwners.get(args.identity) + if ( + plannedOwner?.entityType !== args.owner.entityType || + plannedOwner.entityId !== args.owner.entityId + ) + throw new Error('Planned file identity does not belong to this owner') + } + if (args.owner.entityType === 'workspace') await lockWorkspaceProject(tx, args.owner.entityId) + await lockFileDirectories(tx, [args.owner]) + const target = await resolveFileFolderTarget(args.owner, args, tx) + const folderId = target?.id ?? null + const exists = async (name: string) => { + const [row] = await tx + .select({ id: workspaceFiles.id }) + .from(workspaceFiles) + .where( + and( + fileOwnerCondition(args.owner), + eq(workspaceFiles.originalName, name), + workspaceFileNameFolderCondition(folderId), + isNull(workspaceFiles.deletedAt) + ) + ) + .limit(1) + return Boolean(row) + } + let name = args.staged.name + if (await exists(name)) { + if (args.exactName) throw new FileConflictError(name) + let suffix = 1 + do { + name = withCopySuffix( + args.staged.name, + suffix <= MAX_NUMBERED_COPY_SUFFIX ? suffix : generateShortId(8) + ) + suffix += 1 + } while (suffix <= MAX_NUMBERED_COPY_SUFFIX + 2 && (await exists(name))) + } + const inserted = await insertFileMetadataInTx(tx, args.owner, { + id: args.identity?.id ?? `wf_${generateShortId()}`, + key: args.staged.key, + userId: args.userId, + folderId, + originalName: name, + contentType: args.staged.contentType, + size: args.staged.size, + }) + if (!inserted) throw new FileConflictError(name) + if (args.secretProvenance) { + await replaceWorkspaceFileSecretProvenanceInTx( + tx, + inserted.id, + inserted.contentUpdatedAt, + args.secretProvenance + ) + } + return inserted +} + /** * Upload a file to workspace-scoped storage */ @@ -636,7 +761,6 @@ export async function uploadWorkspaceFile( const effectiveName = pageRestore?.name ?? normalizedFileName const effectiveContentType = pageRestore ? SIM_PAGE_CONTENT_TYPE : contentType const exactName = options?.exactName ?? false - const storageBillingContext = await resolveStorageBillingContext(workspaceId) let lastError: unknown const maxAttempts = exactName ? 1 : MAX_UPLOAD_UNIQUE_RETRIES @@ -647,7 +771,7 @@ export async function uploadWorkspaceFile( if (exactName && (await fileExistsInWorkspace(workspaceId, uniqueName, folderId))) { throw new FileConflictError(uniqueName) } - const fileId = `wf_${generateShortId()}` + const identity = planFileIdentity({ entityType: 'workspace', entityId: workspaceId }) try { const uploadResult = await stageFileContent({ @@ -660,49 +784,34 @@ export async function uploadWorkspaceFile( }) const finalized = await finalizeStagedFileContent(uploadResult, async (tx) => { - await lockWorkspaceProject(tx, workspaceId) - await lockFileDirectories(tx, [{ entityType: 'workspace', entityId: workspaceId }]) - let activeFolderId: string | null + const accounting = await prepareFileAccountingInTx(tx, { + entityType: 'workspace', + entityId: workspaceId, + }) + let activeFolderId = folderId if (options?.folderPath !== undefined) { + await lockFileDirectories(tx, [{ entityType: 'workspace', entityId: workspaceId }]) const folderIndex = await loadActiveFolderPathIndex(workspaceId, 'file', tx) const resolvedFolderId = resolveFolderPathFromIndex(folderIndex, options.folderPath) if (resolvedFolderId === undefined) { throw new OrchestrationError('not_found', 'Target folder not found') } activeFolderId = resolvedFolderId - } else { - activeFolderId = await assertWorkspaceFileFolderTarget(workspaceId, folderId, tx) } - const inserted = await insertWorkspaceFileMetadataInTx(tx, { - id: fileId, - key: uploadResult.key, + const inserted = await commitFileCreateInTx(tx, { + owner: { entityType: 'workspace', entityId: workspaceId }, + staged: uploadResult, + identity, userId, - workspaceId, folderId: activeFolderId, - originalName: uniqueName, - contentType: effectiveContentType, - size: effectiveBuffer.length, + exactName: true, + secretProvenance: options?.secretProvenance, }) - if (!inserted) { - throw new FileConflictError(uniqueName) - } - if (options?.secretProvenance) { - await replaceWorkspaceFileSecretProvenanceInTx( - tx, - inserted.id, - inserted.contentUpdatedAt, - options.secretProvenance - ) - } - const usage = await incrementStorageUsageForBillingContextInTx( - tx, - storageBillingContext, - effectiveBuffer.length - ) - return { inserted, updatedUsage: usage } + const usage = await accounting.mutation.applyDelta(effectiveBuffer.length) + return { inserted, updatedUsage: usage, billing: accounting.billing } }) - void maybeNotifyStorageLimitForBillingContext(storageBillingContext, finalized.updatedUsage) + void maybeNotifyStorageLimitForBillingContext(finalized.billing, finalized.updatedUsage) logger.info( `Successfully uploaded workspace file: ${uniqueName} with key: ${uploadResult.key}` @@ -921,13 +1030,15 @@ export async function registerUploadedWorkspaceFile(params: { const folderId = params.folderId ?? null - const storageBillingContext = await resolveStorageBillingContext(workspaceId) for (let attempt = 0; attempt < MAX_UPLOAD_UNIQUE_RETRIES; attempt++) { const fileId = `wf_${generateShortId()}` const displayName = await allocateUniqueWorkspaceFileName(workspaceId, effectiveName, folderId) const finalized = await db.transaction(async (tx) => { - await lockWorkspaceProject(tx, workspaceId) + const accounting = await prepareFileAccountingInTx(tx, { + entityType: 'workspace', + entityId: workspaceId, + }) await lockFileDirectories(tx, [{ entityType: 'workspace', entityId: workspaceId }]) const activeFolderId = await assertWorkspaceFileFolderTarget(workspaceId, folderId, tx) const inserted = await insertWorkspaceFileMetadataInTx(tx, { @@ -964,11 +1075,7 @@ export async function registerUploadedWorkspaceFile(params: { return { kind: 'existing', file: raceWinner } as const } - const updatedUsage = await incrementStorageUsageForBillingContextInTx( - tx, - storageBillingContext, - effectiveSize - ) + const updatedUsage = await accounting.mutation.applyDelta(effectiveSize) await replaceWorkspaceFileSecretProvenanceInTx( tx, inserted.id, @@ -976,7 +1083,7 @@ export async function registerUploadedWorkspaceFile(params: { secretProvenance ) await markUploadSessionFileRegistered(tx, params.uploadSessionId, workspaceId, inserted.id) - return { kind: 'created', file: inserted, updatedUsage } as const + return { kind: 'created', file: inserted, updatedUsage, billing: accounting.billing } as const }) if (finalized.kind === 'name-conflict') { @@ -987,7 +1094,7 @@ export async function registerUploadedWorkspaceFile(params: { } if (finalized.kind === 'created') { - void maybeNotifyStorageLimitForBillingContext(storageBillingContext, finalized.updatedUsage) + void maybeNotifyStorageLimitForBillingContext(finalized.billing, finalized.updatedUsage) } await commitPageRestoreRewrite() @@ -1306,7 +1413,7 @@ function mapWorkspaceFileRecord( } /** Projects the same persisted file metadata for either supported editable owner. */ -function mapFileRecord( +export function mapFileRecord( file: WorkspaceFileListRow, owner: O, folderPaths: Map @@ -1575,6 +1682,7 @@ const WORKSPACE_FILE_SORTS = { } satisfies Record[]> export interface QueryWorkspaceFilesOptions { + fileIds?: readonly string[] scope?: WorkspaceFileScope /** Restrict to one file folder. */ /** `undefined` lists every folder, `null` lists only root files. */ @@ -1653,7 +1761,7 @@ export async function queryWorkspaceFiles( } /** Authorized callers share one owner-filtered keyset query under their transaction. */ -async function queryFileRecords( +export async function queryFileRecords( owner: O, options: QueryWorkspaceFilesOptions, client: DbOrTx = db @@ -1682,6 +1790,7 @@ async function queryFileRecords( const conditions = [ fileOwnerCondition(owner), + options.fileIds ? inArray(workspaceFiles.id, [...options.fileIds]) : undefined, scope === 'all' ? undefined : scope === 'archived' @@ -1833,6 +1942,76 @@ export function findWorkspaceFileRecord( return files.find((file) => normalizeVfsSegment(file.name) === segmentKey) ?? null } +/** Resolves an ID or scoped VFS path without widening to another owner or scanning file contents. */ +export async function resolveFileReference( + owner: O, + reference: string, + client: DbOrTx = db +): Promise | null> { + let localReference = reference.trim().replace(/^\/+/, '') + const namespace = owner.entityType === 'project' ? 'projects' : 'workspaces' + if (localReference.startsWith(`${namespace}/`)) { + const prefix = `${namespace}/${owner.entityId}/` + if (!localReference.startsWith(prefix)) { + throw new OrchestrationError('validation', 'File reference does not match its owner') + } + localReference = localReference.slice(prefix.length) + } + const isFileId = localReference.startsWith('wf_') || isUuid(localReference) + if (!isFileId && !localReference.startsWith('files/')) { + throw new OrchestrationError( + 'validation', + 'File reference must be an ID or an owner-scoped files path' + ) + } + let segments: string[] + try { + segments = normalizeWorkspaceFileReferenceSegments(localReference) + } catch { + throw new OrchestrationError('validation', 'Invalid file reference path') + } + if (segments.length === 0) throw new OrchestrationError('validation', 'File reference is empty') + + const folders = await loadActiveFileFolderPathIndex(owner, client, { + maxRows: MAX_FOLDERS_PER_WORKSPACE, + }) + let selector: SQL + if (isFileId) { + selector = eq(workspaceFiles.id, localReference) + } else { + const folderPath = buildFolderPath(segments.slice(0, -1)) + let folderId: string | null = null + if (folderPath !== '/') { + const matchingFolders = [...folders.pathById].filter( + ([, path]) => + parseFolderPath(path).map(normalizeVfsSegment).join('/') === + segments.slice(0, -1).map(normalizeVfsSegment).join('/') + ) + if (matchingFolders.length > 1) { + throw new OrchestrationError('conflict', 'File folder reference is ambiguous') + } + const match = matchingFolders[0] + if (!match) return null + folderId = match[0] + } + const name = segments.at(-1) ?? '' + selector = + and( + workspaceFileNameFolderCondition(folderId), + sql`regexp_replace(normalize(${workspaceFiles.originalName}, NFC), '[\\x01-\\x1f\\x7f]', '', 'g') ~ ${displaySegmentPattern(name)}` + ) ?? sql`false` + } + const rows = await client + .select() + .from(workspaceFiles) + .where(and(fileOwnerCondition(owner), isNull(workspaceFiles.deletedAt), selector)) + .limit(2) + if (rows.length > 1) throw new OrchestrationError('conflict', 'File reference is ambiguous') + const row = rows[0] + if (!row) return null + return mapFileRecord(row, owner, buildWorkspaceFileFolderPathMap([...folders.rowById.values()])) +} + async function getWorkspaceFileByExactReference( workspaceId: string, segments: string[] @@ -1885,15 +2064,13 @@ export async function resolveWorkspaceFileReference( return findWorkspaceFileRecord(files, fileReference) } -/** - * Load the canonical authorization context for an active workspace file by resource ID. - * Database failures propagate so callers never confuse unavailable state with a missing file. - * Chat uploads are admitted only on explicit opt-in (see {@link WorkspaceFileLookupOptions}). - */ -export async function loadActiveWorkspaceFileContext( +async function loadWorkspaceFileContext( fileId: string, - options?: WorkspaceFileLookupOptions & { includeDeleted?: boolean } -): Promise { + options: WorkspaceFileLookupOptions & { + includeDeleted?: boolean + includeArchivedWorkspace?: boolean + } +): Promise { const [context] = await db .select({ fileId: workspaceFiles.id, @@ -1901,6 +2078,7 @@ export async function loadActiveWorkspaceFileContext( workspaceOrganizationId: workspace.organizationId, allowPersonalApiKeys: workspace.allowPersonalApiKeys, billedAccountUserId: workspace.billedAccountUserId, + deletedAt: workspaceFiles.deletedAt, ownership: { projectId: workspaceFiles.projectId, context: workspaceFiles.context, @@ -1915,63 +2093,42 @@ export async function loadActiveWorkspaceFileContext( .where( and( eq(workspaceFiles.id, fileId), - workspaceFileContextCondition(options?.includeChatUploads), - ...(options?.includeDeleted ? [] : [isNull(workspaceFiles.deletedAt)]), - isNull(workspace.archivedAt) + workspaceFileContextCondition(options.includeChatUploads), + options.includeDeleted ? undefined : isNull(workspaceFiles.deletedAt), + options.includeArchivedWorkspace ? undefined : isNull(workspace.archivedAt) ) ) .limit(1) if ( !context || - !matchesWorkspaceFileOwner(resolveFileOwner(context.ownership), context.workspaceId) + !matchesFileOwner(resolveFileOwner(context.ownership), { + entityType: 'workspace', + entityId: context.workspaceId, + }) ) { return null } return omit(context, ['ownership']) } -function matchesWorkspaceFileOwner(owner: FileOwner | null, workspaceId: string): boolean { - return owner?.entityType === 'workspace' && owner.entityId === workspaceId +/** Loads canonical active-workspace identity; chat uploads and deleted files require explicit opt-in. */ +export async function loadActiveWorkspaceFileContext( + fileId: string, + options?: WorkspaceFileLookupOptions & { includeDeleted?: boolean } +): Promise { + const context = await loadWorkspaceFileContext(fileId, { + includeDeleted: options?.includeDeleted, + includeChatUploads: options?.includeChatUploads, + }) + return context ? omit(context, ['deletedAt']) : null } -/** - * Load a workspace file for a lifecycle transition, including archived files. - * The workspace archive state is returned by the canonical workspace record and is enforced by - * the operation's manager primitive where the transition requires an active workspace. - */ +/** Loads workspace files for lifecycle transitions; the manager enforces the operation's archive policy. */ export async function loadWorkspaceFileLifecycleContext( fileId: string ): Promise { - const [context] = await db - .select({ - fileId: workspaceFiles.id, - workspaceId: workspace.id, - workspaceOrganizationId: workspace.organizationId, - allowPersonalApiKeys: workspace.allowPersonalApiKeys, - billedAccountUserId: workspace.billedAccountUserId, - deletedAt: workspaceFiles.deletedAt, - ownership: { - projectId: workspaceFiles.projectId, - context: workspaceFiles.context, - workspaceId: workspaceFiles.workspaceId, - organizationId: workspaceFiles.organizationId, - userId: workspaceFiles.userId, - chatId: workspaceFiles.chatId, - }, - }) - .from(workspaceFiles) - .innerJoin(workspace, eq(workspaceFiles.workspaceId, workspace.id)) - .where(and(eq(workspaceFiles.id, fileId), eq(workspaceFiles.context, 'workspace'))) - .limit(1) - - if ( - !context || - !matchesWorkspaceFileOwner(resolveFileOwner(context.ownership), context.workspaceId) - ) { - return null - } - return omit(context, ['ownership']) + return loadWorkspaceFileContext(fileId, { includeDeleted: true, includeArchivedWorkspace: true }) } /** @@ -2178,7 +2335,7 @@ export class ContentVersionConflictError extends Error { } } -interface CommitFileContentOptions { +export interface CommitFileContentOptions { owner: EditableFileOwner fileId: string staged: StagedFileContent @@ -2188,13 +2345,10 @@ interface CommitFileContentOptions { secretProvenancePolicy?: WorkspaceFileSecretProvenancePolicy } -/** Commits bytes, history and provenance under canonical scope and storage locks. */ -async function commitFileContentInTx(tx: DbTransaction, options: CommitFileContentOptions) { +/** Commits bytes, history and provenance after the caller has locked its canonical billing payer. */ +export async function commitFileContentInTx(tx: DbTransaction, options: CommitFileContentOptions) { const { owner, fileId, staged } = options - if (owner.entityType === 'workspace') { - await lockWorkspaceProject(tx, owner.entityId) - await lockWorkspaceStorageForMutationInTx(tx, owner.entityId) - } + if (owner.entityType === 'workspace') await lockWorkspaceProject(tx, owner.entityId) if (staged.contentHash === null) throw new Error('Content replacement requires staged bytes with a verified content hash') assertStagedFileOwner(owner, staged) @@ -2347,7 +2501,6 @@ export async function updateWorkspaceFileContent( throw new OrchestrationError('not_found', 'File not found') } - const storageBillingContext = await resolveStorageBillingContext(workspaceId) const nextContentType = contentType || fileRecord.type try { const staged = await stageFileContent({ @@ -2360,6 +2513,10 @@ export async function updateWorkspaceFileContent( }) const finalized = await finalizeStagedFileContent(staged, async (tx) => { + const accounting = await prepareFileAccountingInTx(tx, { + entityType: 'workspace', + entityId: workspaceId, + }) const committed = await commitFileContentInTx(tx, { owner: { entityType: 'workspace', entityId: workspaceId }, fileId, @@ -2372,26 +2529,13 @@ export async function updateWorkspaceFileContent( sizeDiff, storageCleanupEventIds, } = committed - let updatedUsage: number | undefined - if (sizeDiff > 0) { - updatedUsage = await incrementStorageUsageForBillingContextInTx( - tx, - storageBillingContext, - sizeDiff - ) - } else if (sizeDiff < 0) { - await decrementStorageUsageForBillingContextInTx( - tx, - storageBillingContext, - Math.abs(sizeDiff) - ) - } + const updatedUsage = await accounting.mutation.applyDelta(sizeDiff) const liveDocEventId = options.syncLiveDoc !== false && (isMarkdownFile({ type: currentFile.contentType, name: currentFile.originalName }) || isMarkdownFile({ type: updatedFile.contentType, name: updatedFile.originalName })) - ? await enqueueWorkspaceFileLiveDocReconciliation(tx, { + ? await enqueueFileLiveDocReconciliation(tx, { workspaceId, fileId, version: updatedFile.contentUpdatedAt.getTime(), @@ -2402,6 +2546,7 @@ export async function updateWorkspaceFileContent( file: updatedFile, sizeDiff, updatedUsage, + billing: accounting.billing, liveDocEventId, storageCleanupEventIds, currentVersion: committed.currentVersion, @@ -2410,37 +2555,16 @@ export async function updateWorkspaceFileContent( if (finalized.sizeDiff !== 0) { void maybeNotifyStorageLimitForBillingContext( - storageBillingContext, + finalized.billing, finalized.updatedUsage, finalized.sizeDiff < 0 ) } - await processWorkspaceFileStorageCleanupsNow(finalized.storageCleanupEventIds, { - workspaceId, - fileId, - reason: 'released version', - }) - - if (finalized.liveDocEventId) { - try { - const result = await processWorkspaceFileLiveDocReconciliationNow(finalized.liveDocEventId) - if (result !== 'completed') { - logger.warn('Live document reconciliation deferred to outbox retry', { - workspaceId, - fileId, - eventId: finalized.liveDocEventId, - result, - }) - } - } catch (error) { - logger.warn('Live document reconciliation deferred after inline processing error', { - workspaceId, - fileId, - eventId: finalized.liveDocEventId, - error: getErrorMessage(error), - }) - } - } + await finishFileContentEffects( + { cleanupIds: finalized.storageCleanupEventIds, liveDocEventId: finalized.liveDocEventId }, + { workspaceId, fileId, reason: 'released version' }, + 'defer' + ) const currentFolderPath = finalized.file.folderId === fileRecord.folderId ? fileRecord.folderPath : null @@ -2533,7 +2657,7 @@ export async function renameWorkspaceFile( } /** Renames under the owner's tree and file locks without changing the content identity. */ -async function renameFileInTx( +export async function renameFileInTx( tx: DbTransaction, owner: EditableFileOwner, fileId: string, @@ -2641,7 +2765,6 @@ export async function purgeCreatedWorkspaceFile(params: { expectedFolderId: string | null expectedUpdatedAt: Date }): Promise { - const storageBillingContext = await resolveStorageBillingContext(params.workspaceId) const expectedFolder = params.expectedFolderId === null ? isNull(workspaceFiles.folderId) @@ -2658,8 +2781,10 @@ export async function purgeCreatedWorkspaceFile(params: { isNull(workspaceFiles.deletedAt) ) const cleanupEventIds = await db.transaction(async (tx) => { - await lockWorkspaceProject(tx, params.workspaceId) - await lockWorkspaceStorageForMutationInTx(tx, params.workspaceId) + const accounting = await prepareFileAccountingInTx(tx, { + entityType: 'workspace', + entityId: params.workspaceId, + }) const [lockedFile] = await tx .select({ id: workspaceFiles.id, @@ -2680,11 +2805,7 @@ export async function purgeCreatedWorkspaceFile(params: { .returning({ id: workspaceFiles.id }) if (!deleted) throw new Error('Locked archive-created file could not be deleted') - await decrementStorageUsageForBillingContextInTx( - tx, - storageBillingContext, - getWorkspaceFileSize(lockedFile) - ) + await accounting.mutation.applyDelta(-getWorkspaceFileSize(lockedFile)) const keys = new Set([lockedFile.key, ...versionKeys]) return enqueueWorkspaceFileStorageCleanups(tx, [...keys]) }) @@ -2733,7 +2854,7 @@ export async function restoreWorkspaceFile(workspaceId: string, fileId: string): } /** Restores a retained head, re-rooting and deduplicating within its canonical owner. */ -async function restoreFileInTx( +export async function restoreFileInTx( tx: DbTransaction, owner: EditableFileOwner, fileId: string diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-accounting.test.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-accounting.test.ts index 570e7ae5788..8b63cf6b11b 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-accounting.test.ts +++ b/apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-accounting.test.ts @@ -16,6 +16,7 @@ import { workspaceFileSecretProvenanceMock, workspaceFileSecretProvenanceMockFns, } from '@sim/testing/mocks/workspace-file-secret-provenance.mock' +import { toRecord } from '@sim/utils/object' import { eq } from 'drizzle-orm' import { beforeEach, describe, expect, it, vi } from 'vitest' @@ -58,9 +59,9 @@ vi.mock('@/lib/realtime/notify', () => realtimeNotifyMock) vi.mock('@/lib/projects/membership', () => projectMembershipMock) -vi.mock('@/lib/uploads/contexts/workspace/workspace-file-live-doc-outbox', () => ({ - enqueueWorkspaceFileLiveDocReconciliation: mockEnqueueWorkspaceFileLiveDocReconciliation, - processWorkspaceFileLiveDocReconciliationNow: mockProcessWorkspaceFileLiveDocReconciliationNow, +vi.mock('@/lib/uploads/server/live-doc-outbox', () => ({ + enqueueFileLiveDocReconciliation: mockEnqueueWorkspaceFileLiveDocReconciliation, + processFileLiveDocReconciliationNow: mockProcessWorkspaceFileLiveDocReconciliationNow, })) vi.mock('@/lib/billing/storage', () => billingStorageMock) @@ -116,13 +117,10 @@ workspaceFileFoldersMockFns.mockNormalizeWorkspaceFileItemName.mockImplementatio ) const mockResolveRestoredFolderId = folderQueriesMockFns.mockResolveRestoredFolderId -const mockDecrementStorageUsageForBillingContextInTx = - billingStorageMockFns.mockDecrementStorageUsageForBillingContextInTx -const mockIncrementStorageUsageForBillingContextInTx = - billingStorageMockFns.mockIncrementStorageUsageForBillingContextInTx +const mockApplyFileStorageDelta = billingStorageMockFns.mockApplyFileStorageDelta const mockMaybeNotifyStorageLimitForBillingContext = billingStorageMockFns.mockMaybeNotifyStorageLimitForBillingContext -const mockResolveStorageBillingContext = billingStorageMockFns.mockResolveStorageBillingContext +const mockPrepareFileAccountingInTx = billingStorageMockFns.mockPrepareFileAccountingInTx const mockInitializeWorkspaceFileSecretProvenanceInTx = workspaceFileSecretProvenanceMockFns.mockInitializeWorkspaceFileSecretProvenanceInTx @@ -179,7 +177,10 @@ describe('workspace file metadata and storage accounting', () => { let stagedKey = '' beforeEach(() => { resetDbChainMock() - mockResolveStorageBillingContext.mockResolvedValue(STORAGE_CONTEXT) + mockPrepareFileAccountingInTx.mockResolvedValue({ + billing: STORAGE_CONTEXT, + mutation: { applyDelta: mockApplyFileStorageDelta }, + }) mockResolveWorkspaceFileFolderTarget.mockResolvedValue(null) mockAssertWorkspaceFileFolderTarget.mockResolvedValue(null) mockHasCloudStorage.mockReturnValue(false) @@ -197,9 +198,8 @@ describe('workspace file metadata and storage accounting', () => { mockGetWorkspaceWithOwner.mockResolvedValue({ archivedAt: null }) mockFileNameExistsInWorkspaceFolder.mockResolvedValue(false) mockResolveRestoredFolderId.mockResolvedValue(null) - mockIncrementStorageUsageForBillingContextInTx.mockReset().mockResolvedValue(10) + mockApplyFileStorageDelta.mockReset().mockResolvedValue(10) mockInitializeWorkspaceFileSecretProvenanceInTx.mockResolvedValue(undefined) - mockDecrementStorageUsageForBillingContextInTx.mockResolvedValue(undefined) mockMaybeNotifyStorageLimitForBillingContext.mockResolvedValue(undefined) mockDeleteFile.mockResolvedValue(undefined) mockEnqueueWorkspaceFileStorageCleanups.mockImplementation(async (_executor, keys: string[]) => @@ -228,6 +228,12 @@ describe('workspace file metadata and storage accounting', () => { mockResolveFolderPathFromIndex .mockReturnValueOnce('folder-initial') .mockReturnValueOnce('folder-final') + workspaceFileFoldersMockFns.mockResolveFileFolderTarget.mockImplementationOnce( + async (_owner, target) => { + const folderId = toRecord(target).folderId + return typeof folderId === 'string' ? { id: folderId } : null + } + ) dbChainMockFns.returning.mockResolvedValueOnce([inserted]) await uploadWorkspaceFile( @@ -260,9 +266,7 @@ describe('workspace file metadata and storage accounting', () => { it('cleans up a newly uploaded object when atomic metadata finalization rolls back', async () => { dbChainMockFns.returning.mockResolvedValueOnce([FILE_ROW]) - mockIncrementStorageUsageForBillingContextInTx.mockRejectedValueOnce( - new Error('payer update failed') - ) + mockApplyFileStorageDelta.mockRejectedValueOnce(new Error('payer update failed')) await expect( uploadWorkspaceFile( @@ -306,18 +310,14 @@ describe('workspace file metadata and storage accounting', () => { expect(eq).toHaveBeenCalledWith(workspaceFiles.originalName, FILE_ROW.originalName) expect(eq).toHaveBeenCalledWith(workspaceFiles.folderId, extractedRow.folderId) expect(eq).toHaveBeenCalledWith(workspaceFiles.updatedAt, FILE_ROW.updatedAt) - expect(mockDecrementStorageUsageForBillingContextInTx).toHaveBeenCalledWith( - expect.any(Object), - STORAGE_CONTEXT, - FILE_ROW.size - ) + expect(mockApplyFileStorageDelta).toHaveBeenCalledWith(-FILE_ROW.size) expect(mockEnqueueWorkspaceFileStorageCleanups).toHaveBeenCalledWith(expect.any(Object), [ FILE_ROW.key, ]) expect(dbChainMockFns.delete.mock.invocationCallOrder[0]).toBeLessThan( - mockDecrementStorageUsageForBillingContextInTx.mock.invocationCallOrder[0] + mockApplyFileStorageDelta.mock.invocationCallOrder[0] ) - expect(mockDecrementStorageUsageForBillingContextInTx.mock.invocationCallOrder[0]).toBeLessThan( + expect(mockApplyFileStorageDelta.mock.invocationCallOrder[0]).toBeLessThan( mockEnqueueWorkspaceFileStorageCleanups.mock.invocationCallOrder[0] ) expect(mockProcessWorkspaceFileStorageCleanupsNow).toHaveBeenCalledWith( @@ -341,7 +341,7 @@ describe('workspace file metadata and storage accounting', () => { }) ).resolves.toBe(false) - expect(mockDecrementStorageUsageForBillingContextInTx).not.toHaveBeenCalled() + expect(mockApplyFileStorageDelta).not.toHaveBeenCalled() expect(mockEnqueueWorkspaceFileStorageCleanups).not.toHaveBeenCalled() expect(mockProcessWorkspaceFileStorageCleanupsNow).not.toHaveBeenCalled() expect(mockDeleteFile).not.toHaveBeenCalled() @@ -373,7 +373,7 @@ describe('workspace file metadata and storage accounting', () => { ]) expect([first.created, second.created].sort()).toEqual([false, true]) - expect(mockIncrementStorageUsageForBillingContextInTx).toHaveBeenCalledTimes(1) + expect(mockApplyFileStorageDelta).toHaveBeenCalledTimes(1) expect(mockReplaceWorkspaceFileSecretProvenanceInTx).toHaveBeenCalledTimes(1) expect(mockReplaceWorkspaceFileSecretProvenanceInTx).toHaveBeenCalledWith( expect.any(Object), @@ -411,7 +411,7 @@ describe('workspace file metadata and storage accounting', () => { { status: 'exact', entries: [] } ) expect(mockReplaceWorkspaceFileSecretProvenanceInTx).not.toHaveBeenCalled() - expect(mockIncrementStorageUsageForBillingContextInTx).not.toHaveBeenCalled() + expect(mockApplyFileStorageDelta).not.toHaveBeenCalled() }) it('does not delete an object when a registration race finds a different operation', async () => { @@ -431,7 +431,7 @@ describe('workspace file metadata and storage accounting', () => { }) ).rejects.toThrow('already registered to a different workspace file operation') - expect(mockIncrementStorageUsageForBillingContextInTx).not.toHaveBeenCalled() + expect(mockApplyFileStorageDelta).not.toHaveBeenCalled() expect(mockDeleteFile).not.toHaveBeenCalled() }) @@ -453,7 +453,7 @@ describe('workspace file metadata and storage accounting', () => { }) ).rejects.toMatchObject({ code: 'conflict' }) expect(dbChainMockFns.returning).not.toHaveBeenCalled() - expect(mockIncrementStorageUsageForBillingContextInTx).not.toHaveBeenCalled() + expect(mockApplyFileStorageDelta).not.toHaveBeenCalled() expect(mockMaybeNotifyStorageLimitForBillingContext).not.toHaveBeenCalled() }) @@ -468,7 +468,7 @@ describe('workspace file metadata and storage accounting', () => { await deleteWorkspaceFile(FILE_ROW.workspaceId, FILE_ROW.id) await deleteWorkspaceFile(FILE_ROW.workspaceId, FILE_ROW.id) - expect(mockDecrementStorageUsageForBillingContextInTx).not.toHaveBeenCalled() + expect(mockApplyFileStorageDelta).not.toHaveBeenCalled() }) it('re-roots a restored file whose folder has since been archived', async () => { @@ -516,11 +516,7 @@ describe('workspace file metadata and storage accounting', () => { persistMetadata: false, }) ) - expect(mockIncrementStorageUsageForBillingContextInTx).toHaveBeenCalledWith( - expect.any(Object), - STORAGE_CONTEXT, - 3 - ) + expect(mockApplyFileStorageDelta).toHaveBeenCalledWith(3) expect(mockApplyWorkspaceFileSecretProvenancePolicyInTx).toHaveBeenCalledWith( expect.any(Object), FILE_ROW.id, @@ -599,9 +595,7 @@ describe('workspace file metadata and storage accounting', () => { dbChainMockFns.limit.mockResolvedValueOnce([FILE_ROW]).mockResolvedValueOnce([FILE_ROW]) dbChainMockFns.returning.mockResolvedValueOnce([updatedFile]) - mockIncrementStorageUsageForBillingContextInTx.mockRejectedValueOnce( - new Error('Storage limit exceeded') - ) + mockApplyFileStorageDelta.mockRejectedValueOnce(new Error('Storage limit exceeded')) await expect( updateWorkspaceFileContent( @@ -642,11 +636,7 @@ describe('workspace file metadata and storage accounting', () => { MD_ROW.id, PREPARED_COLLAB_STATE ) - expect(mockIncrementStorageUsageForBillingContextInTx).toHaveBeenCalledWith( - transaction, - STORAGE_CONTEXT, - content.length - MD_ROW.sizeBytes - ) + expect(mockApplyFileStorageDelta).toHaveBeenCalledWith(content.length - MD_ROW.sizeBytes) expect(mockEnqueueWorkspaceFileLiveDocReconciliation).toHaveBeenCalledWith( transaction, expect.objectContaining({ fileId: MD_ROW.id }) @@ -722,8 +712,7 @@ describe('workspace file metadata and storage accounting', () => { expect(mockSaveCollabDocStateInTx).toHaveBeenCalledWith(transaction, MD_ROW.id, preparedState) expect(dbChainMockFns.update).not.toHaveBeenCalled() expect(mockApplyWorkspaceFileSecretProvenancePolicyInTx).not.toHaveBeenCalled() - expect(mockIncrementStorageUsageForBillingContextInTx).not.toHaveBeenCalled() - expect(mockDecrementStorageUsageForBillingContextInTx).not.toHaveBeenCalled() + expect(mockApplyFileStorageDelta).not.toHaveBeenCalled() expect(mockEnqueueWorkspaceFileLiveDocReconciliation).not.toHaveBeenCalled() expect(mockProcessWorkspaceFileLiveDocReconciliationNow).not.toHaveBeenCalled() expect(mockMaybeNotifyStorageLimitForBillingContext).not.toHaveBeenCalled() @@ -776,9 +765,7 @@ describe('workspace file metadata and storage accounting', () => { { ...MD_ROW, key: replacementKey, sizeBytes: 13 }, ]) - mockIncrementStorageUsageForBillingContextInTx.mockRejectedValueOnce( - new Error('accounting unavailable') - ) + mockApplyFileStorageDelta.mockRejectedValueOnce(new Error('accounting unavailable')) await expect( updateWorkspaceFileContent( @@ -801,11 +788,7 @@ describe('workspace file metadata and storage accounting', () => { MD_ROW.id, PREPARED_COLLAB_STATE ) - expect(mockIncrementStorageUsageForBillingContextInTx).toHaveBeenCalledWith( - transaction, - STORAGE_CONTEXT, - 8 - ) + expect(mockApplyFileStorageDelta).toHaveBeenCalledWith(8) expect(mockEnqueueWorkspaceFileLiveDocReconciliation).not.toHaveBeenCalled() expect(mockMaybeNotifyStorageLimitForBillingContext).not.toHaveBeenCalled() expect(mockEnqueueWorkspaceFileStorageCleanups).toHaveBeenCalledWith( diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-billing.test.ts b/apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-billing.test.ts deleted file mode 100644 index 93a76b0b06e..00000000000 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-storage-billing.test.ts +++ /dev/null @@ -1,108 +0,0 @@ -import { dbChainMockFns, resetDbChainMock } from '@sim/testing' -import { billingStorageMock, billingStorageMockFns } from '@sim/testing/mocks/billing-storage.mock' -import { projectMembershipMock } from '@sim/testing/mocks/project-membership.mock' -import { storageServiceMock, storageServiceMockFns } from '@sim/testing/mocks/storage-service.mock' -import { uploadsMock, uploadsMockFns } from '@sim/testing/mocks/uploads.mock' -import { - workspaceFileFoldersMock, - workspaceFileFoldersMockFns, -} from '@sim/testing/mocks/workspace-file-folders.mock' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock('@/lib/projects/membership', () => projectMembershipMock) -vi.mock('@/lib/billing/storage', () => billingStorageMock) - -vi.mock('@/lib/uploads', () => uploadsMock) - -vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock) - -vi.mock( - '@/lib/uploads/contexts/workspace/workspace-file-folder-manager', - () => workspaceFileFoldersMock -) - -import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' - -const mockUploadFile = storageServiceMockFns.mockUploadFile -const mockIncrementStorageUsageForBillingContextInTx = - billingStorageMockFns.mockIncrementStorageUsageForBillingContextInTx -const mockMaybeNotifyStorageLimitForBillingContext = - billingStorageMockFns.mockMaybeNotifyStorageLimitForBillingContext -const mockResolveStorageBillingContext = billingStorageMockFns.mockResolveStorageBillingContext -const mockResolveWorkspaceFileFolderTarget = - workspaceFileFoldersMockFns.mockResolveWorkspaceFileFolderTarget - -workspaceFileFoldersMockFns.mockBuildWorkspaceFileFolderPathMap.mockImplementation(() => new Map()) -workspaceFileFoldersMockFns.mockNormalizeWorkspaceFileItemName.mockImplementation( - (name: string) => name -) - -uploadsMockFns.mockGetServePathPrefix.mockImplementation(() => '/api/files/serve/s3/') - -const STORAGE_CONTEXT = { - workspaceId: 'workspace-1', - billedAccountUserId: 'workspace-owner', - billingEntity: { type: 'organization' as const, id: 'workspace-org' }, - plan: 'team_25000', - customStorageLimitGB: null, -} - -describe('workspace file storage attribution', () => { - beforeEach(() => { - resetDbChainMock() - mockResolveStorageBillingContext.mockResolvedValue(STORAGE_CONTEXT) - mockResolveWorkspaceFileFolderTarget.mockResolvedValue(null) - mockIncrementStorageUsageForBillingContextInTx.mockResolvedValue(5) - mockMaybeNotifyStorageLimitForBillingContext.mockResolvedValue(undefined) - mockUploadFile.mockImplementation(async ({ customKey }) => ({ key: customKey })) - }) - - it.each(['external-collaborator', 'personal-api-key-user'])( - 'charges the workspace payer while retaining %s as uploader metadata', - async (actorUserId) => { - dbChainMockFns.returning - .mockImplementationOnce(async () => [ - { - id: 'file-1', - key: mockUploadFile.mock.calls.at(-1)?.[0].customKey, - userId: actorUserId, - workspaceId: 'workspace-1', - folderId: null, - context: 'workspace', - chatId: null, - originalName: 'note.txt', - displayName: 'note.txt', - contentType: 'text/plain', - size: 5, - sizeBytes: 5, - deletedAt: null, - uploadedAt: new Date(), - updatedAt: new Date(), - contentUpdatedAt: new Date(), - }, - ]) - .mockResolvedValueOnce([{ id: 'file-1' }]) - - await uploadWorkspaceFile( - 'workspace-1', - actorUserId, - Buffer.from('hello'), - 'note.txt', - 'text/plain' - ) - - expect(mockResolveStorageBillingContext).toHaveBeenCalledWith('workspace-1') - expect(mockIncrementStorageUsageForBillingContextInTx).toHaveBeenCalledWith( - expect.any(Object), - STORAGE_CONTEXT, - 5 - ) - expect(dbChainMockFns.values).toHaveBeenCalledWith( - expect.objectContaining({ - userId: actorUserId, - workspaceId: 'workspace-1', - }) - ) - } - ) -}) diff --git a/apps/sim/lib/uploads/core/storage-client.ts b/apps/sim/lib/uploads/core/storage-client.ts index 485841ec286..d23713fea8f 100644 --- a/apps/sim/lib/uploads/core/storage-client.ts +++ b/apps/sim/lib/uploads/core/storage-client.ts @@ -11,32 +11,15 @@ export function getServePathPrefix(): string { } /** - * Get file metadata from storage provider + * Read raw object metadata from the configured provider; it is not canonical file ownership. * @param key File key/name * @param customConfig Optional custom storage configuration * @returns File metadata object with userId, workspaceId, originalName, uploadedAt, etc. */ -export async function getFileMetadata( +export async function getStorageObjectMetadata( key: string, customConfig?: StorageConfig ): Promise> { - const { getFileMetadataByKey } = await import('../server/metadata') - const metadataRecord = await getFileMetadataByKey(key) - - if (metadataRecord) { - return { - ...(metadataRecord.projectId == null && - metadataRecord.context !== 'project' && - metadataRecord.userId - ? { userId: metadataRecord.userId } - : {}), - workspaceId: metadataRecord.workspaceId || '', - originalName: metadataRecord.originalName, - uploadedAt: metadataRecord.uploadedAt.toISOString(), - purpose: metadataRecord.context, - } - } - if (USE_BLOB_STORAGE) { const { headBlobObject } = await import('@/lib/uploads/providers/blob/client') const { BLOB_CONFIG } = await import('@/lib/uploads/config') diff --git a/apps/sim/lib/uploads/documents/compile.ts b/apps/sim/lib/uploads/documents/compile.ts index becb6507afa..933e0dba95f 100644 --- a/apps/sim/lib/uploads/documents/compile.ts +++ b/apps/sim/lib/uploads/documents/compile.ts @@ -3,6 +3,7 @@ import { createLogger } from '@sim/logger' import { sha256Hex } from '@sim/security/hash' import { compareStrings } from '@sim/utils/string' import { isDocSandboxEnabled } from '@/lib/core/config/env-flags' +import { assertKnownSizeWithinLimit } from '@/lib/core/utils/stream-limits' import { CodeLanguage } from '@/lib/execution/languages' import { executeInSandbox, @@ -28,6 +29,7 @@ import { import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' import { readWorkspaceFileContent } from '@/lib/workspace-files/application/read-workspace-file-content' import { readWorkspaceFileMetadata } from '@/lib/workspace-files/application/read-workspace-file-metadata' +import type { EditableFileOwner } from '@/lib/workspace-files/ownership' import type { SandboxTaskId } from '@/sandbox-tasks/registry' const logger = createLogger('CopilotDocCompile') @@ -48,7 +50,7 @@ const PYTHON_XLSX_SOURCE_MIME = 'text/x-python-xlsx' type DocEngine = 'node' | 'python' -interface E2BDocFormat { +export interface E2BDocFormat { ext: 'pptx' | 'docx' | 'pdf' | 'xlsx' engine: DocEngine formatName: 'PPTX' | 'DOCX' | 'PDF' | 'XLSX' @@ -152,7 +154,7 @@ interface ReferencedImageResolution { artifactIdentity?: string } -interface CompiledDocResult { +export interface CompiledDocResult { artifactKey?: string buffer: Buffer contentType: string @@ -601,6 +603,87 @@ async function renderPreparedDocument(source: string, fmt: E2BDocFormat, inputs: : compileDocViaE2BPython(source, fmt, inputs) } +/** Compiles a document against a fixed, authorized input manifest and canonical owner cache. */ +export async function compileFileDocument(args: { + owner: EditableFileOwner + source: string + fileName: string + inputs: readonly { fileId: string; contentType: string; content: Buffer }[] + onArtifactWrite?: (key: string) => void + inputIdentity?: string + maxBytes: number + signal?: AbortSignal +}): Promise { + const fmt = await getE2BDocFormat(args.fileName) + if (!fmt) throw new DocCompileUserError(`Unsupported document format: ${args.fileName}`) + if ( + args.inputs.length > MAX_REFERENCED_INPUTS || + args.inputs.some((input) => input.content.length > MAX_STAGED_FILE_BYTES) || + args.inputs.reduce((sum, input) => sum + input.content.length, 0) > MAX_STAGED_TOTAL_BYTES + ) { + throw new DocCompileUserError('Document inputs exceed the rendering limit') + } + const cached = await loadCompiledDoc(args.owner, args.source, fmt.ext, args.inputIdentity, { + maxBytes: args.maxBytes, + signal: args.signal, + }) + if (cached) + return { + buffer: cached, + artifactKey: compiledArtifactKey(args.owner, args.source, fmt.ext, args.inputIdentity), + contentType: fmt.contentType, + dependsOnReferencedFiles: args.inputs.length > 0, + } + let buffer: Buffer + if (isDocSandboxEnabled) { + buffer = await renderPreparedDocument( + args.source, + fmt, + args.inputs.map((input) => ({ + path: `/home/user/inputs/${input.fileId}`, + content: input.content.toString('base64'), + encoding: 'base64' as const, + })) + ) + } else { + const format = COMPILABLE_FORMATS[`.${fmt.ext}`] + if (!format || isSimdocDeckSource(args.source)) { + throw new DocCompileUserError('This document format requires the document sandbox') + } + const inputs = new Map(args.inputs.map((input) => [input.fileId, input])) + buffer = await runSandboxTask( + format.taskId, + { code: args.source }, + { + ownerKey: `${args.owner.entityType}:${args.owner.entityId}`, + signal: args.signal, + resolvePreparedFile(fileId) { + const input = inputs.get(fileId) + if (!input) + throw new Error('Document requested a file outside its authorized input manifest') + return input + }, + } + ) + } + assertKnownSizeWithinLimit(buffer.length, args.maxBytes, 'compiled document') + await storeCompiledDoc( + args.owner, + args.source, + fmt.ext, + fmt.contentType, + buffer, + args.inputIdentity, + args.onArtifactWrite + ) + return { + buffer, + artifactKey: compiledArtifactKey(args.owner, args.source, fmt.ext, args.inputIdentity), + contentType: fmt.contentType, + dependsOnReferencedFiles: args.inputs.length > 0, + } +} + // Template-clone scripts author against the simdoc Deck (pptx) / Doc (docx) // API. The prelude supplies input_path (staged workspace files) and // OUTPUT_PATH; the finalizer saves the expected variable (scripts never save @@ -882,7 +965,7 @@ export function isCompiledDocumentBuffer(fileName: string, buffer: Buffer): bool * not exist yet; the raw bytes are source, so serving them under the file's binary * content type would be corrupt. The caller should signal "not ready" instead. */ -type ServableDoc = +export type ServableDoc = | { kind: 'passthrough' } | { kind: 'artifact'; buffer: Buffer; contentType: string } | { kind: 'unavailable' } diff --git a/apps/sim/lib/uploads/documents/index.ts b/apps/sim/lib/uploads/documents/index.ts index 5641079904e..732f104e8cf 100644 --- a/apps/sim/lib/uploads/documents/index.ts +++ b/apps/sim/lib/uploads/documents/index.ts @@ -1,5 +1,6 @@ export { collectReferencedFileIds, + compileFileDocument, getDocumentSourceLanguage, getE2BDocFormat, isCompiledDocumentBuffer, diff --git a/apps/sim/lib/uploads/index.ts b/apps/sim/lib/uploads/index.ts index 3c026bae8f0..8974b287c06 100644 --- a/apps/sim/lib/uploads/index.ts +++ b/apps/sim/lib/uploads/index.ts @@ -5,5 +5,6 @@ export { } from '@/lib/uploads/config' export * as ChatFiles from '@/lib/uploads/contexts/chat' export * as CopilotFiles from '@/lib/uploads/contexts/copilot' -export { getFileMetadata, getServePathPrefix } from '@/lib/uploads/core/storage-client' +export { getServePathPrefix } from '@/lib/uploads/core/storage-client' export * as StorageService from '@/lib/uploads/core/storage-service' +export { getFileMetadata } from '@/lib/uploads/server/legacy-metadata' diff --git a/apps/sim/lib/uploads/server/content-effects.ts b/apps/sim/lib/uploads/server/content-effects.ts new file mode 100644 index 00000000000..c3ed0235f00 --- /dev/null +++ b/apps/sim/lib/uploads/server/content-effects.ts @@ -0,0 +1,32 @@ +import { createLogger } from '@sim/logger' +import { describeError } from '@sim/utils/errors' +import { processWorkspaceFileStorageCleanupsNow } from '@/lib/uploads/contexts/workspace/workspace-file-storage-cleanup-outbox' +import { processFileLiveDocReconciliationNow } from '@/lib/uploads/server/live-doc-outbox' + +const logger = createLogger('FileContentEffects') + +/** Processes durable content effects after commit, retaining the caller's inline failure policy. */ +export async function finishFileContentEffects( + effects: { cleanupIds: readonly string[]; liveDocEventId?: string }, + logContext: Record, + reconciliationFailure: 'propagate' | 'defer' = 'propagate' +): Promise { + await processWorkspaceFileStorageCleanupsNow(effects.cleanupIds, logContext) + if (!effects.liveDocEventId) return + if (reconciliationFailure === 'propagate') { + await processFileLiveDocReconciliationNow(effects.liveDocEventId) + return + } + const context = { ...logContext, eventId: effects.liveDocEventId } + try { + const result = await processFileLiveDocReconciliationNow(effects.liveDocEventId) + if (result !== 'completed') { + logger.warn('Live document reconciliation deferred to outbox retry', { ...context, result }) + } + } catch (error) { + logger.warn('Live document reconciliation deferred after inline processing error', { + ...context, + error: describeError(error), + }) + } +} diff --git a/apps/sim/lib/uploads/server/delivery.ts b/apps/sim/lib/uploads/server/delivery.ts new file mode 100644 index 00000000000..3c986c854b8 --- /dev/null +++ b/apps/sim/lib/uploads/server/delivery.ts @@ -0,0 +1,158 @@ +import { createHash } from 'node:crypto' +import { ensureFileNameExtension } from '@/lib/uploads/utils/file-utils' + +const SAFE_INLINE_TYPES = new Set([ + 'image/png', + 'image/jpeg', + 'image/jpg', + 'image/gif', + 'image/svg+xml', + 'image/webp', + 'image/avif', + 'image/bmp', + 'image/x-icon', + 'application/pdf', + 'text/plain', + 'text/csv', + 'application/json', +]) + +const FORCE_ATTACHMENT_EXTENSIONS = new Set(['html', 'htm', 'js', 'css', 'xml']) + +function getSecureFileHeaders(filename: string, originalContentType: string) { + const extension = filename.split('.').pop()?.toLowerCase() || '' + + if (FORCE_ATTACHMENT_EXTENSIONS.has(extension)) { + return { + contentType: 'application/octet-stream', + disposition: 'attachment', + } + } + + const mediaType = originalContentType.split(';', 1)[0].trim().toLowerCase() + const safeContentType = mediaType === 'text/html' ? 'text/plain' : originalContentType + const disposition = SAFE_INLINE_TYPES.has(mediaType === 'text/html' ? 'text/plain' : mediaType) + ? 'inline' + : 'attachment' + + return { + contentType: safeContentType, + disposition, + } +} + +/** + * Percent-encode a filename as an RFC 8187 `ext-value`. + * + * `encodeURIComponent` alone is not enough: it leaves `'`, `(`, `)` and `*` raw, and + * none of those are `attr-char`. The apostrophe is the specific hazard — it is the + * delimiter in `UTF-8''name`, so a filename like `it's.pdf` would emit a third `'` + * and desync the parser. + */ +function encodeExtValue(filename: string): string { + return encodeURIComponent(filename).replace( + /['()*]/g, + (char) => `%${char.charCodeAt(0).toString(16).toUpperCase()}` + ) +} + +/** + * Build the `filename` parameters for a Content-Disposition header. + * + * The name is attacker-controlled (it is the user's `originalName`), so it can never + * be interpolated raw: a `"` closes the quoted-string early and everything after it + * is parsed as further parameters. An injected `filename*` is the payload that + * matters, because RFC 6266 tells clients to prefer `filename*` over `filename` — + * so the attacker's value wins and the download lands under a name the product UI + * never showed. Both parameters are therefore always emitted from sanitized input: + * the quoted form keeps only printable ASCII minus `"` and `\`, and the `filename*` + * form is fully percent-encoded. + * + * `;` is neutralized too, even though a quoted string may legally contain one: the + * quoted parameter exists as the fallback for clients that do not implement + * `filename*`, and those are the same clients liable to split parameters on a bare + * `;` without honouring the quoting. The exact name still survives in `filename*`. + */ +export function encodeFilenameForHeader(storageKey: string): string { + const filename = storageKey.split('/').pop() || storageKey + const asciiSafe = filename.replace(/[^\x20-\x7E]/g, '_').replace(/["\\;]/g, '_') + // Unchanged input proves the name is printable ASCII with no `"` or `\`, so the + // quoted form alone is both safe and sufficient — `filename*` buys nothing here. + if (asciiSafe === filename) { + return `filename="${filename}"` + } + return `filename="${asciiSafe}"; filename*=UTF-8''${encodeExtValue(filename)}` +} + +/** Explicit delivery policies preserve each surface's existing browser cache behavior. */ +export const FILE_CACHE_CONTROL = { + noStore: 'private, no-store', + revalidate: 'private, no-cache, must-revalidate', + private: 'private, no-cache', + immutable: 'private, max-age=31536000, immutable', + publicAsset: 'public, max-age=31536000', +} as const + +/** Constructs identical security and filename headers for buffered and streamed representations. */ +export function fileDeliveryHeaders(input: { + filename: string + contentType: string + cacheControl: string + contentLength?: number + attachment?: boolean +}): Headers { + const filename = ensureFileNameExtension(input.filename, input.contentType) + const secure = getSecureFileHeaders(filename, input.contentType) + const headers = new Headers({ + 'Content-Type': secure.contentType, + 'Content-Disposition': `${input.attachment ? 'attachment' : secure.disposition}; ${encodeFilenameForHeader(filename)}`, + 'Cache-Control': input.cacheControl, + 'X-Content-Type-Options': 'nosniff', + }) + if (input.contentLength !== undefined) headers.set('Content-Length', String(input.contentLength)) + if (secure.contentType.split(';', 1)[0].trim().toLowerCase() === 'image/svg+xml') + headers.set( + 'Content-Security-Policy', + "default-src 'none'; style-src 'unsafe-inline'; sandbox;" + ) + return headers +} + +/** Hash only already-buffered representations; streamed downloads never need materializing. */ +export function bufferedRepresentationEtag(buffer: Buffer): string { + return `"${createHash('sha256').update(buffer).digest('base64url')}"` +} + +/** Binary presenters share header assembly without coupling storage mechanics to a route framework. */ +export function presentFileDelivery(input: { + body: Buffer | ReadableStream + filename: string + contentType: string + contentLength: number + cacheControl: string + attachment?: boolean +}) { + const headers = fileDeliveryHeaders(input) + return { + body: Buffer.isBuffer(input.body) + ? new Uint8Array( + input.body.buffer as ArrayBuffer, + input.body.byteOffset, + input.body.byteLength + ) + : input.body, + contentType: headers.get('Content-Type') ?? 'application/octet-stream', + contentLength: input.contentLength, + headers, + } +} + +/** Immutable workspace caching requires a fixed storage object and no live referenced inputs. */ +export function workspaceFileCacheControl( + contentAddressed: boolean, + dependsOnReferencedFiles = false +): string { + return contentAddressed && !dependsOnReferencedFiles + ? FILE_CACHE_CONTROL.immutable + : FILE_CACHE_CONTROL.revalidate +} diff --git a/apps/sim/lib/uploads/server/embedded-image-refs.ts b/apps/sim/lib/uploads/server/embedded-image-refs.ts index 0fbb78ff65c..a5156c10db1 100644 --- a/apps/sim/lib/uploads/server/embedded-image-refs.ts +++ b/apps/sim/lib/uploads/server/embedded-image-refs.ts @@ -11,6 +11,7 @@ import { extractEmbeddedFileRef, storedFileId, } from '@/lib/uploads/utils/embedded-image-ref' +import type { EditableFileOwner } from '@/lib/workspace-files/ownership' /** Hard cap on embedded images collected for a bulk export bundle. */ export const MAX_EMBEDDED_IMAGES = 50 @@ -43,33 +44,48 @@ function childrenOf(token: Token): Token[] { * Links are excluded for the same reason: a link is navigated to, not displayed, so it is neither an * exportable asset nor something a document's public share should cascade to. */ -export function extractEmbeddedFileRefs(content: string): { keys: string[]; ids: string[] } { +export function extractEmbeddedFileRefs( + content: string, + owner?: EditableFileOwner +): { keys: string[]; ids: string[] } { const keys = new Set() const ids = new Set() - visitEmbeddedFileRefs(content, (ref) => { - if ('key' in ref) keys.add(ref.key) - else ids.add(ref.fileId) - return keys.size + ids.size >= MAX_EMBEDDED_IMAGES - }) + visitEmbeddedFileRefs( + content, + (ref) => { + if ('key' in ref) keys.add(ref.key) + else ids.add(ref.fileId) + return keys.size + ids.size >= MAX_EMBEDDED_IMAGES + }, + owner + ) return { keys: [...keys], ids: [...ids] } } /** Matches one stored image reference without imposing a bulk export's asset-count limit. */ -export function hasEmbeddedFileRef(content: string, target: NonNullable): boolean { - return visitEmbeddedFileRefs(content, (ref) => - 'fileId' in target - ? 'fileId' in ref && storedFileId(ref.fileId) === target.fileId - : 'key' in ref && ref.key === target.key +export function hasEmbeddedFileRef( + content: string, + target: NonNullable, + owner?: EditableFileOwner +): boolean { + return visitEmbeddedFileRefs( + content, + (ref) => + 'fileId' in target + ? 'fileId' in ref && storedFileId(ref.fileId) === target.fileId + : 'key' in ref && ref.key === target.key, + owner ) } /** Stops at the first accepted reference; callers bound document bytes before parsing. */ function visitEmbeddedFileRefs( content: string, - visit: (ref: NonNullable) => boolean + visit: (ref: NonNullable) => boolean, + owner?: EditableFileOwner ): boolean { const record = (src: string) => { - const ref = extractEmbeddedFileRef(src) + const ref = extractEmbeddedFileRef(src, owner) return ref !== null && visit(ref) } diff --git a/apps/sim/lib/uploads/server/image-derivative.ts b/apps/sim/lib/uploads/server/image-derivative.ts index b17559fbefc..b97689fda2f 100644 --- a/apps/sim/lib/uploads/server/image-derivative.ts +++ b/apps/sim/lib/uploads/server/image-derivative.ts @@ -4,17 +4,25 @@ import { getErrorMessage } from '@sim/utils/errors' import { downloadFile, uploadFile } from '@/lib/uploads/core/storage-service' import { isHevcHeifContainer, transcodeHeicToJpeg } from '@/lib/uploads/server/heic' import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' +import type { EditableFileOwner } from '@/lib/workspace-files/ownership' const logger = createLogger('ImageDerivative') +interface ImageDerivativeOptions { + owner: EditableFileOwner + onArtifactWrite?: (key: string) => void +} + /** * Keyed by the source's storage key rather than a hash of its bytes. Workspace keys * are regenerated on every content replacement, so the key is already a content * version — using it avoids streaming the whole original just to hash it. */ -function derivativeKey(storageKey: string): string { +function derivativeKey(storageKey: string, owner?: EditableFileOwner): string { const hash = createHash('sha256').update(storageKey, 'utf-8').digest('hex') - return `image-derivative/${hash}.jpg` + return owner?.entityType === 'project' + ? `project/${owner.entityId}/image-derivative/${hash}.jpg` + : `image-derivative/${hash}.jpg` } /** @@ -27,11 +35,14 @@ function derivativeKey(storageKey: string): string { * in that band into a miss, re-transcoding the original on each preview, which costs * more than serving the cached copy would have. */ -async function loadDerivative(storageKey: string): Promise { +async function loadDerivative( + storageKey: string, + options?: ImageDerivativeOptions +): Promise { try { return await downloadFile({ - key: derivativeKey(storageKey), - context: 'copilot', + key: derivativeKey(storageKey, options?.owner), + context: options?.owner.entityType === 'project' ? 'project' : 'copilot', maxBytes: MAX_BUFFERED_TRANSFER_BYTES, }) } catch { @@ -47,14 +58,20 @@ async function loadDerivative(storageKey: string): Promise { * read transcodes again. Failing the request would turn a cache problem into a broken * image for bytes we have already rendered successfully. */ -async function storeDerivative(storageKey: string, jpeg: Buffer): Promise { +async function storeDerivative( + storageKey: string, + jpeg: Buffer, + options?: ImageDerivativeOptions +): Promise { + const key = derivativeKey(storageKey, options?.owner) try { + options?.onArtifactWrite?.(key) await uploadFile({ file: jpeg, fileName: 'derivative.jpg', contentType: 'image/jpeg', - context: 'copilot', - customKey: derivativeKey(storageKey), + context: options?.owner.entityType === 'project' ? 'project' : 'copilot', + customKey: key, preserveKey: true, }) } catch (error) { @@ -81,16 +98,17 @@ async function storeDerivative(storageKey: string, jpeg: Buffer): Promise */ export async function resolveServableImageBytes( buffer: Buffer, - storageKey: string + storageKey: string, + options?: ImageDerivativeOptions ): Promise<{ buffer: Buffer; contentType: string } | null> { if (!isHevcHeifContainer(buffer)) return null - const cached = await loadDerivative(storageKey) + const cached = await loadDerivative(storageKey, options) if (cached) return { buffer: cached, contentType: 'image/jpeg' } const jpeg = await transcodeHeicToJpeg(buffer) if (!jpeg) return null - await storeDerivative(storageKey, jpeg) + await storeDerivative(storageKey, jpeg, options) return { buffer: jpeg, contentType: 'image/jpeg' } } diff --git a/apps/sim/lib/uploads/core/storage-client.test.ts b/apps/sim/lib/uploads/server/legacy-metadata.test.ts similarity index 96% rename from apps/sim/lib/uploads/core/storage-client.test.ts rename to apps/sim/lib/uploads/server/legacy-metadata.test.ts index 25ead64beb3..9f9e7a34ef7 100644 --- a/apps/sim/lib/uploads/core/storage-client.test.ts +++ b/apps/sim/lib/uploads/server/legacy-metadata.test.ts @@ -17,7 +17,7 @@ vi.mock('@/lib/uploads/providers/s3/client', () => ({ vi.mock('@/lib/uploads/server/metadata', () => uploadsMetadataMock) -import { getFileMetadata } from '@/lib/uploads/core/storage-client' +import { getFileMetadata } from '@/lib/uploads/server/legacy-metadata' setUploadsConfig({ USE_S3_STORAGE: true, diff --git a/apps/sim/lib/uploads/server/legacy-metadata.ts b/apps/sim/lib/uploads/server/legacy-metadata.ts new file mode 100644 index 00000000000..ef336ed3238 --- /dev/null +++ b/apps/sim/lib/uploads/server/legacy-metadata.ts @@ -0,0 +1,31 @@ +import { getStorageObjectMetadata } from '@/lib/uploads/core/storage-client' +import type { StorageConfig } from '@/lib/uploads/shared/types' + +/** + * Compatibility projection for legacy key-based authorization. Shared Project files require + * their Principal-aware operation; their creator must never become a legacy user grant. + * Owner-aware operations consume canonical metadata through resolveFileOwner instead. + */ +export async function getFileMetadata( + key: string, + customConfig?: StorageConfig +): Promise> { + const { getFileMetadataByKey } = await import('@/lib/uploads/server/metadata') + const metadataRecord = await getFileMetadataByKey(key) + + if (metadataRecord) { + return { + ...(metadataRecord.projectId == null && + metadataRecord.context !== 'project' && + metadataRecord.userId + ? { userId: metadataRecord.userId } + : {}), + workspaceId: metadataRecord.workspaceId || '', + originalName: metadataRecord.originalName, + uploadedAt: metadataRecord.uploadedAt.toISOString(), + purpose: metadataRecord.context, + } + } + + return getStorageObjectMetadata(key, customConfig) +} diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-live-doc-outbox.test.ts b/apps/sim/lib/uploads/server/live-doc-outbox.test.ts similarity index 87% rename from apps/sim/lib/uploads/contexts/workspace/workspace-file-live-doc-outbox.test.ts rename to apps/sim/lib/uploads/server/live-doc-outbox.test.ts index 750b238e9e5..9441aeae4b2 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-live-doc-outbox.test.ts +++ b/apps/sim/lib/uploads/server/live-doc-outbox.test.ts @@ -2,14 +2,14 @@ import { dbChainMockFns, resetDbChainMock } from '@sim/testing' import { realtimeNotifyMock, realtimeNotifyMockFns } from '@sim/testing/mocks/realtime-notify.mock' import { storageServiceMock, storageServiceMockFns } from '@sim/testing/mocks/storage-service.mock' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { WORKSPACE_FILE_LIVE_DOC_OUTBOX_EVENT } from '@/lib/uploads/contexts/workspace/file-outbox-events' vi.mock('@/lib/realtime/notify', () => realtimeNotifyMock) vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock) import type { OutboxEventContext } from '@/lib/core/outbox/service' -import { WORKSPACE_FILE_LIVE_DOC_OUTBOX_EVENT } from '@/lib/uploads/contexts/workspace/file-outbox-events' -import { workspaceFileLiveDocOutboxHandlers } from '@/lib/uploads/contexts/workspace/workspace-file-live-doc-outbox' +import { fileLiveDocOutboxHandlers } from '@/lib/uploads/server/live-doc-outbox' const mockDownloadFile = storageServiceMockFns.mockDownloadFile @@ -35,7 +35,7 @@ function context(): OutboxEventContext { } function handler() { - const registered = workspaceFileLiveDocOutboxHandlers[WORKSPACE_FILE_LIVE_DOC_OUTBOX_EVENT] + const registered = fileLiveDocOutboxHandlers[WORKSPACE_FILE_LIVE_DOC_OUTBOX_EVENT] if (!registered) throw new Error('Workspace file live-document handler is not registered') return registered } @@ -64,7 +64,11 @@ describe('workspace file live-document outbox', () => { expect.objectContaining({ key: 'workspace/workspace-1/file.md', context: 'workspace' }) ) expect(mockApplyEditToLiveFileDoc).toHaveBeenCalledWith( - 'file-1', + { + fileId: 'file-1', + version: VERSION.getTime(), + owner: { entityType: 'workspace', entityId: 'workspace-1' }, + }, '# Durable content', { version: VERSION.getTime() }, expect.any(AbortSignal) @@ -125,7 +129,11 @@ describe('workspace file live-document outbox', () => { expect(mockDownloadFile).not.toHaveBeenCalled() expect(mockApplyEditToLiveFileDoc).not.toHaveBeenCalled() expect(mockInvalidateLiveFileDoc).toHaveBeenCalledWith( - 'file-1', + { + fileId: 'file-1', + version: VERSION.getTime(), + owner: { entityType: 'workspace', entityId: 'workspace-1' }, + }, VERSION.getTime(), expect.any(AbortSignal) ) @@ -147,7 +155,11 @@ describe('workspace file live-document outbox', () => { expect(mockDownloadFile).not.toHaveBeenCalled() expect(mockApplyEditToLiveFileDoc).not.toHaveBeenCalled() expect(mockInvalidateLiveFileDoc).toHaveBeenCalledWith( - 'file-1', + { + fileId: 'file-1', + version: VERSION.getTime(), + owner: { entityType: 'workspace', entityId: 'workspace-1' }, + }, VERSION.getTime(), expect.any(AbortSignal) ) @@ -170,7 +182,11 @@ describe('workspace file live-document outbox', () => { expect(mockDownloadFile).not.toHaveBeenCalled() expect(mockApplyEditToLiveFileDoc).not.toHaveBeenCalled() expect(mockInvalidateLiveFileDoc).toHaveBeenCalledWith( - 'file-1', + { + fileId: 'file-1', + version: VERSION.getTime(), + owner: { entityType: 'workspace', entityId: 'workspace-1' }, + }, latestVersion, expect.any(AbortSignal) ) diff --git a/apps/sim/lib/uploads/contexts/workspace/workspace-file-live-doc-outbox.ts b/apps/sim/lib/uploads/server/live-doc-outbox.ts similarity index 60% rename from apps/sim/lib/uploads/contexts/workspace/workspace-file-live-doc-outbox.ts rename to apps/sim/lib/uploads/server/live-doc-outbox.ts index 287f8bb8ec5..811de7db861 100644 --- a/apps/sim/lib/uploads/contexts/workspace/workspace-file-live-doc-outbox.ts +++ b/apps/sim/lib/uploads/server/live-doc-outbox.ts @@ -1,5 +1,6 @@ import { db } from '@sim/db' import { workspaceFiles } from '@sim/db/schema' +import { parseFileDocTarget } from '@sim/realtime-protocol/file-doc-target' import { isRecordLike } from '@sim/utils/object' import { PASTE_LIMITS } from '@sim/utils/paste' import { and, eq, isNull } from 'drizzle-orm' @@ -14,16 +15,12 @@ import { applyEditToLiveFileDoc, invalidateLiveFileDoc } from '@/lib/realtime/no import { WORKSPACE_FILE_LIVE_DOC_OUTBOX_EVENT } from '@/lib/uploads/contexts/workspace/file-outbox-events' import { downloadFile } from '@/lib/uploads/core/storage-service' import { isMarkdownFile } from '@/lib/uploads/utils/file-utils' -import { - type FileOwnerAdapters, - requireFileOwnerAdapter, -} from '@/lib/workspace-files/owner-adapters' import type { EditableFileOwner } from '@/lib/workspace-files/ownership' import { fileOwnerCondition } from '@/lib/workspace-files/ownership-query' -type WorkspaceFileLiveDocPayload = { fileId: string; version: number } & ( +type FileLiveDocPayload = { fileId: string; version: number } & ( | { workspaceId: string; owner?: never } - | { owner: { entityType: 'project'; entityId: string }; workspaceId?: never } + | { owner: EditableFileOwner; workspaceId?: never } ) interface ParsedLiveDocPayload { @@ -32,51 +29,17 @@ interface ParsedLiveDocPayload { owner: EditableFileOwner } -interface LiveDocDelivery { - invalidate(target: ParsedLiveDocPayload, version: number, signal: AbortSignal): Promise - merge( - target: ParsedLiveDocPayload, - markdown: string, - signal: AbortSignal - ): ReturnType -} - -const LIVE_DOC_DELIVERY: FileOwnerAdapters = { - workspace: { - invalidate: (target, version, signal) => invalidateLiveFileDoc(target.fileId, version, signal), - merge: (target, markdown, signal) => - applyEditToLiveFileDoc(target.fileId, markdown, { version: target.version }, signal), - }, - project: { - invalidate: (target, version, signal) => - invalidateLiveFileDoc(target.fileId, version, signal, { - entityType: 'project', - entityId: target.owner.entityId, - }), - merge: (target, markdown, signal) => - applyEditToLiveFileDoc(target.fileId, markdown, { version: target.version }, signal, { - entityType: 'project', - entityId: target.owner.entityId, - }), - }, -} - function parsePayload(payload: unknown): ParsedLiveDocPayload { if (!isRecordLike(payload)) { throw new Error('Workspace file live-document outbox payload must be an object') } - const candidate = payload as Partial + const candidate = payload as Partial let owner: EditableFileOwner if (candidate.owner !== undefined) { - if ( - candidate.workspaceId !== undefined || - !isRecordLike(candidate.owner) || - candidate.owner.entityType !== 'project' || - typeof candidate.owner.entityId !== 'string' || - !candidate.owner.entityId - ) - throw new Error('Invalid Project live-document owner') - owner = { entityType: 'project', entityId: candidate.owner.entityId } + const target = parseFileDocTarget(candidate) + if (candidate.workspaceId !== undefined || !target?.owner) + throw new Error('Invalid live-document owner') + owner = target.owner } else { if (typeof candidate.workspaceId !== 'string' || !candidate.workspaceId) throw new Error('Workspace file live-document outbox payload is missing workspaceId') @@ -95,7 +58,7 @@ function parsePayload(payload: unknown): ParsedLiveDocPayload { return { fileId: candidate.fileId, version: candidate.version, owner } } -const reconcileWorkspaceFileLiveDoc: OutboxHandler = async (rawPayload, context) => { +const reconcileFileLiveDoc: OutboxHandler = async (rawPayload, context) => { const payload = parsePayload(rawPayload) context.signal.throwIfAborted() const [file] = await db @@ -117,7 +80,6 @@ const reconcileWorkspaceFileLiveDoc: OutboxHandler = async (rawPayload, .limit(1) if (!file) return - const delivery = requireFileOwnerAdapter(LIVE_DOC_DELIVERY, payload.owner) const currentVersion = file.contentUpdatedAt.getTime() if (currentVersion < payload.version) { throw new Error('Workspace file live-document reconciliation is ahead of durable content') @@ -128,7 +90,7 @@ const reconcileWorkspaceFileLiveDoc: OutboxHandler = async (rawPayload, file.sizeBytes > PASTE_LIMITS.RICH_MARKDOWN_BYTES ) { /** Later binary writes do not enqueue reconciliation, so retire the latest unsupported version. */ - await delivery.invalidate(payload, currentVersion, context.signal) + await invalidateLiveFileDoc(payload, currentVersion, context.signal) return } if (currentVersion > payload.version) return @@ -140,25 +102,30 @@ const reconcileWorkspaceFileLiveDoc: OutboxHandler = async (rawPayload, signal: context.signal, }) context.signal.throwIfAborted() - const result = await delivery.merge(payload, content.toString('utf-8'), context.signal) + const result = await applyEditToLiveFileDoc( + payload, + content.toString('utf-8'), + { version: payload.version }, + context.signal + ) if (result.status === 'merge-unavailable') { return deferOutboxHandler('Live document merge slot is temporarily unavailable') } } -export const workspaceFileLiveDocOutboxHandlers = { - [WORKSPACE_FILE_LIVE_DOC_OUTBOX_EVENT]: reconcileWorkspaceFileLiveDoc, +export const fileLiveDocOutboxHandlers = { + [WORKSPACE_FILE_LIVE_DOC_OUTBOX_EVENT]: reconcileFileLiveDoc, } satisfies OutboxHandlerRegistry /** Enqueues live-document reconciliation in the same transaction as the durable file version. */ -export function enqueueWorkspaceFileLiveDocReconciliation( +export function enqueueFileLiveDocReconciliation( executor: Pick, - payload: WorkspaceFileLiveDocPayload + payload: FileLiveDocPayload ): Promise { return enqueueOutboxEvent(executor, WORKSPACE_FILE_LIVE_DOC_OUTBOX_EVENT, payload) } /** Attempts a newly committed reconciliation immediately; the outbox worker owns retries. */ -export function processWorkspaceFileLiveDocReconciliationNow(eventId: string) { - return processOutboxEventById(eventId, workspaceFileLiveDocOutboxHandlers) +export function processFileLiveDocReconciliationNow(eventId: string) { + return processOutboxEventById(eventId, fileLiveDocOutboxHandlers) } diff --git a/apps/sim/lib/uploads/server/markdown-export.ts b/apps/sim/lib/uploads/server/markdown-export.ts index 7c2f42a7dd4..d6494dabd44 100644 --- a/apps/sim/lib/uploads/server/markdown-export.ts +++ b/apps/sim/lib/uploads/server/markdown-export.ts @@ -10,10 +10,14 @@ import remarkParse from 'remark-parse' import remarkStringify from 'remark-stringify' import { unified } from 'unified' import { MATERIALIZE_CONCURRENCY, mapWithConcurrency } from '@/lib/core/utils/concurrency' +import { assertKnownSizeWithinLimit, isPayloadSizeLimitError } from '@/lib/core/utils/stream-limits' import type { StorageContext } from '@/lib/uploads/config' import { downloadFile } from '@/lib/uploads/core/storage-service' +import { bufferZipWithinLimit } from '@/lib/uploads/server/zip' import { extractEmbeddedFileRef } from '@/lib/uploads/utils/embedded-image-ref' import { formatFileSize } from '@/lib/uploads/utils/file-utils' +import { safeZipLeafName } from '@/lib/uploads/zip-entry-path' +import type { EditableFileOwner } from '@/lib/workspace-files/ownership' import { splitFrontmatter } from '@/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/markdown-fidelity' const logger = createLogger('MarkdownExport') @@ -21,7 +25,7 @@ const logger = createLogger('MarkdownExport') export const MAX_EXPORT_TOTAL_BYTES = 250 * 1024 * 1024 /** Larger documents remain available as exact Markdown without allocating a syntax tree. */ export const MAX_EXPORT_MARKDOWN_PARSE_BYTES = 10 * 1024 * 1024 -const MAX_EXPORT_ASSET_BYTES = 25 * 1024 * 1024 +export const MAX_EXPORT_ASSET_BYTES = 25 * 1024 * 1024 const markdownProcessor = unified() .use(remarkParse) .use(remarkGfm) @@ -29,7 +33,11 @@ const markdownProcessor = unified() const markdownLexer = new Marked() /** Source ranges keep unrelated links, definitions, code, and whitespace byte-for-byte intact. */ -function rewriteImageSources(source: string, filenames: ReadonlyMap): string { +function rewriteImageSources( + source: string, + filenames: ReadonlyMap, + owner?: EditableFileOwner +): string { const { frontmatter, body: content } = splitFrontmatter(source) const root = markdownProcessor.parse(content) const definitions = new Map() @@ -45,8 +53,8 @@ function rewriteImageSources(source: string, filenames: ReadonlyMap { - const ref = extractEmbeddedFileRef(src) - const filename = ref && 'fileId' in ref ? filenames.get(ref.fileId) : undefined + const ref = extractEmbeddedFileRef(src, owner) + const filename = ref ? filenames.get('fileId' in ref ? ref.fileId : ref.key) : undefined return filename === undefined ? null : `./assets/${encodeURIComponent(filename)}` } const replacements: Array<{ start: number; end: number; value: string }> = [] @@ -160,6 +168,8 @@ export interface MarkdownExportAsset { context: StorageContext originalName: string size: number + /** Prepared authorized bytes let a compound export fence every asset after external IO. */ + buffer?: Buffer } export interface MarkdownExportResult { @@ -180,10 +190,11 @@ export class MarkdownExportSizeError extends Error { } function safeFilename(name: string): string { - return path + const filename = path .basename(name) .replace(/["\\]/g, '_') .replace(/[\r\n\t]/g, '') + return !filename || filename === '.' || filename === '..' ? safeZipLeafName(name) : filename } function deduplicatedFilename(preferred: string, existing: Set, imageId: string): string { @@ -203,10 +214,12 @@ export async function createMarkdownExport({ content, fileName, assets, + owner, }: { content: Buffer fileName: string assets: readonly MarkdownExportAsset[] + owner?: EditableFileOwner }): Promise { const plainMarkdown: MarkdownExportResult = { buffer: content, @@ -226,11 +239,14 @@ export async function createMarkdownExport({ if (overflowBytes !== undefined) return null let buffer: Buffer try { - buffer = await downloadFile({ - key: asset.key, - context: asset.context, - maxBytes: MAX_EXPORT_ASSET_BYTES, - }) + buffer = + asset.buffer ?? + (await downloadFile({ + key: asset.key, + context: asset.context, + maxBytes: MAX_EXPORT_ASSET_BYTES, + })) + assertKnownSizeWithinLimit(buffer.length, MAX_EXPORT_ASSET_BYTES, 'Markdown export asset') } catch (error) { logger.warn('Failed to fetch asset for export', { imageId: asset.imageId, @@ -265,16 +281,27 @@ export async function createMarkdownExport({ const markdown = rewriteImageSources( content.toString('utf-8'), - new Map([...assetMap].map(([imageId, asset]) => [imageId, asset.filename])) + new Map([...assetMap].map(([imageId, asset]) => [imageId, asset.filename])), + owner ) + const exportedBytes = retainedBytes - content.length + Buffer.byteLength(markdown, 'utf-8') + if (exportedBytes > MAX_EXPORT_TOTAL_BYTES) throw new MarkdownExportSizeError(exportedBytes) const zip = new JSZip() zip.file(safeFilename(fileName), markdown) const assetsFolder = zip.folder('assets')! for (const { filename, buffer } of assetMap.values()) assetsFolder.file(filename, buffer) + let buffer: Buffer + try { + buffer = await bufferZipWithinLimit(zip, MAX_EXPORT_TOTAL_BYTES) + } catch (error) { + if (isPayloadSizeLimitError(error)) + throw new MarkdownExportSizeError(error.observedBytes ?? MAX_EXPORT_TOTAL_BYTES + 1) + throw error + } return { - buffer: await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }), + buffer, fileName: safeFilename(`${fileName.replace(/\.[^.]+$/, '')}.zip`), contentType: 'application/zip', format: 'zip', diff --git a/apps/sim/lib/uploads/server/zip.ts b/apps/sim/lib/uploads/server/zip.ts new file mode 100644 index 00000000000..5b58f1ec46a --- /dev/null +++ b/apps/sim/lib/uploads/server/zip.ts @@ -0,0 +1,10 @@ +import type JSZip from 'jszip' +import { readNodeStreamToBufferWithLimit } from '@/lib/core/utils/stream-limits' + +/** Bounds emitted ZIP bytes while compression runs, before allocating the final output buffer. */ +export function bufferZipWithinLimit(zip: JSZip, maxBytes: number): Promise { + return readNodeStreamToBufferWithLimit( + zip.generateNodeStream({ streamFiles: true, compression: 'DEFLATE' }), + { maxBytes, label: 'file archive' } + ) +} diff --git a/apps/sim/lib/uploads/upload-session/project-file-binding.ts b/apps/sim/lib/uploads/upload-session/project-file-binding.ts new file mode 100644 index 00000000000..747015a1912 --- /dev/null +++ b/apps/sim/lib/uploads/upload-session/project-file-binding.ts @@ -0,0 +1,86 @@ +import { type Principal, requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { isRecordLike } from '@sim/utils/object' +import { requireResourceDelegation } from '@/lib/core/application/resource-delegation' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { PROJECT_FILE_DELEGATION_TTL_MS } from '@/lib/projects/files/application/operations' +import { PROJECT_FILE_UPLOAD_BINDING_KEY } from '@/lib/uploads/upload-session/types' + +function credential(principal: Principal, projectId: string) { + switch (principal.kind) { + case 'session': + return { kind: principal.kind, userId: principal.userId, sessionId: principal.sessionId } + case 'personal_api_key': + return { kind: principal.kind, userId: principal.userId, keyId: principal.keyId } + case 'oauth_access_token': + return { kind: principal.kind, userId: principal.userId, clientId: principal.clientId } + case 'resource_delegated': { + requireResourceDelegation(principal, { + audience: 'sim:project-files', + services: ['copilot'], + scope: { kind: 'entity', entityType: 'project', entityId: projectId }, + maxTtlMs: PROJECT_FILE_DELEGATION_TTL_MS, + }) + if (principal.serviceId !== 'copilot') break + return { + kind: principal.kind, + serviceId: principal.serviceId, + subjectUserId: principal.subjectUserId, + audience: principal.audience, + invocation: { ...principal.invocation }, + } + } + } + throw new OrchestrationError('forbidden', 'This principal cannot control Project file uploads') +} + +/** A receipt binds the actual credential and invocation; regenerated delegation IDs may retry. */ +export function createProjectFileUploadBinding(principal: Principal, projectId: string) { + return { + version: 1 as const, + entityType: 'project' as const, + entityId: projectId, + userId: requirePrincipalSubjectUserId(principal), + principal: credential(principal, projectId), + } +} + +interface ProjectUploadSession { + purpose: string + workspaceId: string | null + userId: string + metadata: Record +} + +/** Project receipts have no legacy uploader-only authorization fallback. */ +export function assertProjectFileUploadBinding( + session: ProjectUploadSession, + principal: Principal, + projectId?: string +): string { + const binding = session.metadata[PROJECT_FILE_UPLOAD_BINDING_KEY] + if ( + session.purpose !== 'project_file' || + session.workspaceId !== null || + !isRecordLike(binding) || + binding.version !== 1 || + binding.entityType !== 'project' || + typeof binding.entityId !== 'string' || + !binding.entityId || + binding.userId !== session.userId || + (projectId !== undefined && projectId !== binding.entityId) || + !isRecordLike(binding.principal) + ) + throw new OrchestrationError('not_found', 'Upload session not found') + const expected = createProjectFileUploadBinding(principal, binding.entityId) + const actual = binding.principal + const matches = + expected.userId === binding.userId && + Object.entries(expected.principal).every(([key, value]) => { + if (key !== 'invocation') return actual[key] === value + const invocation = actual.invocation + if (!isRecordLike(invocation) || !isRecordLike(value)) return false + return Object.entries(value).every(([field, entry]) => invocation[field] === entry) + }) + if (!matches) throw new OrchestrationError('not_found', 'Upload session not found') + return binding.entityId +} diff --git a/apps/sim/lib/uploads/upload-session/project-file-provenance.ts b/apps/sim/lib/uploads/upload-session/project-file-provenance.ts new file mode 100644 index 00000000000..372315b79f1 --- /dev/null +++ b/apps/sim/lib/uploads/upload-session/project-file-provenance.ts @@ -0,0 +1,40 @@ +import { isRecordLike } from '@sim/utils/object' +import type { WorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' +import { + parseWorkspaceFileSecretProvenance, + type WorkspaceFileUploadSource, +} from '@/lib/uploads/upload-session/workspace-file-provenance' + +export const PROJECT_FILE_UPLOAD_PROVENANCE_KEY = 'projectFileSecretProvenance' + +/** Classification is bound to the shared owner and sealed once when completion claims its lease. */ +export function bindProjectFileUploadProvenance( + projectId: string, + source: WorkspaceFileUploadSource +) { + return { + version: 1, + projectId, + provenance: + source === 'pending' + ? { status: 'unknown' as const } + : parseWorkspaceFileSecretProvenance(source), + ...(source === 'pending' ? { pending: true } : {}), + } +} + +/** Malformed or missing Project classification remains unknown, never an implicit clean upload. */ +export function readProjectFileUploadProvenance( + metadata: Record, + projectId: string +): WorkspaceFileSecretProvenance { + const binding = metadata[PROJECT_FILE_UPLOAD_PROVENANCE_KEY] + if ( + !isRecordLike(binding) || + binding.version !== 1 || + binding.projectId !== projectId || + Object.hasOwn(binding, 'pending') + ) + return { status: 'unknown' } + return parseWorkspaceFileSecretProvenance(binding.provenance) +} diff --git a/apps/sim/lib/uploads/upload-session/service.ts b/apps/sim/lib/uploads/upload-session/service.ts index 02b4ae48143..57d3ed45fa0 100644 --- a/apps/sim/lib/uploads/upload-session/service.ts +++ b/apps/sim/lib/uploads/upload-session/service.ts @@ -11,12 +11,14 @@ import { sha256Hex } from '@sim/security/hash' import { generateSecureToken } from '@sim/security/tokens' import { getErrorMessage } from '@sim/utils/errors' import { generateId } from '@sim/utils/id' +import { isRecordLike } from '@sim/utils/object' import { and, asc, eq, inArray, isNull, lt, or, sql } from 'drizzle-orm' import { checkStorageQuotaForBillingContext, resolveStorageBillingContext, } from '@/lib/billing/storage' import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbOrTx, DbTransaction } from '@/lib/db/types' import { generateUniqueExecutionFileKey } from '@/lib/uploads/contexts/execution/utils' import { generateKnowledgeBaseFileKey } from '@/lib/uploads/contexts/knowledge-base/knowledge-base-file-manager' import { assertOrganizationAttachmentControlBinding } from '@/lib/uploads/contexts/organization-assistant/binding' @@ -35,6 +37,14 @@ import { type StorageContext, } from '@/lib/uploads/shared/types' import { maybeCleanupLocalUploadArtifacts } from '@/lib/uploads/upload-session/cleanup' +import { + assertProjectFileUploadBinding, + createProjectFileUploadBinding, +} from '@/lib/uploads/upload-session/project-file-binding' +import { + bindProjectFileUploadProvenance, + PROJECT_FILE_UPLOAD_PROVENANCE_KEY, +} from '@/lib/uploads/upload-session/project-file-provenance' import { abortProviderUpload, type CompletedUploadPart, @@ -48,11 +58,12 @@ import { type UploadPartUrl, uploadStorageProvider, } from '@/lib/uploads/upload-session/provider' -import type { - UploadSessionPurpose, - UploadSessionStatus, - UploadStorageProvider, - UploadTransferMethod, +import { + PROJECT_FILE_UPLOAD_BINDING_KEY, + type UploadSessionPurpose, + type UploadSessionStatus, + type UploadStorageProvider, + type UploadTransferMethod, } from '@/lib/uploads/upload-session/types' import { bindWorkspaceFileUploadProvenance, @@ -79,7 +90,6 @@ export const UPLOAD_SESSION_ASSET_MAX_BYTES = 5 * 1024 * 1024 const PROCESSING_LEASE_MS = 5 * 60 * 1000 const CLEANUP_BATCH_SIZE = 100 const TERMINAL_RETENTION_MS = 7 * 24 * 60 * 60 * 1000 -const cleanupDb = dbFor('cleanup') export type { UploadSessionPurpose, UploadSessionStatus, UploadTransferMethod } @@ -201,6 +211,13 @@ interface CreateUploadSessionBaseParams { export type CreateUploadSessionParams = CreateUploadSessionBaseParams & ( + | { + purpose: 'project_file' + projectId: string + workspaceId?: never + principal: Principal + secretProvenance?: WorkspaceFileUploadSource + } | { purpose: 'workspace_file' workspaceId: string @@ -241,6 +258,101 @@ export type CreateUploadSessionParams = CreateUploadSessionBaseParams & type UploadSessionRow = typeof uploadSession.$inferSelect +function verifiedCompletionObject(session: UploadSessionRecord, leaseId: string) { + const binding = session.metadata[PROJECT_FILE_UPLOAD_BINDING_KEY] + return Object.freeze({ + leaseId, + metadata: JSON.stringify(session.metadata), + object: Object.freeze({ + id: session.id, + purpose: session.purpose, + workspaceId: session.workspaceId, + userId: session.userId, + finalKey: session.finalKey, + storageContext: session.storageContext, + storageProvider: session.storageProvider, + providerUploadId: session.providerUploadId, + providerObjectVersion: session.providerObjectVersion, + fileName: session.fileName, + contentType: session.contentType, + fileSize: session.fileSize, + }), + projectId: + isRecordLike(binding) && typeof binding.entityId === 'string' ? binding.entityId : null, + }) +} + +const verifiedUploadObjects = new WeakMap< + UploadSessionRecord, + ReturnType +>() + +/** Only the live finalizer receives proof of provider-verified bytes; serialization drops it. */ +export function getVerifiedUploadSessionObject(session: UploadSessionRecord) { + const proof = verifiedUploadObjects.get(session) + if (!proof) + throw new UploadSessionError('conflict', 'Upload object has no active verification proof') + return { ...proof.object, projectId: proof.projectId } +} + +/** Locks the exact completion lease before its metadata, billing, and receipt commit together. */ +export async function lockUploadSessionRegistrationInTx( + tx: DbTransaction, + session: UploadSessionRecord +) { + const proof = verifiedUploadObjects.get(session) + if (!proof) + throw new UploadSessionError('conflict', 'Upload object has no active verification proof') + const [row] = await tx + .select() + .from(uploadSession) + .where(eq(uploadSession.id, proof.object.id)) + .for('update') + if ( + !row || + row.status !== 'finalizing' || + row.completedFileId !== null || + row.processingLeaseId !== proof.leaseId || + !row.processingLeaseExpiresAt || + row.processingLeaseExpiresAt.getTime() <= Date.now() || + JSON.stringify(row.metadata) !== proof.metadata || + !Object.entries(proof.object).every( + ([key, value]) => row[key as keyof UploadSessionRow] === value + ) + ) + throw new UploadSessionError('conflict', 'Upload registration lease was lost') + let registered = false + return { + async registerFile(fileId: string) { + if (registered) throw new Error('Upload session registration was already recorded') + const rows = await tx + .update(uploadSession) + .set({ completedFileId: fileId, updatedAt: new Date() }) + .where( + and( + eq(uploadSession.id, row.id), + eq(uploadSession.status, 'finalizing'), + eq(uploadSession.processingLeaseId, proof.leaseId), + isNull(uploadSession.completedFileId) + ) + ) + .returning({ id: uploadSession.id }) + if (rows.length !== 1) + throw new UploadSessionError('conflict', 'Upload registration lease was lost') + registered = true + }, + } +} + +function projectCompletionProvenance( + session: UploadSessionRecord, + source?: WorkspaceFileSecretProvenance +) { + const binding = session.metadata[PROJECT_FILE_UPLOAD_BINDING_KEY] + if (!isRecordLike(binding) || typeof binding.entityId !== 'string') throw uploadNotFound() + return bindProjectFileUploadProvenance(binding.entityId, source ?? { status: 'unknown' }) +} + export async function createUploadSession( params: CreateUploadSessionParams ): Promise { @@ -249,6 +361,19 @@ export async function createUploadSession( const uploadToken = generateSecureToken(32) const workspaceId = params.purpose === 'profile_picture' ? null : (params.workspaceId ?? null) const metadata = { ...(params.metadata ?? {}) } + if (params.purpose === 'project_file') { + const binding = createProjectFileUploadBinding(params.principal, params.projectId) + if (binding.userId !== params.userId) + throw new UploadSessionError('forbidden', 'Project uploads require the actual uploading user') + metadata[PROJECT_FILE_UPLOAD_BINDING_KEY] = binding + metadata[PROJECT_FILE_UPLOAD_PROVENANCE_KEY] = bindProjectFileUploadProvenance( + params.projectId, + params.secretProvenance ?? + (params.principal.kind === 'resource_delegated' + ? { status: 'unknown' } + : { status: 'exact', entries: [] }) + ) + } if (params.purpose === 'organization_logo') { if (params.principal.kind !== 'session' || params.principal.userId !== params.userId) { throw new UploadSessionError('forbidden', 'Organization logos require the uploading session') @@ -429,8 +554,9 @@ export async function getOwnedUploadSession(params: { workflowId?: string executionId?: string principal?: Principal + executor?: Pick }): Promise { - const [row] = await db + const [row] = await (params.executor ?? db) .select() .from(uploadSession) .where(eq(uploadSession.id, params.uploadId)) @@ -501,6 +627,11 @@ export function createUploadSessionAuthBinding( options: { executorDelegationAudience?: string; copilotDelegationAudience?: string } = {} ): UploadSessionAuthBinding { switch (principal.kind) { + case 'resource_delegated': + throw new UploadSessionError( + 'forbidden', + 'Resource delegation cannot create workspace uploads' + ) case 'slack_app': case 'slack_installation': throw new UploadSessionError('forbidden', 'Slack installations cannot create uploads') @@ -604,8 +735,10 @@ export function assertUploadSessionAuthBinding( session: UploadSessionRecord, principal: Principal ): void { - if (session.purpose === 'project_file') - throw new UploadSessionError('forbidden', 'Project upload control is unavailable') + if (session.purpose === 'project_file') { + assertProjectFileUploadBinding(session, principal) + return + } if (session.purpose === 'organization_logo') { assertOrganizationLogoControlBinding(session, principal) return @@ -764,12 +897,14 @@ export async function completeUploadSession(params: { recoveringFinalization ? ['finalizing'] : ['uploading', 'completing'], recoveringFinalization ? 'finalizing' : 'completing', db, - params.session.purpose === 'workspace_file' && params.session.workspaceId - ? bindWorkspaceFileUploadProvenance( - params.session.workspaceId, - params.secretProvenance ?? { status: 'unknown' } - ) - : undefined + params.session.purpose === 'project_file' + ? projectCompletionProvenance(params.session, params.secretProvenance) + : params.session.purpose === 'workspace_file' && params.session.workspaceId + ? bindWorkspaceFileUploadProvenance( + params.session.workspaceId, + params.secretProvenance ?? { status: 'unknown' } + ) + : undefined )), uploadToken: params.session.uploadToken, } @@ -848,7 +983,13 @@ export async function completeUploadSession(params: { requireRow(finalizingRow, 'Upload completion lease was lost'), claimed.uploadToken ) - const finalized = await params.finalize(finalizing) + verifiedUploadObjects.set(finalizing, verifiedCompletionObject(finalizing, leaseId)) + let finalized: Awaited> + try { + finalized = await params.finalize(finalizing) + } finally { + verifiedUploadObjects.delete(finalizing) + } const completed = await markUploadSessionCompleted( claimed, leaseId, @@ -973,6 +1114,7 @@ export async function cleanupExpiredUploadSessions(): Promise<{ failed: number purged: number }> { + const cleanupDb = dbFor('cleanup') const now = new Date() const candidates = await cleanupDb .select() @@ -1203,9 +1345,15 @@ async function claimSession( statuses: UploadSessionStatus[], nextStatus: UploadSessionStatus = 'completing', database: typeof db = db, - fileProvenance?: ReturnType + fileProvenance?: + | ReturnType + | ReturnType ): Promise { const now = new Date() + const provenanceKey = + fileProvenance && 'projectId' in fileProvenance + ? PROJECT_FILE_UPLOAD_PROVENANCE_KEY + : WORKSPACE_FILE_UPLOAD_PROVENANCE_KEY const [row] = await database .update(uploadSession) .set({ @@ -1217,8 +1365,8 @@ async function claimSession( /** Seal once with the completion lease; recovery must retain the first claim's evidence. */ ...(fileProvenance ? { - metadata: sql`CASE WHEN ${uploadSession.metadata}->${WORKSPACE_FILE_UPLOAD_PROVENANCE_KEY}->>'pending' = 'true' - THEN jsonb_set(${uploadSession.metadata}, ARRAY[${WORKSPACE_FILE_UPLOAD_PROVENANCE_KEY}]::text[], ${JSON.stringify(fileProvenance)}::jsonb) + metadata: sql`CASE WHEN ${uploadSession.metadata}->${provenanceKey}->>'pending' = 'true' + THEN jsonb_set(${uploadSession.metadata}, ARRAY[${provenanceKey}]::text[], ${JSON.stringify(fileProvenance)}::jsonb) ELSE ${uploadSession.metadata} END`, } : {}), @@ -1361,7 +1509,7 @@ function validateFile(params: CreateUploadSessionParams): void { if (!params.contentType.trim()) { throw new UploadSessionError('validation', 'contentType must not be empty') } - const minimum = params.purpose === 'workspace_file' ? 0 : 1 + const minimum = params.purpose === 'workspace_file' || params.purpose === 'project_file' ? 0 : 1 if (!Number.isSafeInteger(params.fileSize) || params.fileSize < minimum) { const range = minimum === 0 ? 'a non-negative integer' : 'a positive integer' throw new UploadSessionError('validation', `fileSize must be ${range}`) @@ -1395,12 +1543,15 @@ function validateFile(params: CreateUploadSessionParams): void { } if ( params.purpose !== 'profile_picture' && + params.purpose !== 'project_file' && params.purpose !== 'organization_logo' && !organizationAttachment && !params.workspaceId?.trim() ) { throw new UploadSessionError('validation', 'workspaceId must not be empty') } + if (params.purpose === 'project_file' && !params.projectId.trim()) + throw new UploadSessionError('validation', 'projectId must not be empty') if (params.purpose === 'knowledge_document' && !params.knowledgeBaseId.trim()) { throw new UploadSessionError('validation', 'knowledgeBaseId must not be empty') } @@ -1452,6 +1603,11 @@ function resolveUploadStorage( id: string ): { storageContext: StorageContext; finalKey: string } { switch (params.purpose) { + case 'project_file': + return { + storageContext: 'project', + finalKey: `project/${params.projectId}/${buildStorageKeySegment(`${id}-`, params.fileName)}`, + } case 'workspace_file': return { storageContext: 'workspace', diff --git a/apps/sim/lib/uploads/utils/embedded-image-ref.ts b/apps/sim/lib/uploads/utils/embedded-image-ref.ts index f49a97b1269..4df92926927 100644 --- a/apps/sim/lib/uploads/utils/embedded-image-ref.ts +++ b/apps/sim/lib/uploads/utils/embedded-image-ref.ts @@ -1,3 +1,6 @@ +import { parseSimFileReference } from '@/lib/uploads/utils/file-reference' +import type { EditableFileOwner } from '@/lib/workspace-files/ownership' + /** * The grammar of a markup-embedded workspace image reference: how one `src` maps to the workspace * file it points at ({@link extractEmbeddedFileRef}), and how to find the `src` values in a raw HTML @@ -25,21 +28,37 @@ export function storedFileId(spelledId: string): string { } } +/** A rejected private reference must not fall back to its credential-bearing URL. */ +export type EmbeddedFileResolution = + | { kind: 'file'; reference: NonNullable } + | { kind: 'external' } + | { kind: 'rejected' } + /** - * Parse a single embed `src` into the workspace file it references, normalizing the spellings the - * editor and file agent produce: `/api/files/serve/` (incl. `s3/`/`blob/`/`gcs/` prefixes), `/api/files/view/`, - * and the in-app path `/workspace//files/`. Returns null for absolute, `data:`, or non-workspace - * URLs (e.g. public `profile-pictures/` assets), which render as-is. - * - * A key is percent-decoded — it is matched against stored keys. An id is returned exactly as it is - * spelled in the `src`, because the export bundler rewrites embeds by searching the document for - * that spelling; handing it a decoded id it cannot find would bundle an asset and leave the markdown - * pointing at the API URL, which renders as a broken image offline. + * Classifies embeds using one grammar for inline authorization and display. Absolute internal + * references require a caller-supplied trusted origin; without it they remain rejected for SSR. */ -export function extractEmbeddedFileRef(src: string): EmbeddedFileRef { +export function resolveEmbeddedFileRef( + src: string, + owner?: EditableFileOwner, + trustedOrigin?: string +): EmbeddedFileResolution { try { - const parsed = new URL(src, 'http://placeholder') - if (parsed.origin !== 'http://placeholder') return null + const value = src.trim() + if (value.startsWith('sim:file/')) { + if (!owner) return { kind: 'rejected' } + const reference = parseSimFileReference(value.slice('sim:file/'.length), owner) + return reference?.owner?.entityType === owner.entityType && + reference.owner.entityId === owner.entityId + ? { kind: 'file', reference: { fileId: reference.fileId } } + : { kind: 'rejected' } + } + const absolute = /^[a-z][a-z0-9+.-]*:/i.test(value) || value.startsWith('//') + const origin = trustedOrigin ? new URL(trustedOrigin).origin : undefined + const parsed = new URL(value, origin ?? 'http://placeholder') + if (absolute && origin && parsed.origin !== origin) return { kind: 'external' } + const resolved = (reference: NonNullable): EmbeddedFileResolution => + absolute && !origin ? { kind: 'rejected' } : { kind: 'file', reference } const segs = parsed.pathname.split('/') if (segs[1] === 'api' && segs[2] === 'files' && segs[3] === 'serve') { let keySegs = segs.slice(4) @@ -47,22 +66,57 @@ export function extractEmbeddedFileRef(src: string): EmbeddedFileRef { keySegs = keySegs.slice(1) } const raw = keySegs.join('/') - if (!raw) return null + if (!raw) return { kind: 'rejected' } const key = decodeURIComponent(raw) - return key.startsWith('workspace/') ? { key } : null + if (!key.startsWith('workspace/') && !key.startsWith('project/')) return { kind: 'external' } + return key.startsWith(owner ? `${owner.entityType}/${owner.entityId}/` : 'workspace/') + ? resolved({ key }) + : { kind: 'rejected' } + } + if (segs[1] === 'api' && segs[2] === 'files' && segs[3] === 'view') { + return segs[4] ? resolved({ fileId: segs[4] }) : { kind: 'rejected' } } - if (segs[1] === 'api' && segs[2] === 'files' && segs[3] === 'view' && segs[4]) { - return { fileId: segs[4] } + if (segs[1] === 'api' && segs[2] === 'projects' && segs[4] === 'files') { + return owner?.entityType === 'project' && + decodeURIComponent(segs[3]) === owner.entityId && + segs[5] && + segs[6] === 'content' && + segs.length === 7 + ? resolved({ fileId: segs[5] }) + : { kind: 'rejected' } } - if (segs[1] === 'workspace' && segs[3] === 'files' && segs[4]) { - return { fileId: segs[4] } + if (segs[1] === 'projects' && segs[3] === 'files') { + return owner?.entityType === 'project' && + decodeURIComponent(segs[2]) === owner.entityId && + segs[4] && + segs.length === 5 + ? resolved({ fileId: segs[4] }) + : { kind: 'rejected' } } - return null + if (segs[1] === 'workspace' && segs[3] === 'files') { + if ( + !segs[4] || + (owner && + (owner.entityType !== 'workspace' || decodeURIComponent(segs[2]) !== owner.entityId)) + ) + return { kind: 'rejected' } + return resolved({ fileId: segs[4] }) + } + return { kind: 'external' } } catch { - return null + return { kind: 'rejected' } } } +/** + * Returns stored-key or document-spelled ID references for existing scanners and exporters. + * Display code uses the full classification so a rejected internal reference cannot pass through. + */ +export function extractEmbeddedFileRef(src: string, owner?: EditableFileOwner): EmbeddedFileRef { + const result = resolveEmbeddedFileRef(src, owner) + return result.kind === 'file' ? result.reference : null +} + /** * Matches `` `src` attribute values: double-quoted, single-quoted, or (validly) unquoted per * the HTML spec — the browser's own clipboard serialization always quotes it, but other producers diff --git a/apps/sim/lib/uploads/utils/file-reference.ts b/apps/sim/lib/uploads/utils/file-reference.ts new file mode 100644 index 00000000000..784b8e605ed --- /dev/null +++ b/apps/sim/lib/uploads/utils/file-reference.ts @@ -0,0 +1,29 @@ +import type { EditableFileOwner } from '@/lib/workspace-files/ownership' + +/** Parses the ID, optional single owner, and fragment of a sim:file resource reference. */ +export function parseSimFileReference(reference: string, defaultOwner?: EditableFileOwner) { + const match = /^([^?#]+)(?:\?([^#]*))?(#.*)?$/.exec(reference) + if (!match) return null + let fileId: string + try { + fileId = decodeURIComponent(match[1]) + } catch { + return null + } + if (!/^[A-Za-z0-9_-]+$/.test(fileId)) return null + let owner = defaultOwner + if (match[2] !== undefined) { + const entries = [...new URLSearchParams(match[2])] + const entry = entries[0] + if (entries.length !== 1 || !entry) return null + const [entityType, entityId] = entry + if ( + (entityType !== 'workspace' && entityType !== 'project') || + !/^[A-Za-z0-9_-]+$/.test(entityId) + ) { + return null + } + owner = { entityType, entityId } + } + return { fileId, owner, fragment: match[3] ?? '' } +} diff --git a/apps/sim/lib/uploads/utils/file-utils.server.test.ts b/apps/sim/lib/uploads/utils/file-utils.server.test.ts index 920dbb2b757..f3259b0ea22 100644 --- a/apps/sim/lib/uploads/utils/file-utils.server.test.ts +++ b/apps/sim/lib/uploads/utils/file-utils.server.test.ts @@ -222,6 +222,7 @@ describe('servable page provenance', () => { buffer: Buffer.from('rendered image'), contentType: 'text/html', contributingFiles: [contributor], + dependsOnReferencedFiles: true, }) }) }) diff --git a/apps/sim/lib/uploads/utils/file-utils.server.ts b/apps/sim/lib/uploads/utils/file-utils.server.ts index f266129659a..04c493bcebe 100644 --- a/apps/sim/lib/uploads/utils/file-utils.server.ts +++ b/apps/sim/lib/uploads/utils/file-utils.server.ts @@ -412,6 +412,7 @@ export async function downloadFileFromStorage( * actually attach/upload, plus the content type that matches those bytes. */ export interface ServableFile { + dependsOnReferencedFiles?: boolean artifactKey?: string buffer: Buffer contentType: string @@ -475,6 +476,7 @@ export async function downloadServableFileFromStorage( buffer: rendered, contentType: 'text/html', contributingFiles: page.contributingFiles, + dependsOnReferencedFiles: true, } } } diff --git a/apps/sim/lib/uploads/utils/file-utils.ts b/apps/sim/lib/uploads/utils/file-utils.ts index 3582fb2079b..54e797918af 100644 --- a/apps/sim/lib/uploads/utils/file-utils.ts +++ b/apps/sim/lib/uploads/utils/file-utils.ts @@ -289,7 +289,7 @@ export function buildArchiveExtractGuidance(name: string): string { return `"${name}" is a .zip archive — its contents can't be read directly. Mount it into the chat sandbox with run_code (inputs.files: [{"path": "uploads/${name}", "sandboxPath": "/tmp/${name}"}]) and unzip it there; persist anything worth keeping with \`files upload @\`.` } -const EXTENSION_TO_MIME: Record = { +export const EXTENSION_TO_MIME: Readonly> = { jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png', diff --git a/apps/sim/lib/users/queries.ts b/apps/sim/lib/users/queries.ts index 969e9345b0f..73e5f7c3190 100644 --- a/apps/sim/lib/users/queries.ts +++ b/apps/sim/lib/users/queries.ts @@ -3,6 +3,7 @@ import { settings, user } from '@sim/db/schema' import { eq, inArray } from 'drizzle-orm' import type { UserSettingsApi } from '@/lib/api/contracts/user' import { normalizeStringArray } from '@/lib/core/utils/arrays' +import type { DbOrTx } from '@/lib/db/types' const MAX_USER_EMAIL_BATCH = 1000 @@ -116,14 +117,15 @@ export async function getUserEmailsByIds(userIds: readonly string[]): Promise> { const uniqueIds = Array.from(new Set(userIds)) if (uniqueIds.length === 0) return new Map() if (uniqueIds.length > MAX_USER_EMAIL_BATCH) { throw new Error(`Cannot resolve more than ${MAX_USER_EMAIL_BATCH} user emails at once`) } - const rows = await db + const rows = await executor .select({ id: user.id, email: user.email }) .from(user) .where(inArray(user.id, uniqueIds)) diff --git a/apps/sim/lib/workflows/application/run-workflow-from-copilot.ts b/apps/sim/lib/workflows/application/run-workflow-from-copilot.ts index 97300d598ef..182b7d81c5a 100644 --- a/apps/sim/lib/workflows/application/run-workflow-from-copilot.ts +++ b/apps/sim/lib/workflows/application/run-workflow-from-copilot.ts @@ -239,6 +239,7 @@ async function executeCopilotRun(params: { } }): Promise { if ( + params.principal.kind === 'resource_delegated' || params.principal.kind === 'organization_delegated' || params.principal.kind === 'slack_app' || params.principal.kind === 'slack_installation' || diff --git a/apps/sim/lib/workspace-files/api/archive-presenter.test.ts b/apps/sim/lib/workspace-files/api/archive-presenter.test.ts new file mode 100644 index 00000000000..6494023dccf --- /dev/null +++ b/apps/sim/lib/workspace-files/api/archive-presenter.test.ts @@ -0,0 +1,114 @@ +/** @vitest-environment node */ +import { Readable } from 'node:stream' +import { createDeferred } from '@sim/testing/helpers/deferred' +import { storageServiceMock, storageServiceMockFns } from '@sim/testing/mocks/storage-service.mock' +import { sleep } from '@sim/utils/helpers' +import JSZip from 'jszip' +import { describe, expect, it, vi } from 'vitest' +import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' + +vi.mock('@/lib/uploads/core/storage-service', () => storageServiceMock) + +import { presentWorkspaceFileArchive } from '@/lib/workspace-files/api/archive-presenter' + +function plan() { + const filesToZip: WorkspaceFileRecord[] = Array.from({ length: 3 }, (_, index) => ({ + id: `file-${index}`, + workspaceId: 'workspace', + name: `${index}.txt`, + key: `workspace/${index}`, + path: `/files/${index}`, + size: 131072, + type: 'text/plain', + uploadedBy: 'creator', + uploadedAt: new Date(0), + updatedAt: new Date(0), + })) + return { + filesToZip, + folderPaths: new Map(), + renderedDocuments: new Map(), + declaredBytes: 393216, + } +} + +describe('file archive resource lifetime', () => { + it('keeps only one storage stream open while preserving every entry', async () => { + let active = 0 + let peak = 0 + storageServiceMockFns.mockDownloadFileStream.mockImplementation(async () => { + active++ + peak = Math.max(peak, active) + const source = Readable.from( + (async function* () { + yield Buffer.alloc(65536, 'a') + await sleep(1) + yield Buffer.alloc(65536, 'b') + })() + ) + let released = false + const release = () => { + if (!released) { + released = true + active-- + } + } + source.once('end', release) + source.once('close', release) + return source + }) + const result = presentWorkspaceFileArchive(plan()) + const zip = await JSZip.loadAsync(await new Response(result.body).arrayBuffer()) + for (const name of ['0.txt', '1.txt', '2.txt']) { + expect(await zip.file(name)?.async('string')).toBe('a'.repeat(65536) + 'b'.repeat(65536)) + } + expect(peak).toBe(1) + expect(active).toBe(0) + }) + + it('rejects partial storage failure and closes the input', async () => { + const source = Readable.from( + (async function* () { + yield Buffer.from('partial') + throw new Error('Storage unavailable') + })() + ) + storageServiceMockFns.mockDownloadFileStream.mockResolvedValue(source) + const result = presentWorkspaceFileArchive(plan()) + await expect(new Response(result.body).arrayBuffer()).rejects.toThrow('Storage unavailable') + expect(source.destroyed).toBe(true) + }) + + it('closes a storage stream acquired after the client cancels', async () => { + const acquiring = createDeferred() + const acquired = createDeferred() + const source = new Readable({ read() {} }) + storageServiceMockFns.mockDownloadFileStream.mockImplementationOnce(() => { + acquiring.resolve() + return acquired.promise + }) + const reader = presentWorkspaceFileArchive(plan()).body.getReader() + try { + await acquiring.promise + await reader.cancel() + acquired.resolve(source) + await vi.waitFor(() => expect(source.destroyed).toBe(true)) + } finally { + acquired.resolve(source) + source.destroy() + } + }) + + it('closes the active source when the client cancels', async () => { + const source = new Readable({ + read() { + this.push(Buffer.alloc(65536)) + }, + }) + storageServiceMockFns.mockDownloadFileStream.mockResolvedValue(source) + const reader = presentWorkspaceFileArchive(plan()).body.getReader() + await reader.read() + await reader.cancel() + await vi.waitFor(() => expect(source.destroyed).toBe(true)) + }) +}) diff --git a/apps/sim/lib/workspace-files/api/archive-presenter.ts b/apps/sim/lib/workspace-files/api/archive-presenter.ts new file mode 100644 index 00000000000..a80faa070f7 --- /dev/null +++ b/apps/sim/lib/workspace-files/api/archive-presenter.ts @@ -0,0 +1,89 @@ +import { once } from 'node:events' +import { addAbortSignal, Readable } from 'node:stream' +import { finished } from 'node:stream/promises' +import { createLogger } from '@sim/logger' +import { toError } from '@sim/utils/errors' +import { ZipArchive } from 'archiver' +import { nodeReadableToWebStream } from '@/lib/core/utils/node-stream' +import { downloadFileStream } from '@/lib/uploads/core/storage-service' +import { FILE_CACHE_CONTROL, fileDeliveryHeaders } from '@/lib/uploads/server/delivery' +import { buildZipEntryPaths } from '@/lib/uploads/zip-entry-path' +import type { DownloadWorkspaceFileItemsResult } from '@/lib/workspace-files/application/download-workspace-file-items' + +const logger = createLogger('FileArchiveDelivery') + +/** Stream an authorized archive plan, opening at most the current entry rather than buffering files. */ +export function presentWorkspaceFileArchive({ + filesToZip, + folderPaths, + renderedDocuments, +}: DownloadWorkspaceFileItemsResult) { + const entryPaths = buildZipEntryPaths( + filesToZip.map((file) => ({ + name: file.name, + folderPath: file.folderId ? folderPaths.get(file.folderId) : null, + contentType: file.type, + })) + ) + const archive = new ZipArchive({ store: true }) + archive.on('warning', (error: Error) => logger.warn('Archive warning', { error })) + const closed = new AbortController() + let activeInput: Readable | undefined + archive.once('close', () => { + closed.abort() + activeInput?.destroy() + }) + async function appendEntries() { + for (const [index, file] of filesToZip.entries()) { + closed.signal.throwIfAborted() + const rendered = renderedDocuments.get(file.id) + const input = + rendered ?? + Readable.from( + (async function* () { + const source = addAbortSignal( + closed.signal, + await downloadFileStream({ + key: file.key, + context: file.storageContext ?? 'workspace', + }) + ) + try { + yield* source + } finally { + source.destroy() + } + })(), + { objectMode: false } + ) + activeInput = input instanceof Readable ? input : undefined + const consumed = once(archive, 'entry', { signal: closed.signal }) + const sourceFinished = activeInput + ? finished(activeInput, { readable: true, writable: false, cleanup: true }) + : undefined + try { + archive.append(input, { name: entryPaths[index] }) + await Promise.all([consumed, sourceFinished]) + } finally { + activeInput?.destroy() + activeInput = undefined + } + } + await archive.finalize() + } + appendEntries().catch((error: unknown) => { + if (toError(error).name === 'AbortError') return + logger.error('Failed to build file archive', { error }) + archive.destroy(toError(error)) + }) + return { + body: nodeReadableToWebStream(archive), + contentType: 'application/zip', + headers: fileDeliveryHeaders({ + filename: 'workspace-files.zip', + contentType: 'application/zip', + attachment: true, + cacheControl: FILE_CACHE_CONTROL.noStore, + }), + } +} diff --git a/apps/sim/lib/workspace-files/api/copy-presenter.ts b/apps/sim/lib/workspace-files/api/copy-presenter.ts new file mode 100644 index 00000000000..c15e5da753b --- /dev/null +++ b/apps/sim/lib/workspace-files/api/copy-presenter.ts @@ -0,0 +1,27 @@ +import type { CopyFileItemsResponse } from '@/lib/api/contracts/file-copy' +import { workspaceFileRevision } from '@/lib/workspace-files/application/file-revision' +import type { CopiedFileItems } from '@/lib/workspace-files/copy' + +/** Both copy surfaces expose new canonical identities without object-storage addresses. */ +export function presentCopiedFileItems(result: CopiedFileItems): CopyFileItemsResponse { + return { + files: result.files.map(({ key, path, ...file }) => { + const revision = workspaceFileRevision(file) + if (revision === null) throw new Error('Copied file is missing its content revision') + return { + ...file, + uploadedAt: file.uploadedAt.toISOString(), + updatedAt: file.updatedAt.toISOString(), + contentUpdatedAt: file.contentUpdatedAt?.toISOString() ?? null, + deletedAt: file.deletedAt?.toISOString() ?? null, + revision, + } + }), + folders: result.folders.map((folder) => ({ + ...folder, + createdAt: folder.createdAt.toISOString(), + updatedAt: folder.updatedAt.toISOString(), + deletedAt: folder.deletedAt?.toISOString() ?? null, + })), + } +} diff --git a/apps/sim/lib/workspace-files/api/index.ts b/apps/sim/lib/workspace-files/api/index.ts index f4c93a1ad9a..144afebaf0e 100644 --- a/apps/sim/lib/workspace-files/api/index.ts +++ b/apps/sim/lib/workspace-files/api/index.ts @@ -6,3 +6,5 @@ export { internalWorkspaceFileServeAuth, v2FileErrorPolicies, } from '@/lib/workspace-files/api/route-policies' +export { presentWorkspaceFileArchive } from './archive-presenter' +export { toFileVersion } from './version-presenters' diff --git a/apps/sim/lib/workspace-files/api/version-presenters.ts b/apps/sim/lib/workspace-files/api/version-presenters.ts new file mode 100644 index 00000000000..316d97371e6 --- /dev/null +++ b/apps/sim/lib/workspace-files/api/version-presenters.ts @@ -0,0 +1,19 @@ +import type { V2FileVersion } from '@/lib/api/contracts/v2/file-versions' +import type { AuthoredFileVersion } from '@/lib/workspace-files/application/version-authors' + +/** Serializes the version and author fields already projected by an authorized operation. */ +export function toFileVersion(version: AuthoredFileVersion): V2FileVersion { + return { + fileId: version.fileId, + version: version.version, + isCurrent: version.isCurrent, + size: version.size, + contentType: version.contentType, + source: version.source, + authors: version.authors, + restoredFromVersion: version.restoredFromVersion, + createdAt: version.createdAt.toISOString(), + updatedAt: version.updatedAt.toISOString(), + supersededAt: version.supersededAt?.toISOString() ?? null, + } +} diff --git a/apps/sim/lib/workspace-files/application/copy-authorization.ts b/apps/sim/lib/workspace-files/application/copy-authorization.ts new file mode 100644 index 00000000000..89eba620fdf --- /dev/null +++ b/apps/sim/lib/workspace-files/application/copy-authorization.ts @@ -0,0 +1,253 @@ +import type { Principal, ResourceFileCopyScope } from '@sim/auth/principal' +import { projectWorkspace, workspace } from '@sim/db/schema' +import { compareStrings } from '@sim/utils/string' +import { and, asc, inArray, isNull } from 'drizzle-orm' +import { requireOAuthOperationScope } from '@/lib/core/application/oauth-authorization' +import { + isResourceFileCopyScope, + requireResourceDelegation, +} from '@/lib/core/application/resource-delegation' +import { + authorizeWorkspaceOperation, + PrincipalKindAuthorizationError, + requireCurrentHumanRole, +} from '@/lib/core/application/workspace-authorization' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbTransaction } from '@/lib/db/types' +import { assertWorkspaceCapability } from '@/lib/permission-groups/capability-assertions' +import { acquirePermissionGroupOrgLock } from '@/lib/permission-groups/locks' +import { loadProjectAccess } from '@/lib/projects/application/authorization' +import { resolveCopilotProjectScope } from '@/lib/projects/application/discovery' +import { + type ProjectFileAuthorizationContext, + requireCurrentCopilotProjectInvocation, + requireProjectFileOwnerCapabilities, + requireProjectFileOwnerRole, +} from '@/lib/projects/files/application/authorization' +import { lockProject, lockProjectBackfillWrites } from '@/lib/projects/membership' +import { requireProjectFileApiEnabled } from '@/lib/projects/rollout.server' +import { + FILE_COPY_DELEGATION_TTL_MS, + fileCopyOperation, +} from '@/lib/workspace-files/application/copy-operation' +import { fileOperations } from '@/lib/workspace-files/application/operations' +import { + requireCurrentFileSubject, + requireFileSubject, +} from '@/lib/workspace-files/application/subject' +import { + type FileOwnerAdapters, + requireFileOwnerAdapter, +} from '@/lib/workspace-files/owner-adapters' + +export type CopyFileItemsInput = Omit + +type CopyPrincipal = + | Extract + | Extract + +type ProjectAccess = Awaited> + +type FileCopyOwnerContext = + | ProjectFileAuthorizationContext + | { + owner: { entityType: 'workspace'; entityId: string } + workspaceId: string + organizationId: string | null + billedAccountUserId: string + } + +export interface FileCopyAuthorizationContext { + source: FileCopyOwnerContext + destination: FileCopyOwnerContext +} + +interface CopyOwnerPolicyInput { + tx: DbTransaction + principal: CopyPrincipal + userId: string + owner: ResourceFileCopyScope['source']['owner'] + mode: 'read' | 'write' + projects: ReadonlyMap + workspaces: ReadonlyMap +} + +type CopyOwnerPolicy = (input: CopyOwnerPolicyInput) => Promise + +const COPY_OWNER_POLICIES: FileOwnerAdapters = { + async project({ tx, principal, owner, mode, projects }) { + const access = projects.get(owner.entityId) + if (!access) throw new OrchestrationError('not_found', 'Project not found') + const context = await requireProjectFileOwnerRole(tx, principal, access, mode) + await requireProjectFileOwnerCapabilities(tx, principal, access, fileCopyOperation.capability) + return context + }, + async workspace({ tx, principal, userId, owner, mode, workspaces }) { + const row = workspaces.get(owner.entityId) + if (!row) throw new OrchestrationError('not_found', 'Workspace not found') + const context = { + workspaceId: row.id, + workspaceOrganizationId: row.organizationId, + allowPersonalApiKeys: row.allowPersonalApiKeys, + } + // Project access already holds membership and permission SHARE locks; upgrading can deadlock other Projects. + if (principal.kind === 'resource_delegated') { + await requireCurrentHumanRole(userId, context, mode, { executor: tx }) + // permission-group-enforced: files.use — copy delegation retains the subject's workspace file policy. + await assertWorkspaceCapability( + userId, + row.id, + fileCopyOperation.capability, + row.organizationId, + tx + ) + } else { + await authorizeWorkspaceOperation( + principal, + mode === 'read' ? fileOperations.list : fileOperations.create, + context, + { executor: tx } + ) + } + return { + owner: { entityType: 'workspace', entityId: row.id }, + workspaceId: row.id, + organizationId: row.organizationId, + billedAccountUserId: row.billedAccountUserId, + } + }, +} + +function requireCopyPrincipal( + principal: Principal, + input: CopyFileItemsInput +): asserts principal is CopyPrincipal { + if (!fileCopyOperation.principalKinds.some((kind) => kind === principal.kind)) { + throw new PrincipalKindAuthorizationError(principal.kind, fileCopyOperation.id) + } + const scope = { kind: 'file_copy', ...input } + if (!isResourceFileCopyScope(scope)) { + throw new OrchestrationError( + 'validation', + 'Copy requires bounded source and destination owners' + ) + } + requireOAuthOperationScope(principal, fileCopyOperation) + if (principal.kind === 'resource_delegated') { + requireResourceDelegation(principal, { + audience: fileCopyOperation.delegationAudience, + services: fileCopyOperation.delegatedServices, + scope, + maxTtlMs: FILE_COPY_DELEGATION_TTL_MS, + }) + } +} + +/** Authorizes both owners under canonical locks; selected rows remain the copy manager's responsibility. */ +export async function createFileCopyAuthorizer( + principal: Principal, + input: CopyFileItemsInput +): Promise<(tx: DbTransaction) => Promise> { + requireCopyPrincipal(principal, input) + await requireProjectFileApiEnabled() + // actorless-unsupported: files.copy rejects executors and workspace keys; both owner policies require the acting human. + const userId = await requireFileSubject(principal) + const invocationScope = + principal.kind === 'resource_delegated' + ? await resolveCopilotProjectScope(principal) + : undefined + + return async (tx) => { + requireCopyPrincipal(principal, input) + const actingPrincipal = principal + const owners = [input.source.owner, input.destination.owner] + const workspaceIds = [ + ...new Set( + owners.filter((owner) => owner.entityType === 'workspace').map((owner) => owner.entityId) + ), + ].sort(compareStrings) + await lockProjectBackfillWrites(tx, workspaceIds) + const memberships = workspaceIds.length + ? await tx + .select() + .from(projectWorkspace) + .where(inArray(projectWorkspace.workspaceId, workspaceIds)) + : [] + const parents = new Map(memberships.map((row) => [row.workspaceId, row.projectId])) + if (workspaceIds.some((workspaceId) => !parents.has(workspaceId))) { + throw new OrchestrationError('not_found', 'Workspace not found in a Project') + } + const projectIds = [ + ...new Set([ + ...owners.filter((owner) => owner.entityType === 'project').map((owner) => owner.entityId), + ...parents.values(), + ]), + ].sort(compareStrings) + for (const projectId of projectIds) await lockProject(tx, projectId) + const currentMemberships = workspaceIds.length + ? await tx + .select() + .from(projectWorkspace) + .where(inArray(projectWorkspace.workspaceId, workspaceIds)) + : [] + if ( + currentMemberships.length !== memberships.length || + currentMemberships.some((row) => parents.get(row.workspaceId) !== row.projectId) + ) { + throw new OrchestrationError('conflict', 'Project membership changed; retry the copy') + } + + const projects = new Map() + for (const projectId of projectIds) { + projects.set(projectId, await loadProjectAccess(tx, userId, { projectId })) + } + const workspaces = workspaceIds.length + ? await tx + .select() + .from(workspace) + .where(and(inArray(workspace.id, workspaceIds), isNull(workspace.archivedAt))) + .orderBy(asc(workspace.id)) + .for('share') + : [] + const workspacesById = new Map(workspaces.map((row) => [row.id, row])) + const organizationIds = [ + ...new Set( + [ + ...[...projects.values()].map((access) => access.record.organizationId), + ...workspaces.map((row) => row.organizationId), + ].filter((id): id is string => id !== null) + ), + ].sort(compareStrings) + for (const organizationId of organizationIds) + await acquirePermissionGroupOrgLock(tx, organizationId) + await requireCurrentFileSubject(tx, principal) + if (principal.kind === 'resource_delegated') { + if (!invocationScope) throw new Error('Copilot copy invocation was not prepared') + for (const access of projects.values()) { + await requireCurrentCopilotProjectInvocation(tx, principal, access, invocationScope) + } + } + + function authorizeOwner( + owner: ResourceFileCopyScope['source']['owner'], + mode: 'read' | 'write' + ): Promise { + return requireFileOwnerAdapter( + COPY_OWNER_POLICIES, + owner + )({ + tx, + principal: actingPrincipal, + userId, + owner, + mode, + projects, + workspaces: workspacesById, + }) + } + + const source = await authorizeOwner(input.source.owner, 'read') + const destination = await authorizeOwner(input.destination.owner, 'write') + return { source, destination } + } +} diff --git a/apps/sim/lib/workspace-files/application/copy-file-items.ts b/apps/sim/lib/workspace-files/application/copy-file-items.ts new file mode 100644 index 00000000000..f61d6d4c8a9 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/copy-file-items.ts @@ -0,0 +1,226 @@ +import { isUtf8 } from 'node:buffer' +import { AuditAction, AuditResourceType } from '@sim/audit' +import { requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { db } from '@sim/db' +import { prepareFileAccountingInTx } from '@/lib/billing/storage/accounting' +import { maybeNotifyStorageLimitForBillingContext } from '@/lib/billing/storage/tracking' +import { + type AuthorizingUseCase, + recordProjectedUseCaseAuditEntries, + type WorkspaceUseCaseAuditEntry, +} from '@/lib/core/application/authorized-workspace-use-case' +import { runWithOutboundOrganization } from '@/lib/core/network/context.server' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { notifyFileListChanged } from '@/lib/realtime/notify' +import { assertFileFolderTarget } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' +import { + discardStagedFileContent, + planFileIdentity, + type StagedFileContent, + stageFileContent, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { downloadFile } from '@/lib/uploads/core/storage-service' +import { + getDocumentSourceLanguage, + getE2BDocFormat, + isCompiledDocumentBuffer, +} from '@/lib/uploads/documents' +import { getWorkspaceFileSize, MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' +import { isMarkdownFile, isRenderableDocumentName } from '@/lib/uploads/utils/file-utils' +import { + type CopyFileItemsInput, + createFileCopyAuthorizer, +} from '@/lib/workspace-files/application/copy-authorization' +import { fileCopyOperation } from '@/lib/workspace-files/application/copy-operation' +import { + type CopiedFileItems, + commitFileCopyInTx, + requireUnchangedFileCopy, + snapshotFileCopyInTx, +} from '@/lib/workspace-files/copy' +import { rewriteCopiedFileReferences } from '@/lib/workspace-files/copy-references' +import { lockFileDirectories } from '@/lib/workspace-files/locks' + +/** Atomic source-read/destination-write copy; staging never holds authorization or accounting locks. */ +export const copyFileItems: AuthorizingUseCase< + typeof fileCopyOperation, + CopyFileItemsInput, + CopiedFileItems +> = { + operation: fileCopyOperation, + delegationAudience: fileCopyOperation.delegationAudience, + async authorize(args) { + const input = structuredClone(args.input) + const authorize = await createFileCopyAuthorizer(args.principal, input) + await db.transaction(async (tx) => { + const context = await authorize(tx) + await lockFileDirectories(tx, [context.source.owner, context.destination.owner]) + await assertFileFolderTarget(context.destination.owner, input.destination.folderId, tx) + await snapshotFileCopyInTx(tx, { ...input.source, owner: context.source.owner }) + }) + }, + async execute(args) { + const input = structuredClone(args.input) + const authorize = await createFileCopyAuthorizer(args.principal, input) + // actorless-unsupported: the copy authorizer has rejected actorless principals; creator attribution retains its human subject. + const userId = requirePrincipalSubjectUserId(args.principal) + const prepared = await db.transaction(async (tx) => { + const context = await authorize(tx) + await lockFileDirectories(tx, [context.source.owner, context.destination.owner]) + await assertFileFolderTarget(context.destination.owner, input.destination.folderId, tx) + return { + context, + snapshot: await snapshotFileCopyInTx(tx, { ...input.source, owner: context.source.owner }), + } + }) + const staged = new Map() + const identities = new Map( + prepared.snapshot.files.map((file) => [ + file.id, + planFileIdentity(prepared.context.destination.owner), + ]) + ) + const fileIds = new Map([...identities].map(([sourceId, identity]) => [sourceId, identity.id])) + const fileKeys = new Map( + prepared.snapshot.files.map((file) => { + const id = fileIds.get(file.id) + if (!id) throw new Error('Copied file identity plan is incomplete') + return [file.key, id] + }) + ) + let stagedBytes = 0 + const committed = await (async () => { + try { + for (const source of prepared.snapshot.files) { + const content = await runWithOutboundOrganization( + prepared.context.source.organizationId, + () => + downloadFile({ + key: source.key, + context: prepared.context.source.owner.entityType, + maxBytes: getWorkspaceFileSize(source), + }) + ) + if (content.length !== getWorkspaceFileSize(source)) { + throw new OrchestrationError('conflict', 'Source file size changed during copy') + } + const textual = + source.contentType.startsWith('text/') || + ['application/json', 'application/xml', 'application/javascript'].includes( + source.contentType + ) || + isMarkdownFile({ name: source.originalName, type: source.contentType }) || + isRenderableDocumentName(source.originalName) + let copiedContent = content + if ( + textual && + isUtf8(content) && + !isCompiledDocumentBuffer(source.originalName, content) + ) { + const text = content.toString('utf8') + const format = await getE2BDocFormat(source.originalName) + copiedContent = Buffer.from( + rewriteCopiedFileReferences( + text, + { + sourceOwner: prepared.context.source.owner, + destinationOwner: prepared.context.destination.owner, + fileIds, + fileKeys, + }, + format ? getDocumentSourceLanguage(text, format, source.contentType) : null + ) + ) + } + stagedBytes += copiedContent.length + if (stagedBytes > MAX_BUFFERED_TRANSFER_BYTES) + throw new OrchestrationError( + 'payload_too_large', + 'Copied content exceeds the buffered transfer limit' + ) + staged.set( + source.id, + await runWithOutboundOrganization(prepared.context.destination.organizationId, () => + stageFileContent({ + owner: prepared.context.destination.owner, + userId, + name: source.originalName, + contentType: source.contentType, + content: copiedContent, + }) + ) + ) + } + return await db.transaction(async (tx) => { + const context = await authorize(tx) + const accounting = await prepareFileAccountingInTx(tx, context.destination.owner) + await lockFileDirectories(tx, [context.source.owner, context.destination.owner]) + const current = await snapshotFileCopyInTx(tx, { + ...input.source, + owner: context.source.owner, + }) + requireUnchangedFileCopy(prepared.snapshot, current) + const result = await commitFileCopyInTx(tx, { + snapshot: current, + destination: { owner: context.destination.owner, folderId: input.destination.folderId }, + userId, + staged, + identities, + }) + return { + context, + result, + billing: accounting.billing, + usage: await accounting.mutation.applyDelta(stagedBytes), + } + }) + } catch (error) { + const cleanups = await Promise.allSettled( + [...staged.values()].map(discardStagedFileContent) + ) + const failed = cleanups.find((cleanup) => cleanup.status === 'rejected') + if (failed?.status === 'rejected') { + throw new AggregateError( + [error, failed.reason], + 'Copy failed and durable cleanup could not be recorded' + ) + } + throw error + } + })() + const owner = committed.context.destination.owner + const workspaceId = owner.entityType === 'workspace' ? owner.entityId : null + const metadata = { source: input.source, destination: input.destination } + const entries: WorkspaceUseCaseAuditEntry[] = [ + ...committed.result.files.map((file) => ({ + action: AuditAction.FILE_UPLOADED, + resourceType: AuditResourceType.FILE, + resourceId: file.id, + resourceName: file.name, + metadata, + })), + ...committed.result.folders.map((folder) => ({ + action: AuditAction.FOLDER_CREATED, + resourceType: AuditResourceType.FOLDER, + resourceId: folder.id, + resourceName: folder.name, + metadata, + })), + ] + if (committed.result.files.length || committed.result.folders.length) { + await notifyFileListChanged(committed.context.destination.owner) + } + recordProjectedUseCaseAuditEntries( + fileCopyOperation, + workspaceId, + args.principal, + args.request, + entries, + committed.context.destination.organizationId ?? undefined + ) + if (committed.usage !== undefined) { + await maybeNotifyStorageLimitForBillingContext(committed.billing, committed.usage) + } + return committed.result + }, +} diff --git a/apps/sim/lib/workspace-files/application/copy-operation.ts b/apps/sim/lib/workspace-files/application/copy-operation.ts new file mode 100644 index 00000000000..9e70495fe8f --- /dev/null +++ b/apps/sim/lib/workspace-files/application/copy-operation.ts @@ -0,0 +1,23 @@ +import { + assertOperationCapability, + assertOperationOAuthPolicy, +} from '@/lib/core/application/operation' + +export const FILE_COPY_DELEGATION_TTL_MS = 60_000 + +const policy = { + id: 'files.copy', + capability: 'files.use', + oauthScope: 'api:write', + principalKinds: ['session', 'personal_api_key', 'oauth_access_token', 'resource_delegated'], + delegatedServices: ['copilot'], + delegationAudience: 'sim:files:copy', +} as const + +assertOperationCapability(policy) +assertOperationOAuthPolicy(policy) +Object.freeze(policy.principalKinds) +Object.freeze(policy.delegatedServices) + +/** Compound copies bind independent source-read and destination-write authority. */ +export const fileCopyOperation = Object.freeze(policy) diff --git a/apps/sim/lib/workspace-files/application/download-workspace-file-items.test.ts b/apps/sim/lib/workspace-files/application/download-workspace-file-items.test.ts index ddb450623fe..234dbb6f2fc 100644 --- a/apps/sim/lib/workspace-files/application/download-workspace-file-items.test.ts +++ b/apps/sim/lib/workspace-files/application/download-workspace-file-items.test.ts @@ -3,6 +3,7 @@ import { createSessionPrincipal, } from '@sim/testing/factories/principal.factory' import { auditMock } from '@sim/testing/mocks/audit.mock' +import { fileReadReceiptMock } from '@sim/testing/mocks/file-read-receipt.mock' import { fileUtilsMock, fileUtilsMockFns } from '@sim/testing/mocks/file-utils.mock' import { permissionGroupsResolveMock, @@ -26,6 +27,8 @@ const { mockListWorkspaceFileFolders: mockListFolders, } = workspaceUploadsMockFns +vi.mock('@/lib/workspace-files/read-receipt', () => fileReadReceiptMock) + vi.mock('@/lib/permission-groups/resolve.server', () => permissionGroupsResolveMock) vi.mock('@/lib/uploads/contexts/workspace', () => workspaceUploadsMock) @@ -110,7 +113,6 @@ describe('downloadWorkspaceFileItems', () => { }) expect(result.filesToZip.map((item) => item.id)).toEqual(['f1']) - expect(mockResolvePermission).toHaveBeenCalledOnce() }) it.each([ diff --git a/apps/sim/lib/workspace-files/application/download-workspace-file-items.ts b/apps/sim/lib/workspace-files/application/download-workspace-file-items.ts index 011dabaf0c7..691c7594f07 100644 --- a/apps/sim/lib/workspace-files/application/download-workspace-file-items.ts +++ b/apps/sim/lib/workspace-files/application/download-workspace-file-items.ts @@ -1,11 +1,12 @@ import { AuditAction, AuditResourceType } from '@sim/audit' +import { db } from '@sim/db' +import { compareStrings } from '@sim/utils/string' import { type AuthorizedWorkspaceUseCaseContext, capabilityGovernedPrincipalUserId, } from '@/lib/core/application' import { OrchestrationError } from '@/lib/core/orchestration/types' import { PayloadSizeLimitError } from '@/lib/core/utils/stream-limits' -import { parseFolderPath } from '@/lib/folders/paths' import { assertWorkspaceCapability } from '@/lib/permission-groups/capability-assertions' import { buildWorkspaceFileFolderPathMap, @@ -23,12 +24,16 @@ import { import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' import { fetchAuthorizedServableWorkspaceFileBuffer } from '@/lib/workspace-files/application/fetch-servable-workspace-file-buffer' import { fileOperations } from '@/lib/workspace-files/application/operations' -import { parseWorkspaceFileFolderDisplayPath } from '@/lib/workspace-files/folder-display-path' -import { MAX_ZIP_DOWNLOAD_FILES } from '@/lib/workspace-files/limits' - -export const MAX_ZIP_DOWNLOAD_BYTES = 250 * 1024 * 1024 -const MAX_REQUESTED_FILE_IDS = 1_000 -const MAX_REQUESTED_FOLDER_IDS = 1_000 +import { + expandFileDownloadFolders, + normalizeFileDownloadSelection, +} from '@/lib/workspace-files/download-selection' +import { MAX_ZIP_DOWNLOAD_BYTES, MAX_ZIP_DOWNLOAD_FILES } from '@/lib/workspace-files/limits' +import { + createFileReadReceipt, + type FileReadReceipt, + recheckFileReadReceipt, +} from '@/lib/workspace-files/read-receipt' export interface DownloadWorkspaceFileItemsInput { workspaceId: string @@ -49,51 +54,6 @@ export interface DownloadWorkspaceFileItemsResult { declaredBytes: number } -function collectDescendantFolderIds( - selectedFolderIds: string[], - folders: Array<{ id: string; parentId: string | null }> -): Set { - const folderIds = new Set(selectedFolderIds) - let changed = true - while (changed) { - changed = false - for (const folder of folders) { - if (folder.parentId && folderIds.has(folder.parentId) && !folderIds.has(folder.id)) { - folderIds.add(folder.id) - changed = true - } - } - } - return folderIds -} - -/** - * Maps canonical folder paths onto the ids the selection walk uses. - * - * Resolved against the folder set the download already loads rather than by a - * separate path query, and a path that matches nothing is rejected rather than - * silently dropped — a caller that misspells a folder should not receive a zip - * of whatever else it happened to select. - */ -function resolveFolderIdsFromPaths( - paths: string[], - folders: Array<{ id: string }>, - displayPathById: Map -): string[] { - if (paths.length === 0) return [] - const idByPath = new Map() - for (const folder of folders) { - const displayPath = displayPathById.get(folder.id) - if (!displayPath) continue - idByPath.set(parseWorkspaceFileFolderDisplayPath(displayPath).join('\u0000'), folder.id) - } - return paths.map((path) => { - const id = idByPath.get(parseFolderPath(path).join('\u0000')) - if (!id) validationError(`Folder not found: ${path}`) - return id - }) -} - function validationError(message: string): never { throw new OrchestrationError('validation', message) } @@ -107,21 +67,7 @@ async function executeDownloadWorkspaceFileItems({ DownloadWorkspaceFileItemsInput, Awaited> >): Promise { - const fileIds = [...new Set(input.fileIds)] - const folderIds = [...new Set(input.folderIds)] - const requestedFolderPaths = [...new Set(input.folderPaths ?? [])] - if (fileIds.length > MAX_REQUESTED_FILE_IDS) { - validationError(`Too many file IDs selected. Select ${MAX_REQUESTED_FILE_IDS} or fewer files.`) - } - if (folderIds.length + requestedFolderPaths.length > MAX_REQUESTED_FOLDER_IDS) { - validationError( - `Too many folders selected. Select ${MAX_REQUESTED_FOLDER_IDS} or fewer folders.` - ) - } - if (fileIds.length === 0 && folderIds.length === 0 && requestedFolderPaths.length === 0) { - validationError('No files selected for download') - } - + const selection = normalizeFileDownloadSelection(input) /** * permission-group-enforced: files.bulk_download — one operation serves both * a single file and a whole folder tree, and only the archive is what the key @@ -152,11 +98,8 @@ async function executeDownloadWorkspaceFileItems({ listWorkspaceFileFolders(context.workspaceId), ]) const folderPaths = buildWorkspaceFileFolderPathMap(folders) - const selectedFolderIds = collectDescendantFolderIds( - [...folderIds, ...resolveFolderIdsFromPaths(requestedFolderPaths, folders, folderPaths)], - folders - ) - const requestedFileIds = new Set(fileIds) + const selectedFolderIds = expandFileDownloadFolders(selection, folders, folderPaths) + const requestedFileIds = new Set(selection.fileIds) const filesToZip = files.filter( (file) => requestedFileIds.has(file.id) || @@ -181,6 +124,7 @@ async function executeDownloadWorkspaceFileItems({ .filter((file) => !needsRenderedArtifact(file.type, file.name)) .reduce((sum, file) => sum + file.size, 0) const renderedDocuments = new Map() + const receipts: FileReadReceipt[] = [] const pendingNames: string[] = [] let renderedBytes = 0 @@ -189,9 +133,14 @@ async function executeDownloadWorkspaceFileItems({ const remaining = Math.max(0, MAX_ZIP_DOWNLOAD_BYTES - reservedForStreamed - renderedBytes) const allowance = Math.min(remaining, MAX_RENDERED_DOCUMENT_BYTES) try { - const { buffer } = await fetchAuthorizedServableWorkspaceFileBuffer(file, principal, { - maxBytes: allowance, - }) + const { buffer, receipt } = await fetchAuthorizedServableWorkspaceFileBuffer( + file, + principal, + { + maxBytes: allowance, + } + ) + receipts.push(receipt) renderedBytes += buffer.length renderedDocuments.set(file.id, buffer) } catch (error) { @@ -211,6 +160,56 @@ async function executeDownloadWorkspaceFileItems({ throw new OrchestrationError('conflict', docNotReadyMessage(pendingNames)) } + await downloadWorkspaceFileItems.authorize({ principal, input }) + if (context.fileId === undefined) { + const actingUserId = capabilityGovernedPrincipalUserId(principal) + if (actingUserId) { + // permission-group-enforced: files.bulk_download — publication must use the current archive capability. + await assertWorkspaceCapability( + actingUserId, + context.workspaceId, + 'files.bulk_download', + context.workspaceOrganizationId + ) + } + } + const [currentFiles, currentFolders] = await Promise.all([ + listWorkspaceFiles(context.workspaceId, { hydrateFolderPaths: false, throwOnError: true }), + listWorkspaceFileFolders(context.workspaceId), + ]) + const currentPaths = buildWorkspaceFileFolderPathMap(currentFolders) + const currentSelection = expandFileDownloadFolders(selection, currentFolders, currentPaths) + const currentSelectedFiles = currentFiles.filter( + (file) => + requestedFileIds.has(file.id) || + (file.folderId != null && currentSelection.has(file.folderId)) + ) + const identity = (selected: WorkspaceFileRecord[], paths: Map) => + JSON.stringify( + [...selected] + .sort((a, b) => compareStrings(a.id, b.id)) + .map((file) => [ + file.id, + file.key, + file.name, + file.folderId, + file.folderId ? paths.get(file.folderId) : null, + ]) + ) + if (identity(currentSelectedFiles, currentPaths) !== identity(filesToZip, folderPaths)) + throw new OrchestrationError('conflict', 'File selection changed while preparing the download') + receipts.push( + createFileReadReceipt( + { entityType: 'workspace', entityId: context.workspaceId }, + filesToZip.map((file) => ({ + ...file, + contentUpdatedAt: file.contentUpdatedAt ?? file.updatedAt, + })) + ) + ) + await db.transaction(async (tx) => { + for (const receipt of receipts) await recheckFileReadReceipt(tx, receipt) + }) return { filesToZip, folderPaths, renderedDocuments, declaredBytes } } diff --git a/apps/sim/lib/workspace-files/application/download-workspace-file.test.ts b/apps/sim/lib/workspace-files/application/download-workspace-file.test.ts index 2874b81eb65..2945fed8d4b 100644 --- a/apps/sim/lib/workspace-files/application/download-workspace-file.test.ts +++ b/apps/sim/lib/workspace-files/application/download-workspace-file.test.ts @@ -1,6 +1,7 @@ import { Readable } from 'node:stream' import { createSessionPrincipal } from '@sim/testing/factories/principal.factory' import { auditMock, auditMockFns } from '@sim/testing/mocks/audit.mock' +import { fileReadReceiptMock } from '@sim/testing/mocks/file-read-receipt.mock' import { storageServiceMock, storageServiceMockFns } from '@sim/testing/mocks/storage-service.mock' import { workspaceAuthzMock, workspaceAuthzMockFns } from '@sim/testing/mocks/workspace-authz.mock' import { @@ -20,6 +21,8 @@ const hoisted = vi.hoisted(() => ({ fetchServable: vi.fn(), })) +vi.mock('@/lib/workspace-files/read-receipt', () => fileReadReceiptMock) + vi.mock('@sim/audit', () => auditMock) vi.mock('@sim/platform-authz/workspace', () => workspaceAuthzMock) diff --git a/apps/sim/lib/workspace-files/application/download-workspace-file.ts b/apps/sim/lib/workspace-files/application/download-workspace-file.ts index 3f5cda4bb3a..623bba4e56d 100644 --- a/apps/sim/lib/workspace-files/application/download-workspace-file.ts +++ b/apps/sim/lib/workspace-files/application/download-workspace-file.ts @@ -9,6 +9,7 @@ import { } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { getBoundWorkspaceFileSecretProvenance, + mergeWorkspaceFileSecretProvenance, type WorkspaceFileSecretProvenance, } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' import { downloadFileStream } from '@/lib/uploads/core/storage-service' @@ -18,9 +19,11 @@ import { hasWorkspaceFileDeliveryObserver, reportWorkspaceFileDelivery, } from '@/lib/workspace-files/application/file-delivery-observer' +import { finishFileDelivery } from '@/lib/workspace-files/application/finish-file-delivery' import { fileOperations } from '@/lib/workspace-files/application/operations' import { resolveRenderedWorkspaceArtifact } from '@/lib/workspace-files/application/resolve-rendered-workspace-artifact' import { resolveActiveWorkspaceFileContext } from '@/lib/workspace-files/application/workspace-file-context' +import { createFileReadReceipt, type FileReadReceipt } from '@/lib/workspace-files/read-receipt' export interface DownloadWorkspaceFileInput { fileId: string @@ -42,6 +45,7 @@ export interface DownloadWorkspaceFileStreamResult extends DownloadWorkspaceFile */ contentLength: number contentType: string + receipt?: FileReadReceipt secretProvenance?: WorkspaceFileSecretProvenance } @@ -114,11 +118,28 @@ async function executeDownloadWorkspaceFileStream({ }) : undefined await reportWorkspaceFileDelivery(secretProvenance) - return streamWorkspaceFileRecord( + const result = await streamWorkspaceFileRecord( file, principal, input.includeSecretProvenance ? secretProvenance : undefined ) + const currentProvenance = await finishFileDelivery({ + authorize: () => downloadWorkspaceFileStream.authorize({ principal, input }), + receipt: + result.receipt ?? + createFileReadReceipt({ entityType: 'workspace', entityId: context.workspaceId }, [ + { ...file, contentUpdatedAt: file.contentUpdatedAt ?? file.updatedAt }, + ]), + stream: result.stream, + }) + const deliveredProvenance = secretProvenance + ? mergeWorkspaceFileSecretProvenance(secretProvenance, currentProvenance) + : currentProvenance + await reportWorkspaceFileDelivery(deliveredProvenance) + return { + ...result, + ...(input.includeSecretProvenance ? { secretProvenance: deliveredProvenance } : {}), + } } /** @@ -140,7 +161,7 @@ export async function streamWorkspaceFileRecord( * double peak memory. */ if (needsRenderedArtifact(file.type, file.name)) { - const { buffer, contentType } = await resolveRenderedArtifact(file, principal) + const { buffer, contentType, receipt } = await resolveRenderedArtifact(file, principal) return { file, stream: new ReadableStream({ @@ -153,6 +174,7 @@ export async function streamWorkspaceFileRecord( }), contentLength: buffer.length, contentType, + receipt, ...(secretProvenance ? { secretProvenance } : {}), } } diff --git a/apps/sim/lib/workspace-files/application/extract-workspace-file.test.ts b/apps/sim/lib/workspace-files/application/extract-workspace-file.test.ts index 121d8e5b3a2..83fd0e9e162 100644 --- a/apps/sim/lib/workspace-files/application/extract-workspace-file.test.ts +++ b/apps/sim/lib/workspace-files/application/extract-workspace-file.test.ts @@ -45,6 +45,7 @@ vi.mock('@/lib/core/idempotency/service', () => ({ })) vi.mock('@/lib/uploads/archive', () => ({ + archiveFolderName: vi.fn(() => 'bundle'), decompressArchiveBufferToWorkspaceFiles: hoisted.decompress, MAX_ARCHIVE_BYTES: 100 * 1024 * 1024, })) diff --git a/apps/sim/lib/workspace-files/application/extract-workspace-file.ts b/apps/sim/lib/workspace-files/application/extract-workspace-file.ts index e1fdc964ad7..3ca5e6ffdb2 100644 --- a/apps/sim/lib/workspace-files/application/extract-workspace-file.ts +++ b/apps/sim/lib/workspace-files/application/extract-workspace-file.ts @@ -6,7 +6,11 @@ import type { AuthorizedWorkspaceUseCaseContext } from '@/lib/core/application' import { IdempotencyService } from '@/lib/core/idempotency/service' import { OrchestrationError } from '@/lib/core/orchestration/types' import { notifyWorkspaceFilesChanged } from '@/lib/realtime/notify' -import { decompressArchiveBufferToWorkspaceFiles, MAX_ARCHIVE_BYTES } from '@/lib/uploads/archive' +import { + archiveFolderName, + decompressArchiveBufferToWorkspaceFiles, + MAX_ARCHIVE_BYTES, +} from '@/lib/uploads/archive' import { archiveWorkspaceFileFolderIfEmpty, createWorkspaceFileFolder, @@ -73,16 +77,6 @@ type ExtractWorkspaceFileUseCaseContext = AuthorizedWorkspaceUseCaseContext< ActiveWorkspaceFileContext > -function archiveFolderName(fileName: string): string { - const stripped = fileName - .replace(/\.zip$/i, '') - .normalize('NFC') - .replace(/[\x00-\x1f\x7f]/g, '') - .replace(/[/\\]/g, '-') - .trim() - return stripped && stripped !== '.' && stripped !== '..' ? stripped : 'archive' -} - async function withExtractionLease( workspaceId: string, fileId: string, diff --git a/apps/sim/lib/workspace-files/application/fetch-servable-workspace-file-buffer.ts b/apps/sim/lib/workspace-files/application/fetch-servable-workspace-file-buffer.ts index 7a04350b944..84959ce0c5b 100644 --- a/apps/sim/lib/workspace-files/application/fetch-servable-workspace-file-buffer.ts +++ b/apps/sim/lib/workspace-files/application/fetch-servable-workspace-file-buffer.ts @@ -1,9 +1,11 @@ import type { Principal } from '@sim/auth/principal' import { db } from '@sim/db' import { createLogger } from '@sim/logger' +import { OrchestrationError } from '@/lib/core/orchestration/types' import { generateRequestId } from '@/lib/core/utils/request' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server' +import { createFileReadReceipt, type FileReadReceipt } from '@/lib/workspace-files/read-receipt' import { markFileSearchArtifactReadyInTx } from '@/lib/workspace-files/search/artifact-ready' const logger = createLogger('FetchServableWorkspaceFileBuffer') @@ -16,7 +18,12 @@ export async function fetchAuthorizedServableWorkspaceFileBuffer( fileRecord: WorkspaceFileRecord, filePrincipal: Principal, options: { maxBytes: number; signal?: AbortSignal; requestId?: string } -): Promise<{ buffer: Buffer; contentType: string }> { +): Promise<{ + buffer: Buffer + contentType: string + receipt: FileReadReceipt + dependsOnReferencedFiles: boolean +}> { const result = await downloadServableFileFromStorage( { id: fileRecord.id, @@ -57,5 +64,20 @@ export async function fetchAuthorizedServableWorkspaceFileBuffer( }) ) } - return result + const receipt = createFileReadReceipt( + { entityType: 'workspace', entityId: fileRecord.workspaceId }, + [ + { ...fileRecord, contentUpdatedAt: fileRecord.contentUpdatedAt ?? fileRecord.updatedAt }, + ...(result.contributingFiles ?? []).map((file) => { + if (file.context !== 'workspace' || !file.contentUpdatedAt) + throw new OrchestrationError('conflict', 'Document input has no canonical revision') + return { id: file.fileId, key: file.key, contentUpdatedAt: file.contentUpdatedAt } + }), + ] + ) + return { + ...result, + receipt, + dependsOnReferencedFiles: result.dependsOnReferencedFiles ?? receipt.files.length > 1, + } } diff --git a/apps/sim/lib/workspace-files/application/file-version-write.ts b/apps/sim/lib/workspace-files/application/file-version-write.ts index 7bea84114cc..eeb875cfb3e 100644 --- a/apps/sim/lib/workspace-files/application/file-version-write.ts +++ b/apps/sim/lib/workspace-files/application/file-version-write.ts @@ -10,6 +10,7 @@ function versionSourceForPrincipal(principal: Principal): WorkspaceFileVersionSo case 'oauth_access_token': case 'workspace_api_key': return 'api' + case 'resource_delegated': case 'delegated': if (principal.serviceId === 'copilot') return 'copilot' if (principal.serviceId === 'executor') return 'workflow' diff --git a/apps/sim/lib/workspace-files/application/file-versions.test.ts b/apps/sim/lib/workspace-files/application/file-versions.test.ts index 6f82612b283..c8c4c2a38e6 100644 --- a/apps/sim/lib/workspace-files/application/file-versions.test.ts +++ b/apps/sim/lib/workspace-files/application/file-versions.test.ts @@ -1,5 +1,6 @@ import { createSessionPrincipal } from '@sim/testing/factories/principal.factory' import { auditMock, auditMockFns } from '@sim/testing/mocks/audit.mock' +import { fileReadReceiptMock } from '@sim/testing/mocks/file-read-receipt.mock' import { realtimeNotifyMock, realtimeNotifyMockFns } from '@sim/testing/mocks/realtime-notify.mock' import { uploadsMock } from '@sim/testing/mocks/uploads.mock' import { workspaceAuthzMock, workspaceAuthzMockFns } from '@sim/testing/mocks/workspace-authz.mock' @@ -20,6 +21,8 @@ const hoisted = vi.hoisted(() => ({ streamRecord: vi.fn(), })) +vi.mock('@/lib/workspace-files/read-receipt', () => fileReadReceiptMock) + vi.mock('@sim/platform-authz/workspace', () => workspaceAuthzMock) vi.mock('@sim/audit', () => auditMock) diff --git a/apps/sim/lib/workspace-files/application/file-versions.ts b/apps/sim/lib/workspace-files/application/file-versions.ts index 1b7cd7da919..1465a9f56a6 100644 --- a/apps/sim/lib/workspace-files/application/file-versions.ts +++ b/apps/sim/lib/workspace-files/application/file-versions.ts @@ -26,7 +26,6 @@ import { queryWorkspaceFileVersions, type WorkspaceFileVersionRecord, } from '@/lib/uploads/contexts/workspace/workspace-file-versions' -import { hasObjectNotFoundCause } from '@/lib/uploads/core/errors' import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' import { formatFileSize } from '@/lib/uploads/utils/file-utils' import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' @@ -38,14 +37,23 @@ import { hasWorkspaceFileDeliveryObserver, reportWorkspaceFileDelivery, } from '@/lib/workspace-files/application/file-delivery-observer' -import { parseWorkspaceFileRevision } from '@/lib/workspace-files/application/file-revision' +import { workspaceFileRevisionField } from '@/lib/workspace-files/application/file-revision' import { resolveWorkspaceFileVersionWrite } from '@/lib/workspace-files/application/file-version-write' +import { finishFileDelivery } from '@/lib/workspace-files/application/finish-file-delivery' import { fileOperations } from '@/lib/workspace-files/application/operations' import { extractWorkspaceFileRecordText, type ReadWorkspaceFileTextInput, type ReadWorkspaceFileTextResult, } from '@/lib/workspace-files/application/read-workspace-file-text' +import { + type AuthoredFileVersion, + projectFileVersionAuthors, +} from '@/lib/workspace-files/application/version-authors' +import { + assertFileVersionRevision, + readFileVersionObject, +} from '@/lib/workspace-files/application/version-content' import { resolveActiveWorkspaceFileContext } from '@/lib/workspace-files/application/workspace-file-context' const logger = createLogger('WorkspaceFileVersions') @@ -66,7 +74,8 @@ export interface ListWorkspaceFileVersionsInput extends FileVersionTarget { } export interface ListWorkspaceFileVersionsResult { - versions: WorkspaceFileVersionRecord[] + revision?: string + versions: AuthoredFileVersion[] nextKeys: CursorKey[] | null } @@ -81,7 +90,7 @@ export interface ReadWorkspaceFileVersionTextResult extends ReadWorkspaceFileTex version: WorkspaceFileVersionRecord } -export interface DownloadWorkspaceFileVersionResult extends DownloadWorkspaceFileStreamResult { +interface DownloadWorkspaceFileVersionResult extends DownloadWorkspaceFileStreamResult { version: WorkspaceFileVersionRecord } @@ -136,21 +145,6 @@ async function readVersionSecretProvenance( return snapshot ? workspaceFileSecretProvenanceFromSnapshot(snapshot) : { status: 'unknown' } } -/** - * Runs a read of a version's stored object, answering 404 when the object is gone — retention or a - * delete can remove a superseded version between loading its row and reading its bytes. - */ -async function readVersionObject(version: number, read: () => Promise): Promise { - try { - return await read() - } catch (error) { - if (hasObjectNotFoundCause(error)) { - throw new OrchestrationError('not_found', `Version ${version} not found`) - } - throw error - } -} - /** * The file as it was at `version`: current name and location, that version's bytes. Rename and move * are metadata writes that never create versions, so a version always reads under today's name. @@ -181,7 +175,11 @@ export const listWorkspaceFileVersions = defineAuthorizedWorkspaceFileUseCase({ limit: input.limit, after: input.after, }) - return { versions, nextKeys } + return { + versions: await projectFileVersionAuthors(versions), + nextKeys, + ...workspaceFileRevisionField(file), + } }, }) @@ -209,7 +207,7 @@ export const readWorkspaceFileVersionText = defineAuthorizedWorkspaceFileUseCase const version = await loadVersion(file, input.version) const fileAtVersion = recordAtVersion(file, version) const secretProvenance = await readVersionSecretProvenance(file, version) - const result = await readVersionObject(version.version, () => + const result = await readFileVersionObject(version.version, () => extractWorkspaceFileRecordText( fileAtVersion, input, @@ -222,7 +220,7 @@ export const readWorkspaceFileVersionText = defineAuthorizedWorkspaceFileUseCase }, }) -export const downloadWorkspaceFileVersion = defineAuthorizedWorkspaceFileUseCase({ +const downloadVersion = defineAuthorizedWorkspaceFileUseCase({ operation: fileOperations.downloadVersion, resolveContext: ({ input }: { input: FileVersionRef }) => resolveActiveWorkspaceFileContext(input), @@ -230,9 +228,27 @@ export const downloadWorkspaceFileVersion = defineAuthorizedWorkspaceFileUseCase const file = await loadActiveFile(context) const version = await loadVersion(file, input.version) await reportWorkspaceFileDelivery(await readVersionSecretProvenance(file, version)) - const result = await readVersionObject(version.version, () => + const result = await readFileVersionObject(version.version, () => streamWorkspaceFileRecord(recordAtVersion(file, version), principal) ) + await finishFileDelivery({ + async authorize() { + await downloadVersion.authorize({ principal, input }) + const currentFile = await loadActiveFile(context) + const current = await loadVersion(currentFile, input.version) + if ( + current.key !== version.key || + (version.isCurrent && + currentFile.contentUpdatedAt?.getTime() !== file.contentUpdatedAt?.getTime()) + ) + throw new OrchestrationError('conflict', 'The selected version changed during download') + }, + receipt: { + owner: { entityType: 'workspace', entityId: context.workspaceId }, + files: result.receipt?.files.filter((entry) => entry.id !== file.id) ?? [], + }, + stream: result.stream, + }) return { ...result, file, version } }, projectAudit: ({ result }) => ({ @@ -250,6 +266,16 @@ export const downloadWorkspaceFileVersion = defineAuthorizedWorkspaceFileUseCase }), }) +/** Historical HEAD checks the retained version without opening its storage object. */ +export const downloadWorkspaceFileVersion = { + ...downloadVersion, + async authorize(args: Parameters[0]) { + await downloadVersion.authorize(args) + const context = await resolveActiveWorkspaceFileContext(args.input) + await loadVersion(await loadActiveFile(context), args.input.version) + }, +} + async function executeRevertWorkspaceFileVersion({ input, context, @@ -270,22 +296,11 @@ async function executeRevertWorkspaceFileVersion({ `The current version is ${current.version}, not ${input.expectedCurrentVersion}` ) } - /* - * Checked before the no-op branch below: a caller that named content which has since changed - * must hear about it, not be told there was nothing to do. - */ - const expectedContentAt = input.expectedRevision - ? parseWorkspaceFileRevision(input.expectedRevision, context.fileId) - : undefined - if ( - expectedContentAt && - (file.contentUpdatedAt ?? file.updatedAt).getTime() !== expectedContentAt.getTime() - ) { - throw new OrchestrationError( - 'conflict', - 'The file changed since the revision you read; re-read it before reverting' - ) - } + const expectedContentAt = assertFileVersionRevision( + file, + input.expectedRevision, + 'The file changed since the revision you read; re-read it before reverting' + ) const target = await loadVersion(file, input.version) if (target.isCurrent) { return { file, version: target, reverted: false, revertedFrom: current.version } @@ -298,7 +313,7 @@ async function executeRevertWorkspaceFileVersion({ } const [content, provenance] = await Promise.all([ - readVersionObject(target.version, () => + readFileVersionObject(target.version, () => fetchWorkspaceFileBuffer(recordAtVersion(file, target), { maxBytes: MAX_BUFFERED_TRANSFER_BYTES, }) diff --git a/apps/sim/lib/workspace-files/application/finish-file-delivery.ts b/apps/sim/lib/workspace-files/application/finish-file-delivery.ts new file mode 100644 index 00000000000..497d091834a --- /dev/null +++ b/apps/sim/lib/workspace-files/application/finish-file-delivery.ts @@ -0,0 +1,17 @@ +import { db } from '@sim/db' +import { type FileReadReceipt, recheckFileReadReceipt } from '@/lib/workspace-files/read-receipt' + +/** Recheck current application authority and byte identities before publishing; dispose rejected streams. */ +export async function finishFileDelivery(input: { + authorize(): Promise + receipt: FileReadReceipt + stream?: ReadableStream +}) { + try { + await input.authorize() + return await db.transaction((tx) => recheckFileReadReceipt(tx, input.receipt)) + } catch (error) { + await input.stream?.cancel(error).catch(() => undefined) + throw error + } +} diff --git a/apps/sim/lib/workspace-files/application/operations.ts b/apps/sim/lib/workspace-files/application/operations.ts index b7d0ebd70f6..b9418db232a 100644 --- a/apps/sim/lib/workspace-files/application/operations.ts +++ b/apps/sim/lib/workspace-files/application/operations.ts @@ -1,4 +1,8 @@ import { defineWorkspaceOperation } from '@/lib/core/application/workspace-operation' +import { fileCopyOperation } from '@/lib/workspace-files/application/copy-operation' + +/** Compound copy admission remains separate from workspace-only file operations. */ +export const fileCopyOperations = Object.freeze({ copy: fileCopyOperation }) const ALL_COPILOT_PRINCIPAL_POLICY = { principalKinds: [ diff --git a/apps/sim/lib/workspace-files/application/read-workspace-file-content-by-key.test.ts b/apps/sim/lib/workspace-files/application/read-workspace-file-content-by-key.test.ts index b885e7d1728..d27f66de4e8 100644 --- a/apps/sim/lib/workspace-files/application/read-workspace-file-content-by-key.test.ts +++ b/apps/sim/lib/workspace-files/application/read-workspace-file-content-by-key.test.ts @@ -2,6 +2,7 @@ import { createSessionPrincipal, createWorkspaceApiKeyPrincipal, } from '@sim/testing/factories/principal.factory' +import { fileReadReceiptMock } from '@sim/testing/mocks/file-read-receipt.mock' import { uploadsMetadataMock, uploadsMetadataMockFns, @@ -13,6 +14,8 @@ import { } from '@sim/testing/mocks/workspace-uploads.mock' import { beforeEach, describe, expect, it, vi } from 'vitest' +vi.mock('@/lib/workspace-files/read-receipt', () => fileReadReceiptMock) + vi.mock('@/lib/uploads/contexts/workspace', () => workspaceUploadsMock) vi.mock('@/lib/uploads/server/metadata', () => uploadsMetadataMock) diff --git a/apps/sim/lib/workspace-files/application/read-workspace-file-content-by-key.ts b/apps/sim/lib/workspace-files/application/read-workspace-file-content-by-key.ts index 066c49022a2..79ffdeeee96 100644 --- a/apps/sim/lib/workspace-files/application/read-workspace-file-content-by-key.ts +++ b/apps/sim/lib/workspace-files/application/read-workspace-file-content-by-key.ts @@ -10,7 +10,9 @@ import { import { getFileMetadataByKey } from '@/lib/uploads/server/metadata' import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' +import { finishFileDelivery } from '@/lib/workspace-files/application/finish-file-delivery' import { fileOperations } from '@/lib/workspace-files/application/operations' +import { createFileReadReceipt } from '@/lib/workspace-files/read-receipt' export interface ReadWorkspaceFileByKeyInput { key: string @@ -40,16 +42,21 @@ async function loadCurrentWorkspaceFileByKey( async function executeReadWorkspaceFileContentByKey({ input, context, + principal, }: AuthorizedWorkspaceUseCaseContext< typeof fileOperations.readContent, ReadWorkspaceFileByKeyInput, ActiveWorkspaceFileContext >): Promise { const file = await loadCurrentWorkspaceFileByKey(input, context) - return { - file, - content: await fetchWorkspaceFileBuffer(file, { maxBytes: MAX_BUFFERED_TRANSFER_BYTES }), - } + const content = await fetchWorkspaceFileBuffer(file, { maxBytes: MAX_BUFFERED_TRANSFER_BYTES }) + await finishFileDelivery({ + authorize: () => readWorkspaceFileContentByKey.authorize({ principal, input }), + receipt: createFileReadReceipt({ entityType: 'workspace', entityId: context.workspaceId }, [ + { ...file, contentUpdatedAt: file.contentUpdatedAt ?? file.updatedAt }, + ]), + }) + return { file, content } } async function resolveWorkspaceFileByKeyContext({ diff --git a/apps/sim/lib/workspace-files/application/read-workspace-inline-file.test.ts b/apps/sim/lib/workspace-files/application/read-workspace-inline-file.test.ts index d153b8e868e..5693ebb60db 100644 --- a/apps/sim/lib/workspace-files/application/read-workspace-inline-file.test.ts +++ b/apps/sim/lib/workspace-files/application/read-workspace-inline-file.test.ts @@ -1,5 +1,6 @@ import { Readable } from 'node:stream' import { createSessionPrincipal } from '@sim/testing/factories/principal.factory' +import { fileReadReceiptMock } from '@sim/testing/mocks/file-read-receipt.mock' import { storageServiceMock, storageServiceMockFns } from '@sim/testing/mocks/storage-service.mock' import { uploadsMetadataMock, @@ -12,6 +13,8 @@ import { } from '@sim/testing/mocks/workspace-file-manager.mock' import { beforeEach, describe, expect, it, vi } from 'vitest' +vi.mock('@/lib/workspace-files/read-receipt', () => fileReadReceiptMock) + vi.mock('@sim/platform-authz/workspace', () => workspaceAuthzMock) vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => workspaceFileManagerMock) vi.mock('@/lib/uploads/server/metadata', () => uploadsMetadataMock) diff --git a/apps/sim/lib/workspace-files/application/read-workspace-inline-file.ts b/apps/sim/lib/workspace-files/application/read-workspace-inline-file.ts index cff1cc494c9..4833c22103c 100644 --- a/apps/sim/lib/workspace-files/application/read-workspace-inline-file.ts +++ b/apps/sim/lib/workspace-files/application/read-workspace-inline-file.ts @@ -10,7 +10,9 @@ import { import { downloadFileStream } from '@/lib/uploads/core/storage-service' import { getFileMetadataByKey } from '@/lib/uploads/server/metadata' import { defineAuthorizedWorkspaceFileUseCase } from '@/lib/workspace-files/application/authorized-workspace-file-use-case' +import { finishFileDelivery } from '@/lib/workspace-files/application/finish-file-delivery' import { fileOperations } from '@/lib/workspace-files/application/operations' +import { createFileReadReceipt } from '@/lib/workspace-files/read-receipt' export interface ReadWorkspaceInlineFileInput { workspaceId: string @@ -37,6 +39,7 @@ export interface ReadWorkspaceInlineFileResult { async function executeReadWorkspaceInlineFile({ input, context, + principal, }: AuthorizedWorkspaceUseCaseContext< typeof fileOperations.readContent, ReadWorkspaceInlineFileInput, @@ -47,10 +50,19 @@ async function executeReadWorkspaceInlineFile({ }) if (!file) throw new OrchestrationError('not_found', 'Not found') - const stream = await downloadFileStream({ key: file.key, context: 'workspace' }) + const stream = nodeReadableToWebStream( + await downloadFileStream({ key: file.key, context: 'workspace' }) + ) + await finishFileDelivery({ + authorize: () => readWorkspaceInlineFile.authorize({ principal, input }), + receipt: createFileReadReceipt({ entityType: 'workspace', entityId: context.workspaceId }, [ + { ...file, contentUpdatedAt: file.contentUpdatedAt ?? file.updatedAt }, + ]), + stream, + }) return { file, - stream: nodeReadableToWebStream(stream), + stream, contentAddressed: input.key !== undefined && input.key === file.key, } } diff --git a/apps/sim/lib/workspace-files/application/resolve-rendered-workspace-artifact.ts b/apps/sim/lib/workspace-files/application/resolve-rendered-workspace-artifact.ts index 6d1728e9ece..5428ee2249a 100644 --- a/apps/sim/lib/workspace-files/application/resolve-rendered-workspace-artifact.ts +++ b/apps/sim/lib/workspace-files/application/resolve-rendered-workspace-artifact.ts @@ -1,6 +1,6 @@ import type { Principal } from '@sim/auth/principal' import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace' -import { resolveDocumentRender } from '@/lib/uploads/documents' +import { resolveDocumentRender } from '@/lib/uploads/documents/render' import { fetchAuthorizedServableWorkspaceFileBuffer } from '@/lib/workspace-files/application/fetch-servable-workspace-file-buffer' /** @@ -28,7 +28,7 @@ export async function resolveRenderedWorkspaceArtifact( file: WorkspaceFileRecord, filePrincipal: Principal, options: { maxBytes: number; signal?: AbortSignal; tooLargeMessage?: (limit: string) => string } -): Promise<{ buffer: Buffer; contentType: string }> { +) { return resolveDocumentRender(file.name, options, () => fetchAuthorizedServableWorkspaceFileBuffer(file, filePrincipal, { maxBytes: options.maxBytes, diff --git a/apps/sim/lib/workspace-files/application/subject.ts b/apps/sim/lib/workspace-files/application/subject.ts new file mode 100644 index 00000000000..a11bc5906e7 --- /dev/null +++ b/apps/sim/lib/workspace-files/application/subject.ts @@ -0,0 +1,36 @@ +import { type Principal, requirePrincipalSubjectUserId } from '@sim/auth/principal' +import { user } from '@sim/db/schema' +import { eq } from 'drizzle-orm' +import { getActivelyBannedUserIds, isAccountBlocked } from '@/lib/auth/ban' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbTransaction } from '@/lib/db/types' + +type FileSubjectPrincipal = Extract + +/** Resolves the acting human and applies admission-time account and deployment restrictions. */ +export async function requireFileSubject(principal: FileSubjectPrincipal): Promise { + // actorless-unsupported: Project files and cross-owner copies require a human; their operation registries reject workspace keys and executors. + const userId = requirePrincipalSubjectUserId(principal) + if ((await getActivelyBannedUserIds([userId])).length) { + throw new OrchestrationError('forbidden', 'User account is suspended') + } + return userId +} + +/** Holds the acting account stable through a file transaction without substituting an owner. */ +export async function requireCurrentFileSubject( + tx: DbTransaction, + principal: FileSubjectPrincipal +): Promise { + // actorless-unsupported: Project files and cross-owner copies require a human; their operation registries reject workspace keys and executors. + const userId = requirePrincipalSubjectUserId(principal) + const [actor] = await tx + .select({ banned: user.banned, banExpires: user.banExpires, suspendedAt: user.suspendedAt }) + .from(user) + .where(eq(user.id, userId)) + .for('share') + .limit(1) + if (!actor || isAccountBlocked(actor)) { + throw new OrchestrationError('forbidden', 'User account is suspended') + } +} diff --git a/apps/sim/lib/workspace-files/application/version-authors.ts b/apps/sim/lib/workspace-files/application/version-authors.ts new file mode 100644 index 00000000000..dddd41474bd --- /dev/null +++ b/apps/sim/lib/workspace-files/application/version-authors.ts @@ -0,0 +1,24 @@ +import { chunkArray } from '@sim/utils/helpers' +import type { DbOrTx } from '@/lib/db/types' +import type { WorkspaceFileVersionRecord } from '@/lib/uploads/contexts/workspace/workspace-file-versions' +import { findUserEmailsByIds } from '@/lib/users/queries' + +export type AuthoredFileVersion = WorkspaceFileVersionRecord & { + authors: { id: string; email: string | null }[] +} + +/** Resolves attribution only after the caller has authorized the selected file history. */ +export async function projectFileVersionAuthors( + versions: WorkspaceFileVersionRecord[], + executor?: DbOrTx +): Promise { + const ids = [...new Set(versions.flatMap((version) => version.authorUserIds))] + const emails = new Map() + for (const batch of chunkArray(ids, 1000)) { + for (const [id, email] of await findUserEmailsByIds(batch, executor)) emails.set(id, email) + } + return versions.map((version) => ({ + ...version, + authors: version.authorUserIds.map((id) => ({ id, email: emails.get(id) ?? null })), + })) +} diff --git a/apps/sim/lib/workspace-files/application/version-content.ts b/apps/sim/lib/workspace-files/application/version-content.ts new file mode 100644 index 00000000000..31a1c1d76ec --- /dev/null +++ b/apps/sim/lib/workspace-files/application/version-content.ts @@ -0,0 +1,33 @@ +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { WorkspaceFileRecord } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { hasObjectNotFoundCause } from '@/lib/uploads/core/errors' +import { parseWorkspaceFileRevision } from '@/lib/workspace-files/application/file-revision' + +/** Checks exact content before even a no-op revert, independently of coalesced history numbers. */ +export function assertFileVersionRevision( + file: Pick, + expectedRevision: string | undefined, + conflictMessage: string +): Date | undefined { + if (!expectedRevision) return undefined + const expected = parseWorkspaceFileRevision(expectedRevision, file.id) + if ((file.contentUpdatedAt ?? file.updatedAt).getTime() !== expected.getTime()) { + throw new OrchestrationError('conflict', conflictMessage) + } + return expected +} + +/** Maps an object removed after its version was loaded to the same missing-version response. */ +export async function readFileVersionObject( + version: number, + read: () => Promise +): Promise { + try { + return await read() + } catch (error) { + if (hasObjectNotFoundCause(error)) { + throw new OrchestrationError('not_found', `Version ${version} not found`) + } + throw error + } +} diff --git a/apps/sim/lib/workspace-files/application/workspace-file-context.ts b/apps/sim/lib/workspace-files/application/workspace-file-context.ts index c8eaf651197..021d352e6ab 100644 --- a/apps/sim/lib/workspace-files/application/workspace-file-context.ts +++ b/apps/sim/lib/workspace-files/application/workspace-file-context.ts @@ -5,6 +5,7 @@ import { loadWorkspaceFileLifecycleContext, type WorkspaceFileLifecycleContext, } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { matchesFileOwner } from '@/lib/workspace-files/ownership' export interface WorkspaceFileContextInput { fileId: string @@ -17,31 +18,40 @@ export interface WorkspaceFileContextInput { includeChatUploads?: boolean } -export async function resolveActiveWorkspaceFileContext( - input: WorkspaceFileContextInput -): Promise { - const canonical = await loadActiveWorkspaceFileContext(input.fileId, { - includeDeleted: input.includeDeleted, - includeChatUploads: input.includeChatUploads, - }) +function requireWorkspaceFileContext( + canonical: C | null, + assertedWorkspaceId: string | undefined +): C { if ( !canonical || - (input.assertedWorkspaceId !== undefined && input.assertedWorkspaceId !== canonical.workspaceId) + (assertedWorkspaceId !== undefined && + !matchesFileOwner( + { entityType: 'workspace', entityId: canonical.workspaceId }, + { entityType: 'workspace', entityId: assertedWorkspaceId } + )) ) { throw new OrchestrationError('not_found', 'File not found') } return canonical } +export async function resolveActiveWorkspaceFileContext( + input: WorkspaceFileContextInput +): Promise { + return requireWorkspaceFileContext( + await loadActiveWorkspaceFileContext(input.fileId, { + includeDeleted: input.includeDeleted, + includeChatUploads: input.includeChatUploads, + }), + input.assertedWorkspaceId + ) +} + export async function resolveWorkspaceFileLifecycleContext( input: WorkspaceFileContextInput ): Promise { - const canonical = await loadWorkspaceFileLifecycleContext(input.fileId) - if ( - !canonical || - (input.assertedWorkspaceId !== undefined && input.assertedWorkspaceId !== canonical.workspaceId) - ) { - throw new OrchestrationError('not_found', 'File not found') - } - return canonical + return requireWorkspaceFileContext( + await loadWorkspaceFileLifecycleContext(input.fileId), + input.assertedWorkspaceId + ) } diff --git a/apps/sim/lib/workspace-files/archive-extraction.ts b/apps/sim/lib/workspace-files/archive-extraction.ts new file mode 100644 index 00000000000..f00cf5c7896 --- /dev/null +++ b/apps/sim/lib/workspace-files/archive-extraction.ts @@ -0,0 +1,161 @@ +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbTransaction } from '@/lib/db/types' +import { MAX_ARCHIVE_TOTAL_BYTES, type PreparedArchiveExtraction } from '@/lib/uploads/archive' +import { createFileFolder } from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' +import { + commitFileCreateInTx, + discardStagedFileContent, + type StagedFileContent, + stageFileContent, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import type { WorkspaceFileSecretProvenance } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' +import { getFileExtension, getMimeTypeFromExtension } from '@/lib/uploads/utils/file-utils' +import type { EditableFileOwner } from '@/lib/workspace-files/ownership' +import { SIM_PAGE_CONTENT_TYPE } from '@/lib/workspace-files/page-compile' +import { restoreSimPageSourceBuffer } from '@/lib/workspace-files/page-source-embed' + +interface StagedArchiveEntry { + readonly parentSegments: readonly string[] + readonly content: StagedFileContent +} + +interface StagedFileArchive { + readonly entries: readonly StagedArchiveEntry[] + readonly bytes: number + readonly plan: PreparedArchiveExtraction +} + +/** Releases staged objects through the existing durable compensation path. */ +export async function discardFileArchive(archive: Pick) { + const results = await Promise.allSettled( + archive.entries.map((entry) => discardStagedFileContent(entry.content)) + ) + const failures = results.flatMap((result) => + result.status === 'rejected' ? [result.reason] : [] + ) + if (failures.length) throw new AggregateError(failures, 'Archive cleanup could not be recorded') +} + +/** Inflates and stages one bounded entry at a time, without holding database locks. */ +export async function stageFileArchive( + plan: PreparedArchiveExtraction, + args: { owner: EditableFileOwner; userId: string; signal: AbortSignal } +): Promise { + const entries: StagedArchiveEntry[] = [] + let bytes = 0 + try { + for await (const entry of plan.entries(args.signal)) { + args.signal.throwIfAborted() + const name = entry.segments[entry.segments.length - 1] + const restored = restoreSimPageSourceBuffer(name, entry.buffer) + const content = restored?.buffer ?? entry.buffer + bytes += content.length + if (bytes > MAX_ARCHIVE_TOTAL_BYTES) { + throw new OrchestrationError( + 'payload_too_large', + 'Extracted content exceeds the archive limit' + ) + } + entries.push( + Object.freeze({ + parentSegments: Object.freeze(entry.segments.slice(0, -1)), + content: await stageFileContent({ + owner: args.owner, + userId: args.userId, + name: restored?.name ?? name, + contentType: restored + ? SIM_PAGE_CONTENT_TYPE + : getMimeTypeFromExtension(getFileExtension(name)), + content, + signal: args.signal, + }), + }) + ) + } + args.signal.throwIfAborted() + return Object.freeze({ entries: Object.freeze(entries), bytes, plan }) + } catch (error) { + try { + await discardFileArchive({ entries }) + } catch (cleanupError) { + throw new AggregateError([error, cleanupError], 'Archive staging and cleanup failed') + } + throw error + } +} + +/** Publishes the complete tree through canonical shared writers in the caller's transaction. */ +export async function commitFileArchiveInTx( + tx: DbTransaction, + args: { + owner: EditableFileOwner + userId: string + rootName: string + parentId: string | null + parentSegments: readonly string[] + staged: StagedFileArchive + secretProvenance: WorkspaceFileSecretProvenance + signal: AbortSignal + } +) { + args.signal.throwIfAborted() + if (!args.staged.entries.length) { + throw new OrchestrationError('validation', 'No files could be unzipped from this archive') + } + const root = await createFileFolder( + { + owner: args.owner, + userId: args.userId, + name: args.rootName, + parentId: args.parentId, + exactName: false, + validateResolvedName: (name) => + args.staged.plan.validateRootFolderSegments([...args.parentSegments, name]), + }, + tx + ) + const folders = new Map([['[]', root.id]]) + const provenance: WorkspaceFileSecretProvenance = + args.secretProvenance.status === 'unrecorded' || + (args.secretProvenance.status === 'exact' && args.secretProvenance.entries.length === 0) + ? args.secretProvenance + : { status: 'unknown' } + for (const entry of args.staged.entries) { + args.signal.throwIfAborted() + let parentId = root.id + for (let depth = 0; depth < entry.parentSegments.length; depth++) { + const key = JSON.stringify(entry.parentSegments.slice(0, depth + 1)) + let folderId = folders.get(key) + if (!folderId) { + const created = await createFileFolder( + { + owner: args.owner, + userId: args.userId, + name: entry.parentSegments[depth], + parentId, + }, + tx + ) + folderId = created.id + folders.set(key, folderId) + } + parentId = folderId + } + await commitFileCreateInTx(tx, { + owner: args.owner, + userId: args.userId, + staged: entry.content, + folderId: parentId, + exactName: false, + secretProvenance: provenance, + }) + } + args.signal.throwIfAborted() + return { + folderId: root.id, + folderName: root.name, + folderDisplayPath: root.path, + extractedCount: args.staged.entries.length, + skippedCount: args.staged.plan.skipped, + } +} diff --git a/apps/sim/lib/workspace-files/browser-query.ts b/apps/sim/lib/workspace-files/browser-query.ts new file mode 100644 index 00000000000..cc5206dd096 --- /dev/null +++ b/apps/sim/lib/workspace-files/browser-query.ts @@ -0,0 +1,254 @@ +import { folder, workspaceFiles } from '@sim/db/schema' +import { escapeLikePattern } from '@sim/utils/string' +import { and, isNull, type SQL, sql } from 'drizzle-orm' +import { + type CursorKey, + encodeKeyset, + INVALID_CURSOR_MESSAGE, + type KeysetKey, + keysetAfter, + keysetColumns, + type ListSortOrder, + listOrderBy, + numberKey, + textKey, +} from '@/lib/api/list-query' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbOrTx } from '@/lib/db/types' +import { MAX_FOLDER_PATH_SEGMENTS } from '@/lib/folders/paths' +import { getWorkspaceFileSize } from '@/lib/uploads/shared/types' +import { + EXTENSION_TO_MIME, + MIME_TYPE_MAPPING, + resolveEffectiveMimeType, +} from '@/lib/uploads/utils/file-utils' +import { SUPPORTED_DOCUMENT_EXTENSIONS } from '@/lib/uploads/utils/validation' +import { + FILE_BROWSER_MIME_LABELS, + FILE_BROWSER_SIZE_BOUNDARIES, + type FileBrowserCreator, + type FileBrowserFilters, + type FileBrowserItem, + type FileBrowserSort, +} from '@/lib/workspace-files/browser' +import type { EditableFileOwner } from '@/lib/workspace-files/ownership' +import { fileFolderOwnerCondition, fileOwnerCondition } from '@/lib/workspace-files/ownership-query' + +export interface FileBrowserQuery extends FileBrowserFilters { + scope: 'active' | 'archived' + folderId?: string | null + search?: string + sortBy: FileBrowserSort + sortOrder: ListSortOrder + limit: number + after?: CursorKey[] +} + +type BrowserRow = { + id: string + kind: 'file' | 'folder' + name: string + parent_id: string | null + size: string | number + type: string + created_ms: string | number + updated_ms: string | number + creator_id: string | null + creator_name: string | null + creator_image: string | null + creator_deleted: boolean +} + +const EFFECTIVE_EXTENSION_MIMES = Object.fromEntries( + Object.keys(EXTENSION_TO_MIME).map((extension) => [ + extension, + resolveEffectiveMimeType(null, `file.${extension}`), + ]) +) +const AUDIO_MIMES = Object.keys(MIME_TYPE_MAPPING).filter( + (mime) => MIME_TYPE_MAPPING[mime] === 'audio' +) +const VIDEO_MIMES = Object.keys(MIME_TYPE_MAPPING).filter( + (mime) => MIME_TYPE_MAPPING[mime] === 'video' +) + +const BROWSER_SORTS: Record> = { + name: textKey(sql`name collate "C"`, (row) => row.name), + size: numberKey(sql`size`, (row) => Number(row.size)), + type: textKey(sql`type collate "C"`, (row) => row.type), + created: numberKey(sql`created_ms`, (row) => Number(row.created_ms)), + owner: textKey( + sql`coalesce(creator_name, '') collate "C"`, + (row) => row.creator_name ?? '' + ), + updated: numberKey(sql`updated_ms`, (row) => Number(row.updated_ms)), +} + +function creatorFromRow(row: BrowserRow): FileBrowserCreator | null { + return row.creator_id + ? { + id: row.creator_id, + name: row.creator_name ?? 'Deleted user', + image: row.creator_image, + deleted: row.creator_deleted, + } + : null +} + +/** Returns one mixed file/folder page; canonical scope and every filter precede its cursor and LIMIT. */ +export async function queryFileBrowserItems( + owner: EditableFileOwner, + input: FileBrowserQuery, + tx: DbOrTx +) { + const activeFiles = + input.scope === 'archived' + ? sql`${workspaceFiles.deletedAt} is not null` + : sql`${workspaceFiles.deletedAt} is null` + const invalidSizes = await tx + .select({ id: workspaceFiles.id }) + .from(workspaceFiles) + .where(and(fileOwnerCondition(owner), activeFiles, isNull(workspaceFiles.sizeBytes))) + .limit(1) + if (invalidSizes.length) + throw new OrchestrationError('conflict', 'File size metadata is not ready') + const selectedFolders = + input.scope === 'archived' ? sql`deleted_at is not null` : sql`deleted_at is null` + const base = sql`with recursive + owned_folders as ( + select id, name, parent_id, user_id, created_at, updated_at, deleted_at + from ${folder} where ${fileFolderOwnerCondition(owner)} + ), + owned_files as ( + select id, original_name as name, folder_id as parent_id, size_bytes as size, + content_type, user_id, uploaded_at, updated_at, + case when strpos(original_name, '.') > 0 then lower(regexp_replace(original_name, '^.*[.]', '')) else '' end as extension + from ${workspaceFiles} where ${fileOwnerCondition(owner)} and ${activeFiles} + ), + descendants as ( + select id as root_id, id, 1 as depth from owned_folders + union all + select parent.root_id, child.id, parent.depth + 1 from descendants parent + join owned_folders child on child.parent_id = parent.id + where parent.depth < ${MAX_FOLDER_PATH_SEGMENTS} + ), + folder_sizes as ( + select tree.root_id, sum(files.size)::bigint as size from descendants tree + join owned_files files on files.parent_id = tree.id group by tree.root_id + ), + effective_files as ( + select *, case when btrim(content_type) not in ('', 'application/octet-stream', 'binary/octet-stream') + then btrim(content_type) else coalesce(${JSON.stringify(EFFECTIVE_EXTENSION_MIMES)}::jsonb ->> extension, 'application/octet-stream') end as mime + from owned_files + ), + items as ( + select id, 'file'::text as kind, name, parent_id, size, + coalesce(${JSON.stringify(FILE_BROWSER_MIME_LABELS)}::jsonb ->> mime, + case when mime like 'audio/%' then 'Audio' when mime like 'video/%' then 'Video' + when mime like 'image/%' then 'Image' when extension <> '' then upper(extension) else coalesce(content_type, 'File') end) as type, + user_id, trunc(extract(epoch from uploaded_at) * 1000) as created_ms, + trunc(extract(epoch from updated_at) * 1000) as updated_ms, extension, mime + from effective_files + union all + select folders.id, 'folder'::text, folders.name, folders.parent_id, coalesce(sizes.size, 0)::bigint, 'Folder'::text, + folders.user_id, trunc(extract(epoch from folders.created_at) * 1000), + trunc(extract(epoch from folders.updated_at) * 1000), ''::text, ''::text + from owned_folders folders left join folder_sizes sizes on sizes.root_id = folders.id where ${selectedFolders} + ), + authored as ( + select items.*, items.user_id as creator_id, users.name as creator_name, + users.image as creator_image, users.id is null as creator_deleted + from items left join "user" users on users.id = items.user_id + )` + const keys = [ + ...(input.sortBy === 'owner' + ? [ + numberKey(sql`case when creator_name is null then 1 else 0 end`, (row) => + row.creator_name === null ? 1 : 0 + ), + ] + : []), + BROWSER_SORTS[input.sortBy], + textKey(sql`name collate "C"`, (row) => row.name), + textKey(sql`kind collate "C"`, (row) => row.kind), + textKey(sql`id collate "C"`, (row) => row.id), + ] + const directions: ListSortOrder[] = [ + ...(input.sortBy === 'owner' ? ['asc' as const] : []), + input.sortOrder, + 'asc', + 'asc', + 'asc', + ] + let after: SQL | undefined + if (input.after) { + after = keysetAfter(keys, input.after, directions) ?? undefined + if (!after) throw new OrchestrationError('validation', INVALID_CURSOR_MESSAGE) + } + const clauses: SQL[] = [] + if (input.folderId !== undefined) + clauses.push( + input.folderId === null ? sql`parent_id is null` : sql`parent_id = ${input.folderId}` + ) + if (input.search) clauses.push(sql`name ilike ${`%${escapeLikePattern(input.search)}%`}`) + const typeClauses = (input.types ?? []).map((type) => { + if (type === 'document') + return sql`extension in (${sql.join( + SUPPORTED_DOCUMENT_EXTENSIONS.map((extension) => sql`${extension}`), + sql`, ` + )})` + if (type === 'image') return sql`mime like 'image/%'` + const mimes = type === 'audio' ? AUDIO_MIMES : VIDEO_MIMES + return sql`lower(mime) in (${sql.join( + mimes.map((mime) => sql`${mime}`), + sql`, ` + )})` + }) + const sizeClauses = (input.sizes ?? []).map((size) => + size === 'small' + ? sql`size < ${FILE_BROWSER_SIZE_BOUNDARIES.small}` + : size === 'medium' + ? sql`size >= ${FILE_BROWSER_SIZE_BOUNDARIES.small} and size <= ${FILE_BROWSER_SIZE_BOUNDARIES.medium}` + : sql`size > ${FILE_BROWSER_SIZE_BOUNDARIES.medium}` + ) + const fileFilters: SQL[] = [] + if (typeClauses.length) fileFilters.push(sql`(${sql.join(typeClauses, sql` or `)})`) + if (sizeClauses.length) fileFilters.push(sql`(${sql.join(sizeClauses, sql` or `)})`) + if (input.creatorIds?.length) + fileFilters.push( + sql`creator_id in (${sql.join( + input.creatorIds.map((id) => sql`${id}`), + sql`, ` + )})` + ) + if (fileFilters.length) + clauses.push(sql`(kind = 'folder' or (${sql.join(fileFilters, sql` and `)}))`) + if (after) clauses.push(after) + const where = clauses.length ? sql`where ${sql.join(clauses, sql` and `)}` : sql`` + const rows = await tx.execute(sql`${base} select * from authored ${where} + order by ${sql.join(listOrderBy(keysetColumns(keys), directions), sql`, `)} limit ${input.limit + 1}`) + const creatorRows = await tx.execute(sql`${base} + select * from (select distinct creator_id, creator_name, creator_image, creator_deleted from authored where creator_id is not null) creators + order by creator_name collate "C", creator_id collate "C"`) + const page = rows.slice(0, input.limit) + const last = page.at(-1) + const items: FileBrowserItem[] = page.map((row) => ({ + id: row.id, + kind: row.kind, + name: row.name, + parentId: row.parent_id, + size: getWorkspaceFileSize({ sizeBytes: Number(row.size) }), + type: row.type, + creator: creatorFromRow(row), + createdAt: new Date(Number(row.created_ms)), + updatedAt: new Date(Number(row.updated_ms)), + })) + return { + items, + creators: creatorRows.flatMap((row) => { + const creator = creatorFromRow(row) + return creator ? [creator] : [] + }), + nextKeys: rows.length > input.limit && last ? encodeKeyset(keys, last) : null, + } +} diff --git a/apps/sim/lib/workspace-files/browser.ts b/apps/sim/lib/workspace-files/browser.ts new file mode 100644 index 00000000000..d18d7be4dd5 --- /dev/null +++ b/apps/sim/lib/workspace-files/browser.ts @@ -0,0 +1,48 @@ +export const FILE_BROWSER_SORTS = ['name', 'size', 'type', 'created', 'owner', 'updated'] as const +export type FileBrowserSort = (typeof FILE_BROWSER_SORTS)[number] +export const FILE_BROWSER_TYPES = ['document', 'image', 'audio', 'video'] as const +export const FILE_BROWSER_SIZES = ['small', 'medium', 'large'] as const +export const FILE_BROWSER_SIZE_BOUNDARIES = { small: 1_048_576, medium: 10_485_760 } as const + +export const FILE_BROWSER_MIME_LABELS: Readonly> = { + 'application/pdf': 'PDF', + 'application/zip': 'ZIP', + 'application/msword': 'Word', + 'application/vnd.openxmlformats-officedocument.wordprocessingml.document': 'Word', + 'application/vnd.ms-excel': 'Excel', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet': 'Excel', + 'application/vnd.ms-powerpoint': 'PowerPoint', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'PowerPoint', + 'application/json': 'JSON', + 'application/x-yaml': 'YAML', + 'text/csv': 'CSV', + 'text/plain': 'Text', + 'text/html': 'HTML', + 'text/x-sim-page': 'Page', + 'text/markdown': 'Markdown', +} + +export interface FileBrowserCreator { + id: string + name: string + image: string | null + deleted: boolean +} + +export interface FileBrowserItem { + id: string + kind: 'file' | 'folder' + name: string + parentId: string | null + size: number + type: string + createdAt: Date + updatedAt: Date + creator: FileBrowserCreator | null +} + +export interface FileBrowserFilters { + types?: readonly (typeof FILE_BROWSER_TYPES)[number][] + sizes?: readonly (typeof FILE_BROWSER_SIZES)[number][] + creatorIds?: readonly string[] +} diff --git a/apps/sim/lib/workspace-files/copy-references.test.ts b/apps/sim/lib/workspace-files/copy-references.test.ts new file mode 100644 index 00000000000..919e32b50db --- /dev/null +++ b/apps/sim/lib/workspace-files/copy-references.test.ts @@ -0,0 +1,188 @@ +import { describe, expect, it } from 'vitest' +import { rewriteCopiedFileReferences } from '@/lib/workspace-files/copy-references' + +const workspaceOwner = { entityType: 'workspace', entityId: 'source-ws' } as const +const projectOwner = { entityType: 'project', entityId: 'target-project' } as const +const sourceKey = 'workspace/source-ws/123-photo.png' +const maps = { + sourceOwner: workspaceOwner, + destinationOwner: projectOwner, + fileIds: new Map([['photo-1', 'copied-photo']]), + fileKeys: new Map([[sourceKey, 'copied-photo']]), +} + +const projectBytes = '/api/projects/target-project/files/copied-photo/content' + +describe('selected copy references', () => { + it('rewrites selected neutral identities without touching other IDs or resources', () => { + const source = [ + '![photo](/api/files/view/photo-1)', + '[selected](sim:file/photo-1)', + '[other](sim:file/photo-10)', + '[table](sim:table/photo-1)', + '![not selected](/api/files/view/other)', + ].join('\n') + expect(rewriteCopiedFileReferences(source, maps)).toBe( + [ + `![photo](${projectBytes})`, + '[selected](sim:file/copied-photo)', + '[other](sim:file/photo-10)', + '[table](sim:table/photo-1)', + '![not selected](/api/files/view/other)', + ].join('\n') + ) + }) + + it('normalizes selected raw and encoded storage URLs to destination bytes', () => { + const source = [ + `/api/files/serve/${sourceKey}`, + `/api/files/serve/s3/${encodeURIComponent(sourceKey)}?context=workspace`, + `/api/files/serve/blob/${encodeURIComponent(sourceKey)}`, + `/api/files/serve/gcs/${encodeURIComponent(sourceKey)}`, + ] + .map((url) => `![photo](${url})`) + .join('\n') + expect(rewriteCopiedFileReferences(source, maps)).toBe( + Array.from({ length: 4 }, () => `![photo](${projectBytes})`).join('\n') + ) + }) + + it('requires the exact source owner prefix even if a foreign key is accidentally mapped', () => { + const foreign = 'workspace/source-ws-other/123-photo.png' + const source = `![photo](/api/files/serve/${encodeURIComponent(foreign)})` + expect( + rewriteCopiedFileReferences(source, { + ...maps, + fileKeys: new Map([[foreign, 'copied-photo']]), + }) + ).toBe(source) + }) + + it('moves both workspace-qualified identities to the destination Project and keeps fragments', () => { + const source = + '[app](/workspace/source-ws/files/photo-1#caption) [chip](sim:file/photo-1?workspace=source-ws#caption)' + expect(rewriteCopiedFileReferences(source, maps)).toBe( + '[app](/projects/target-project/files/copied-photo#caption) [chip](sim:file/copied-photo?project=target-project#caption)' + ) + }) + + it('moves Project-qualified identities and bytes to a workspace', () => { + const source = [ + '[app](/projects/target-project/files/photo-1)', + '[chip](sim:file/photo-1?project=target-project)', + '![photo](/api/projects/target-project/files/photo-1/content)', + '![key](/api/files/serve/project%2Ftarget-project%2F123-photo.png)', + ].join('\n') + expect( + rewriteCopiedFileReferences(source, { + ...maps, + sourceOwner: projectOwner, + destinationOwner: workspaceOwner, + fileKeys: new Map([['project/target-project/123-photo.png', 'copied-photo']]), + }) + ).toBe( + [ + '[app](/workspace/source-ws/files/copied-photo)', + '[chip](sim:file/copied-photo?workspace=source-ws)', + '![photo](/api/files/view/copied-photo)', + '![key](/api/files/view/copied-photo)', + ].join('\n') + ) + }) + + it('preserves foreign or ambiguous ownership and unsupported file paths', () => { + const source = [ + 'sim:file/photo-1?workspace=other', + 'sim:file/photo-1?project=target-project', + 'sim:file/photo-1?workspace=source-ws&project=target-project', + 'sim:file/photo-1?workspace=source-ws&workspace=source-ws', + '/workspace/other/files/photo-1', + '/projects/target-project/files/photo-1', + '/api/projects/target-project/files/photo-1/content', + '/api/files/view/photo-1/extra', + '/api/files/view/photo-1?project=other', + ].join('\n') + expect(rewriteCopiedFileReferences(source, maps)).toBe(source) + }) + + it('does not turn absolute or protocol-relative URLs into local destination references', () => { + const source = [ + 'https://other.test/api/files/view/photo-1', + '//other.test/api/files/view/photo-1', + 'https://other.test/workspace/source-ws/files/photo-1', + 'https://other.test/?next=/api/files/view/photo-1', + 'https://other.test/a(/api/files/view/photo-1)', + 'prefixsim:file/photo-1', + 'x/api/files/view/photo-1', + ].join('\n') + expect(rewriteCopiedFileReferences(source, maps)).toBe(source) + }) + + it('decodes once while preserving malformed and twice-encoded references', () => { + const source = [ + '/api/files/view/photo%2D1', + '/api/files/view/photo%252D1', + '/api/files/view/%E0%A4%A', + '/workspace/source%2Dws/files/photo%2D1', + ].join('\n') + expect(rewriteCopiedFileReferences(source, maps)).toBe( + [ + projectBytes, + '/api/files/view/photo%252D1', + '/api/files/view/%E0%A4%A', + '/projects/target-project/files/copied-photo', + ].join('\n') + ) + }) + + it('applies each selected mapping once without cascading into another selected identity', () => { + expect( + rewriteCopiedFileReferences('sim:file/photo-1 sim:file/copied-photo', { + ...maps, + fileIds: new Map([ + ['photo-1', 'copied-photo'], + ['copied-photo', 'third-photo'], + ]), + }) + ).toBe('sim:file/copied-photo sim:file/third-photo') + }) + + it('rewrites only selected Office helper and input-path identities', () => { + const source = [ + "const image = await getFileBase64('photo-1')", + 'await addImage(slide, "photo-1", options)', + "await addImage('photo-1', options)", + "drawImage(page, 'photo-1', opts)", + "Doc.open(input_path('photo-1'))", + "fs.readFileSync('/home/user/inputs/photo-1')", + "getFileBase64('unselected')", + 'getFileBase64(variable)', + "slide.addText('photo-1')", + ].join('\n') + expect(rewriteCopiedFileReferences(source, maps, 'javascript')).toBe( + [ + "const image = await getFileBase64('copied-photo')", + 'await addImage(slide, "copied-photo", options)', + "await addImage('copied-photo', options)", + "drawImage(page, 'copied-photo', opts)", + "Doc.open(input_path('copied-photo'))", + "fs.readFileSync('/home/user/inputs/copied-photo')", + "getFileBase64('unselected')", + 'getFileBase64(variable)', + "slide.addText('photo-1')", + ].join('\n') + ) + }) + + it.each(['javascript', 'python'] as const)( + 'copies unfinished %s document source without partial reference rewrites', + (language) => { + const source = [ + 'link = "/api/files/view/photo-1"', + 'image = getFileBase64("photo-1")', + 'unfinished = "unterminated', + ].join('\n') + expect(rewriteCopiedFileReferences(source, maps, language)).toBe(source) + } + ) +}) diff --git a/apps/sim/lib/workspace-files/copy-references.ts b/apps/sim/lib/workspace-files/copy-references.ts new file mode 100644 index 00000000000..7cc2f07f9ec --- /dev/null +++ b/apps/sim/lib/workspace-files/copy-references.ts @@ -0,0 +1,182 @@ +import { + type DocumentSourceLanguage, + iterateDocumentFileReferences, +} from '@/lib/uploads/documents/references' +import type { EditableFileOwner } from '@/lib/workspace-files/ownership' + +interface CopiedFileReferenceMaps { + sourceOwner: EditableFileOwner + destinationOwner: EditableFileOwner + fileIds: ReadonlyMap + fileKeys: ReadonlyMap +} + +interface SourceReplacement { + start: number + end: number + value: string +} + +const FILE_REFERENCE_RE = + /(^|[\s("'<>`])((?:[A-Za-z][A-Za-z0-9+.-]*:\/\/|\/\/|sim:file\/|\/(?:api\/(?:files\/(?:view|serve)\/|projects\/)|workspace\/|projects\/))[^\s)"'<>`]+)/g + +function absoluteReferenceEnd(content: string, start: number): number { + let parentheses = 0 + let end = start + for (; end < content.length; end++) { + const character = content[end] + if (/[\s"'<>`]/.test(character)) break + if (character === '(') parentheses++ + if (character === ')') { + if (parentheses === 0) break + parentheses-- + } + } + return end +} + +function decodedIdentity(value: string): string | undefined { + try { + const decoded = decodeURIComponent(value) + return /^[A-Za-z0-9_-]+$/.test(decoded) ? decoded : undefined + } catch { + return undefined + } +} + +function destinationBytes(owner: EditableFileOwner, fileId: string): string { + return owner.entityType === 'project' + ? `/api/projects/${encodeURIComponent(owner.entityId)}/files/${encodeURIComponent(fileId)}/content` + : `/api/files/view/${encodeURIComponent(fileId)}` +} + +function destinationPage(owner: EditableFileOwner, fileId: string): string { + const namespace = owner.entityType === 'project' ? 'projects' : 'workspace' + return `/${namespace}/${encodeURIComponent(owner.entityId)}/files/${encodeURIComponent(fileId)}` +} + +function matchesOwner(owner: EditableFileOwner, entityType: string, entityId: string): boolean { + return owner.entityType === entityType && owner.entityId === decodedIdentity(entityId) +} + +function rewriteFileReference(reference: string, maps: CopiedFileReferenceMaps): string | null { + const hashIndex = reference.indexOf('#') + const fragment = hashIndex < 0 ? '' : reference.slice(hashIndex) + const withoutFragment = hashIndex < 0 ? reference : reference.slice(0, hashIndex) + const queryIndex = withoutFragment.indexOf('?') + const pathname = queryIndex < 0 ? withoutFragment : withoutFragment.slice(0, queryIndex) + const query = queryIndex < 0 ? null : new URLSearchParams(withoutFragment.slice(queryIndex + 1)) + const mappedId = (spelledId: string) => { + const fileId = decodedIdentity(spelledId) + return fileId === undefined ? undefined : maps.fileIds.get(fileId) + } + + const simLink = /^sim:file\/([^/]+)$/.exec(pathname) + if (simLink) { + const fileId = mappedId(simLink[1]) + if (!fileId) return null + if (query === null) return `sim:file/${encodeURIComponent(fileId)}${fragment}` + const entries = [...query] + const entry = entries[0] + if ( + entries.length !== 1 || + !entry || + entry[0] !== maps.sourceOwner.entityType || + entry[1] !== maps.sourceOwner.entityId + ) { + return null + } + return `sim:file/${encodeURIComponent(fileId)}?${maps.destinationOwner.entityType}=${encodeURIComponent(maps.destinationOwner.entityId)}${fragment}` + } + + const serve = /^\/api\/files\/serve\/(?:s3\/|blob\/|gcs\/)?(.+)$/.exec(pathname) + if (serve) { + if (query !== null) { + const entries = [...query] + const entry = entries[0] + if ( + entries.length !== 1 || + !entry || + entry[0] !== 'context' || + entry[1] !== maps.sourceOwner.entityType + ) { + return null + } + } + let key: string + try { + key = decodeURIComponent(serve[1]) + } catch { + return null + } + if (!key.startsWith(`${maps.sourceOwner.entityType}/${maps.sourceOwner.entityId}/`)) return null + const fileId = maps.fileKeys.get(key) + return fileId ? destinationBytes(maps.destinationOwner, fileId) + fragment : null + } + + if (query !== null) return null + const view = /^\/api\/files\/view\/([^/]+)$/.exec(pathname) + if (view) { + const fileId = mappedId(view[1]) + return fileId ? destinationBytes(maps.destinationOwner, fileId) + fragment : null + } + const page = /^\/(workspace|projects)\/([^/]+)\/files\/([^/]+)$/.exec(pathname) + if (page) { + const entityType = page[1] === 'projects' ? 'project' : 'workspace' + if (!matchesOwner(maps.sourceOwner, entityType, page[2])) return null + const fileId = mappedId(page[3]) + return fileId ? destinationPage(maps.destinationOwner, fileId) + fragment : null + } + const projectContent = /^\/api\/projects\/([^/]+)\/files\/([^/]+)\/content$/.exec(pathname) + if (projectContent) { + if (!matchesOwner(maps.sourceOwner, 'project', projectContent[1])) return null + const fileId = mappedId(projectContent[2]) + return fileId ? destinationBytes(maps.destinationOwner, fileId) + fragment : null + } + return null +} + +/** Repoints only the canonical selected identities; it neither discovers nor copies dependencies. */ +export function rewriteCopiedFileReferences( + content: string, + maps: CopiedFileReferenceMaps, + language: DocumentSourceLanguage | null = null +): string { + if (!content || (maps.fileIds.size === 0 && maps.fileKeys.size === 0)) return content + const replacements: SourceReplacement[] = [] + let protectedEnd = 0 + for (const match of content.matchAll(FILE_REFERENCE_RE)) { + const reference = match[2] + const start = match.index + match[1].length + if (start < protectedEnd) continue + if (/^(?:[A-Za-z][A-Za-z0-9+.-]*:\/\/|\/\/)/.test(reference)) { + protectedEnd = absoluteReferenceEnd(content, start) + continue + } + const value = rewriteFileReference(reference, maps) + if (value === null) continue + replacements.push({ start, end: start + reference.length, value }) + } + if (language) { + try { + for (const { fileId, start, end } of iterateDocumentFileReferences(content, language)) { + const value = maps.fileIds.get(fileId) + if (value !== undefined) replacements.push({ start, end, value }) + } + } catch (error) { + if (error instanceof DocCompileUserError) return content + throw error + } + } + replacements.sort((left, right) => left.start - right.start) + const chunks: string[] = [] + let offset = 0 + for (const { start, end, value } of replacements) { + chunks.push(content.slice(offset, start), value) + offset = end + } + chunks.push(content.slice(offset)) + return chunks.join('') +} + +import { DocCompileUserError } from '@/lib/uploads/documents/compile-error' diff --git a/apps/sim/lib/workspace-files/copy.ts b/apps/sim/lib/workspace-files/copy.ts new file mode 100644 index 00000000000..2701955b8d2 --- /dev/null +++ b/apps/sim/lib/workspace-files/copy.ts @@ -0,0 +1,213 @@ +import { folder, type WorkspaceFileRow, workspaceFiles } from '@sim/db/schema' +import { and, asc, inArray, isNull, or } from 'drizzle-orm' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbTransaction } from '@/lib/db/types' +import { collectDescendantFolderIdsFrom, indexFolderChildren } from '@/lib/folders/subtree' +import { + assertFileFolderTarget, + buildWorkspaceFileFolderPathMap, + createFileFolder, + listFileFolders, +} from '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' +import { + commitFileCreateInTx, + mapFileRecord, + type OwnedFileRecord, + type PlannedFileIdentity, + type StagedFileContent, +} from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { + getBoundWorkspaceFileSecretProvenanceByMetadata, + type WorkspaceFileSecretProvenance, +} from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' +import { getWorkspaceFileSize, MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' +import { MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS } from '@/lib/workspace-files/limits' +import type { EditableFileOwner } from '@/lib/workspace-files/ownership' +import { fileFolderOwnerCondition, fileOwnerCondition } from '@/lib/workspace-files/ownership-query' + +interface CopySelection { + owner: EditableFileOwner + fileIds: readonly string[] + folderIds: readonly string[] +} + +interface CopyFolder { + id: string + name: string + parentId: string | null + updatedAt: Date +} + +interface FileCopySnapshot { + owner: EditableFileOwner + files: WorkspaceFileRow[] + folders: CopyFolder[] + provenance: Map + bytes: number +} + +export interface CopiedFileItems { + files: OwnedFileRecord[] + folders: Awaited> +} + +function requireBoundedSelection(size: number) { + if (size > MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS) { + throw new OrchestrationError('validation', 'Copy expands beyond the file selection limit') + } +} + +/** Captures only explicit source selections and their bounded active descendants. */ +export async function snapshotFileCopyInTx( + tx: DbTransaction, + source: CopySelection +): Promise { + if (!source.fileIds.length && !source.folderIds.length) { + throw new OrchestrationError('validation', 'Copy requires a source selection') + } + const candidates = source.folderIds.length + ? await tx + .select({ + id: folder.id, + name: folder.name, + parentId: folder.parentId, + updatedAt: folder.updatedAt, + }) + .from(folder) + .where(and(fileFolderOwnerCondition(source.owner), isNull(folder.deletedAt))) + .orderBy(asc(folder.id)) + .limit(MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS + 1) + : [] + requireBoundedSelection(candidates.length) + const byId = new Map(candidates.map((row) => [row.id, row])) + const children = indexFolderChildren(candidates) + const selectedFolders = new Set() + for (const id of source.folderIds) { + if (!byId.has(id)) throw new OrchestrationError('not_found', 'Source folder not found') + selectedFolders.add(id) + for (const childId of collectDescendantFolderIdsFrom(children, id)) selectedFolders.add(childId) + } + requireBoundedSelection(selectedFolders.size) + const files = await tx + .select() + .from(workspaceFiles) + .where( + and( + fileOwnerCondition(source.owner), + isNull(workspaceFiles.deletedAt), + or( + source.fileIds.length ? inArray(workspaceFiles.id, [...source.fileIds]) : undefined, + selectedFolders.size ? inArray(workspaceFiles.folderId, [...selectedFolders]) : undefined + ) + ) + ) + .orderBy(asc(workspaceFiles.id)) + .limit(MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS + 1) + .for('share') + requireBoundedSelection(files.length + selectedFolders.size) + const fileIds = new Set(files.map((row) => row.id)) + if (source.fileIds.some((id) => !fileIds.has(id))) { + throw new OrchestrationError('not_found', 'Source file not found') + } + const bytes = files.reduce((sum, row) => sum + getWorkspaceFileSize(row), 0) + if (!Number.isSafeInteger(bytes) || bytes > MAX_BUFFERED_TRANSFER_BYTES) { + throw new OrchestrationError('payload_too_large', 'Copy exceeds the buffered transfer limit') + } + const folders = candidates.filter((row) => selectedFolders.has(row.id)) + const provenance = await getBoundWorkspaceFileSecretProvenanceByMetadata(tx, files) + return { owner: source.owner, files, folders, provenance, bytes } +} + +function snapshotIdentity(snapshot: FileCopySnapshot) { + return JSON.stringify({ + owner: snapshot.owner, + folders: snapshot.folders, + files: snapshot.files.map((file) => ({ + id: file.id, + key: file.key, + name: file.originalName, + folderId: file.folderId, + size: getWorkspaceFileSize(file), + type: file.contentType, + updatedAt: file.updatedAt, + contentUpdatedAt: file.contentUpdatedAt, + provenance: snapshot.provenance.get(file.id), + })), + }) +} + +/** Refuses changed bytes, metadata, classification, or recursive membership after external staging. */ +export function requireUnchangedFileCopy(prepared: FileCopySnapshot, current: FileCopySnapshot) { + if (snapshotIdentity(prepared) !== snapshotIdentity(current)) { + throw new OrchestrationError('conflict', 'Source selection changed during copy; retry') + } +} + +/** Commits a fresh tree and current heads through the shared identity/provenance writers. */ +export async function commitFileCopyInTx( + tx: DbTransaction, + args: { + snapshot: FileCopySnapshot + destination: { owner: EditableFileOwner; folderId: string | null } + userId: string + staged: ReadonlyMap + identities: ReadonlyMap + } +): Promise { + const { snapshot, destination, userId } = args + const targetFolderId = await assertFileFolderTarget(destination.owner, destination.folderId, tx) + const pending = new Map(snapshot.folders.map((row) => [row.id, row])) + const mapped = new Map() + const folders: CopiedFileItems['folders'] = [] + while (pending.size) { + let progressed = false + for (const [id, source] of pending) { + if (source.parentId && pending.has(source.parentId)) continue + const created = await createFileFolder( + { + owner: destination.owner, + userId, + name: source.name, + parentId: source.parentId + ? (mapped.get(source.parentId) ?? targetFolderId) + : targetFolderId, + exactName: false, + }, + tx + ) + folders.push(created) + mapped.set(id, created.id) + pending.delete(id) + progressed = true + } + if (!progressed) + throw new OrchestrationError('conflict', 'Source folder hierarchy contains a cycle') + } + const createdFiles: WorkspaceFileRow[] = [] + for (const source of snapshot.files) { + const staged = args.staged.get(source.id) + const secretProvenance = snapshot.provenance.get(source.id) + const identity = args.identities.get(source.id) + if (!staged || !secretProvenance || !identity) + throw new Error('Copied file staging is incomplete') + createdFiles.push( + await commitFileCreateInTx(tx, { + owner: destination.owner, + staged, + identity, + userId, + folderId: source.folderId + ? (mapped.get(source.folderId) ?? targetFolderId) + : targetFolderId, + secretProvenance, + }) + ) + } + const paths = buildWorkspaceFileFolderPathMap( + await listFileFolders(destination.owner, undefined, tx) + ) + return { + files: createdFiles.map((file) => mapFileRecord(file, destination.owner, paths)), + folders, + } +} diff --git a/apps/sim/lib/workspace-files/delivery.integration.ts b/apps/sim/lib/workspace-files/delivery.integration.ts new file mode 100644 index 00000000000..92a748a7d5e --- /dev/null +++ b/apps/sim/lib/workspace-files/delivery.integration.ts @@ -0,0 +1,215 @@ +import { mkdtempSync } from 'node:fs' +import { mkdir, rm, truncate, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join } from 'node:path' +import { db } from '@sim/db' +import { permissions, user, userStats, workspace, workspaceFiles } from '@sim/db/schema' +import { deleteWorkspaceFixture, insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures' +import { createSessionPrincipal } from '@sim/testing/factories/principal.factory' +import { setUploadDirServer, uploadsSetupMock } from '@sim/testing/mocks/uploads-setup.mock' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { and, eq, inArray } from 'drizzle-orm' +import { afterAll, beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/uploads/core/setup.server', () => uploadsSetupMock) + +import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { + markWorkspaceFileSecretProvenanceUnknown, + type WorkspaceFileSecretProvenance, +} from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' +import * as storage from '@/lib/uploads/core/storage-service' +import { MAX_BUFFERED_TRANSFER_BYTES } from '@/lib/uploads/shared/types' +import { downloadWorkspaceFileStream } from '@/lib/workspace-files/application/download-workspace-file' +import { observeWorkspaceFileDelivery } from '@/lib/workspace-files/application/file-delivery-observer' +import { readWorkspaceInlineFile } from '@/lib/workspace-files/application/read-workspace-inline-file' + +const uploadRoot = mkdtempSync(join(tmpdir(), 'sim-delivery-test-')) +setUploadDirServer(uploadRoot) +const fixtures: { ownerId: string; editorId: string; workspaceId: string }[] = [] +const checks: { name: string; status: string; durationMs: number; error?: string }[] = [] +beforeEach(() => vi.restoreAllMocks()) +function check(name: string, run: () => Promise) { + it(name, async () => { + const started = performance.now() + try { + await run() + checks.push({ name, status: 'passed', durationMs: performance.now() - started }) + } catch (error) { + checks.push({ + name, + status: 'failed', + durationMs: performance.now() - started, + error: getErrorMessage(error), + }) + throw error + } + }) +} +async function fixture() { + const ownerId = generateId() + const editorId = generateId() + const workspaceId = generateId() + await db.insert(user).values( + [ownerId, editorId].map((id) => ({ + id, + name: 'Delivery fixture', + email: `${id}@delivery.invalid`, + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + })) + ) + await db.insert(userStats).values({ id: generateId(), userId: ownerId }) + await insertWorkspaceFixture(db, { + id: workspaceId, + ownerId, + billedAccountUserId: ownerId, + name: 'Delivery', + workspaceMode: 'personal', + }) + await db.insert(permissions).values({ + id: generateId(), + userId: editorId, + entityType: 'workspace', + entityId: workspaceId, + permissionType: 'admin', + }) + fixtures.push({ ownerId, editorId, workspaceId }) + return { ownerId, editorId, workspaceId, principal: createSessionPrincipal({ userId: editorId }) } +} +describe('Workspace delivery against current PostgreSQL authority and local storage', () => { + for (const mutation of ['membership', 'revision', 'deletion'] as const) { + check( + `workspace inline delivery cancels its opened stream after ${mutation} changes`, + async () => { + const f = await fixture() + const file = await uploadWorkspaceFile( + f.workspaceId, + f.editorId, + Buffer.alloc(1024 * 1024, 42), + 'image.png', + 'image/png', + { notifyWorkspaceChange: false } + ) + const open = storage.downloadFileStream + let source: Awaited> | undefined + vi.spyOn(storage, 'downloadFileStream').mockImplementationOnce(async (options) => { + source = await open(options) + if (mutation === 'membership') { + await db + .delete(permissions) + .where( + and(eq(permissions.userId, f.editorId), eq(permissions.entityId, f.workspaceId)) + ) + } else { + await db + .update(workspaceFiles) + .set( + mutation === 'deletion' + ? { deletedAt: new Date() } + : { contentUpdatedAt: new Date(Date.now() + 1000) } + ) + .where(eq(workspaceFiles.id, file.id)) + } + return source + }) + await expect( + readWorkspaceInlineFile.execute({ + principal: f.principal, + input: { workspaceId: f.workspaceId, fileId: file.id }, + }) + ).rejects.toBeInstanceOf(Error) + expect(source?.destroyed).toBe(true) + } + ) + } +}) + +check( + 'downloads above the buffering ceiling stream incrementally and release storage on cancellation', + async () => { + const f = await fixture() + const file = await uploadWorkspaceFile( + f.workspaceId, + f.editorId, + Buffer.from('streamed'), + 'large.bin', + 'application/octet-stream', + { notifyWorkspaceChange: false } + ) + const size = MAX_BUFFERED_TRANSFER_BYTES + 1024 + await truncate(join(uploadRoot, file.key), size) + await db.update(workspaceFiles).set({ sizeBytes: size }).where(eq(workspaceFiles.id, file.id)) + const open = storage.downloadFileStream + let source: Awaited> | undefined + vi.spyOn(storage, 'downloadFileStream').mockImplementationOnce(async (options) => { + source = await open(options) + return source + }) + const result = await downloadWorkspaceFileStream.execute({ + principal: f.principal, + input: { fileId: file.id }, + }) + expect(result.contentLength).toBe(size) + const reader = result.stream.getReader() + const first = await reader.read() + expect(first.done).toBe(false) + expect(first.value?.length).toBeLessThan(size) + await reader.cancel() + expect(source?.destroyed).toBe(true) + } +) + +check( + 'same-revision provenance downgrade reaches the returned stream and delivery observer', + async () => { + const f = await fixture() + const file = await uploadWorkspaceFile( + f.workspaceId, + f.editorId, + Buffer.from('classification race'), + 'race.txt', + 'text/plain', + { notifyWorkspaceChange: false, secretProvenance: { status: 'exact', entries: [] } } + ) + const open = storage.downloadFileStream + vi.spyOn(storage, 'downloadFileStream').mockImplementationOnce(async (options) => { + const source = await open(options) + await markWorkspaceFileSecretProvenanceUnknown(f.workspaceId, [file.id]) + return source + }) + const observed: (WorkspaceFileSecretProvenance | undefined)[] = [] + const result = await observeWorkspaceFileDelivery( + async (provenance) => { + observed.push(provenance) + }, + () => + downloadWorkspaceFileStream.execute({ + principal: f.principal, + input: { fileId: file.id, includeSecretProvenance: true }, + }) + ) + await result.stream.cancel() + const [current] = await db.select().from(workspaceFiles).where(eq(workspaceFiles.id, file.id)) + expect(current.contentUpdatedAt).toEqual(file.contentUpdatedAt) + expect({ returned: result.secretProvenance, observed: observed.at(-1) }).toEqual({ + returned: { status: 'unknown' }, + observed: { status: 'unknown' }, + }) + } +) + +afterAll(async () => { + for (const fixture of fixtures) { + await deleteWorkspaceFixture(db, eq(workspace.id, fixture.workspaceId)) + await db.delete(user).where(inArray(user.id, [fixture.ownerId, fixture.editorId])) + } + await rm(uploadRoot, { recursive: true, force: true }) + const report = process.env.FILE_DELIVERY_INTEGRATION_REPORT_PATH + if (report) { + await mkdir(dirname(report), { recursive: true }) + await writeFile(report, JSON.stringify({ checks }, null, 2)) + } +}) diff --git a/apps/sim/lib/workspace-files/download-selection.ts b/apps/sim/lib/workspace-files/download-selection.ts new file mode 100644 index 00000000000..bc5c583af77 --- /dev/null +++ b/apps/sim/lib/workspace-files/download-selection.ts @@ -0,0 +1,61 @@ +import { OrchestrationError } from '@/lib/core/orchestration/types' +import { parseFolderPath } from '@/lib/folders/paths' +import { + collectDescendantFolderIdsFrom, + type FolderNode, + indexFolderChildren, +} from '@/lib/folders/subtree' +import { parseWorkspaceFileFolderDisplayPath } from '@/lib/workspace-files/folder-display-path' +import { MAX_WORKSPACE_FILE_BULK_REQUEST_IDS } from '@/lib/workspace-files/limits' + +export interface FileDownloadSelection { + fileIds: readonly string[] + folderIds: readonly string[] + folderPaths?: readonly string[] +} + +/** Normalizes the bounded explicit selection before either owner's directory walk. */ +export function normalizeFileDownloadSelection(input: FileDownloadSelection) { + const fileIds = [...new Set(input.fileIds)] + const folderIds = [...new Set(input.folderIds)] + const folderPaths = [...new Set(input.folderPaths ?? [])] + if (fileIds.length > MAX_WORKSPACE_FILE_BULK_REQUEST_IDS) + throw new OrchestrationError( + 'validation', + `Too many file IDs selected. Select ${MAX_WORKSPACE_FILE_BULK_REQUEST_IDS} or fewer files.` + ) + if (folderIds.length + folderPaths.length > MAX_WORKSPACE_FILE_BULK_REQUEST_IDS) + throw new OrchestrationError( + 'validation', + `Too many folders selected. Select ${MAX_WORKSPACE_FILE_BULK_REQUEST_IDS} or fewer folders.` + ) + if (!fileIds.length && !folderIds.length && !folderPaths.length) + throw new OrchestrationError('validation', 'No files selected for download') + return { fileIds, folderIds, folderPaths } +} + +/** Expands canonical parent relationships, never textual path prefixes. */ +export function expandFileDownloadFolders( + input: FileDownloadSelection, + folders: readonly FolderNode[], + displayPaths: ReadonlyMap +): Set { + const selected = new Set(input.folderIds) + if (input.folderPaths?.length) { + const byPath = new Map() + for (const folder of folders) { + const path = displayPaths.get(folder.id) + if (path) byPath.set(parseWorkspaceFileFolderDisplayPath(path).join('\0'), folder.id) + } + for (const path of input.folderPaths) { + const id = byPath.get(parseFolderPath(path).join('\0')) + if (!id) throw new OrchestrationError('validation', `Folder not found: ${path}`) + selected.add(id) + } + } + const children = indexFolderChildren(folders) + for (const id of [...selected]) { + for (const descendant of collectDescendantFolderIdsFrom(children, id)) selected.add(descendant) + } + return selected +} diff --git a/apps/sim/lib/workspace-files/extraction-lease.ts b/apps/sim/lib/workspace-files/extraction-lease.ts new file mode 100644 index 00000000000..58271ee106f --- /dev/null +++ b/apps/sim/lib/workspace-files/extraction-lease.ts @@ -0,0 +1,79 @@ +import { idempotencyKey } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { getErrorMessage } from '@sim/utils/errors' +import { isRecordLike } from '@sim/utils/object' +import { and, eq, sql } from 'drizzle-orm' +import { IdempotencyService } from '@/lib/core/idempotency/service' +import { OrchestrationError } from '@/lib/core/orchestration/types' +import type { DbTransaction } from '@/lib/db/types' +import type { EditableFileOwner } from '@/lib/workspace-files/ownership' + +const logger = createLogger('FileArchiveLease') +const leases = new IdempotencyService({ + namespace: 'file-archive', + ttlSeconds: 6 * 60, + forceStorage: 'database', +}) + +/** Cooperative work budget leaves time for compensation before the lease expires. */ +export const FILE_EXTRACTION_BUDGET_MS = 180_000 + +interface FileExtractionLease { + readonly key: string + readonly token: string +} + +/** Holds an exact, token-fenced database lease without memoizing any extraction result. */ +export async function withFileExtractionLease( + owner: EditableFileOwner, + fileId: string, + action: (lease: FileExtractionLease) => Promise +): Promise { + const claim = await leases.atomicallyClaim( + 'extract', + `${owner.entityType}:${owner.entityId}:${fileId}` + ) + if (!claim.claimed) + throw new OrchestrationError('conflict', 'This archive is already being unzipped') + if (!claim.claimToken) throw new Error('Archive extraction lease is missing its fencing token') + try { + return await action(Object.freeze({ key: claim.normalizedKey, token: claim.claimToken })) + } finally { + await leases + .release(claim.normalizedKey, claim.storageMethod, claim.claimToken) + .catch((error) => { + logger.warn('Failed to release archive extraction lease', { + owner, + fileId, + error: getErrorMessage(error), + }) + }) + } +} + +/** Serializes publication with claim replacement and refuses expired or superseded work. */ +export async function requireFileExtractionLeaseInTx( + tx: DbTransaction, + lease: FileExtractionLease +) { + const [claim] = await tx + .select({ result: idempotencyKey.result }) + .from(idempotencyKey) + .where( + and( + eq(idempotencyKey.key, lease.key), + sql`${idempotencyKey.result}->>'claimToken' = ${lease.token}` + ) + ) + .for('update') + .limit(1) + const result = claim?.result + if ( + !isRecordLike(result) || + result.status !== 'in-progress' || + typeof result.inProgressExpiresAt !== 'number' || + result.inProgressExpiresAt <= Date.now() + ) { + throw new OrchestrationError('conflict', 'Archive extraction lease expired; retry') + } +} diff --git a/apps/sim/lib/workspace-files/limits.ts b/apps/sim/lib/workspace-files/limits.ts index c1b80bad120..d5b89b45483 100644 --- a/apps/sim/lib/workspace-files/limits.ts +++ b/apps/sim/lib/workspace-files/limits.ts @@ -9,3 +9,4 @@ export const MAX_WORKSPACE_FILE_BULK_AFFECTED_ITEMS = 5_000 * validate, resolve, and only then fail — and so the two ceilings cannot drift. */ export const MAX_ZIP_DOWNLOAD_FILES = 100 +export const MAX_ZIP_DOWNLOAD_BYTES = 250 * 1024 * 1024 diff --git a/apps/sim/lib/workspace-files/owner-paths.ts b/apps/sim/lib/workspace-files/owner-paths.ts new file mode 100644 index 00000000000..27b65246241 --- /dev/null +++ b/apps/sim/lib/workspace-files/owner-paths.ts @@ -0,0 +1,15 @@ +import { + type FileOwnerAdapters, + requireFileOwnerAdapter, +} from '@/lib/workspace-files/owner-adapters' +import type { FileOwner } from '@/lib/workspace-files/ownership' + +const namespaces: FileOwnerAdapters<(entityId: string, path: string) => string> = { + workspace: (_entityId, path) => path, + project: (entityId, path) => `projects/${encodeURIComponent(entityId)}/${path}`, +} + +/** Preserves the legacy workspace alias while other registered owners have explicit namespaces. */ +export function fileOwnerVfsPath(owner: FileOwner, path: string): string { + return requireFileOwnerAdapter(namespaces, owner)(owner.entityId, path) +} diff --git a/apps/sim/lib/workspace-files/ownership.ts b/apps/sim/lib/workspace-files/ownership.ts index f61b3b78972..223e3ad8e6c 100644 --- a/apps/sim/lib/workspace-files/ownership.ts +++ b/apps/sim/lib/workspace-files/ownership.ts @@ -60,3 +60,8 @@ export function resolveFileOwner(file: PersistedFileOwnership): FileOwner | null } return null } + +/** Compares canonical owner identity without treating attribution as authority. */ +export function matchesFileOwner(owner: FileOwner | null, assertedOwner: FileOwner): boolean { + return owner?.entityType === assertedOwner.entityType && owner.entityId === assertedOwner.entityId +} diff --git a/apps/sim/lib/workspace-files/page-compile.test.ts b/apps/sim/lib/workspace-files/page-compile.test.ts index b9eae33f957..670494e6108 100644 --- a/apps/sim/lib/workspace-files/page-compile.test.ts +++ b/apps/sim/lib/workspace-files/page-compile.test.ts @@ -62,6 +62,47 @@ describe('compileSimPage', () => { expect(html).toContain('src="/api/files/view/img9"') }) + it('resolves a qualified Project file without treating its owner query as part of the ID', () => { + const html = compileSimPage( + '---\ntitle: T\n---\n![diagram](sim:file/img%2D9?project=project%2Done)\n[open](sim:file/img%2D9?project=project%2Done#details)', + { workspaceId: 'ambient-workspace', baseUrl: 'https://sim.example/' } + ) + expect(html).toContain('src="https://sim.example/api/projects/project-one/files/img-9/content"') + expect(html).toContain( + 'href="https://sim.example/projects/project-one/files/img-9#details" data-sim-link=""' + ) + }) + + it('retains an explicit workspace owner when a Project page contains a workspace file link', () => { + const html = compileSimPage( + '---\ntitle: T\n---\n![diagram](sim:file/img9?workspace=source-workspace)\n[open](sim:file/img9?workspace=source-workspace)', + { projectId: 'ambient-project' } + ) + expect(html).toContain('src="/api/files/view/img9"') + expect(html).toContain('href="/workspace/source-workspace/files/img9" data-sim-link=""') + expect(html).not.toContain('/api/projects/ambient-project/files/img9') + }) + + it('leaves ambiguous and malformed file ownership inert instead of inventing a byte URL', () => { + const links = [ + 'sim:file/img9?project=one&workspace=two', + 'sim:file/img9?project=one&project=two', + 'sim:file/img9?organization=one', + 'sim:file/img9?project=', + 'sim:file/img9?project=one%2Ftwo', + 'sim:file/img%252F9?project=one', + 'sim:file/%E0%A4%A?project=one', + ] + const html = compileSimPage( + `---\ntitle: T\n---\n${links.map((link) => `[open](${link}) ![image](${link})`).join('\n')}`, + { projectId: 'ambient-project' } + ) + expect(html).not.toContain('/api/projects/') + expect(html).not.toContain('/api/files/view/') + expect(html.match(/href="sim:file\//g)).toHaveLength(links.length) + expect(html.match(/src="sim:file\//g)).toHaveLength(links.length) + }) + it('escapes html in yaml-derived values', () => { const html = compileSimPage( '---\ntitle: T\n---\n```sim:kv\n- { key: "'], + ['code.js', 'application/javascript', 'globalThis.deliveryExecuted=true'], + ['mismatch.txt', 'text/html', ''], + ]) { + const file = await create('project', name, type, content) + const path = `${prefix}/${required(file.id)}/content` + const response = await request(path) + assert.equal(response.status, 200) + assert.equal(response.headers.get('cache-control'), 'private, no-store') + assert.equal(response.headers.get('x-content-type-options'), 'nosniff') + if (name.endsWith('.svg')) + assert.match(response.headers.get('content-security-policy') ?? '', /sandbox/) + if (name.endsWith('.html') || name.endsWith('.js')) + assert.match(response.headers.get('content-disposition') ?? '', /^attachment;/) + if (!name.endsWith('.svg')) + assert.match(response.headers.get('content-security-policy') ?? '', /default-src/) + const download = await request(`${prefix}/download?fileIds=${required(file.id)}`) + assert.equal(download.status, 200) + assert.match(download.headers.get('content-disposition') ?? '', /^attachment;/) + assert.match(download.headers.get('content-security-policy') ?? '', /default-src/) + const archive = await JSZip.loadAsync(await download.arrayBuffer()) + assert.equal(await archive.file(required(file.name))?.async('string'), content) + const head = await request(path, { method: 'HEAD' }) + assert.equal(head.status, 200) + assert.equal(await head.text(), '') + assert.equal(head.headers.get('cache-control'), 'private, no-store') + const denied = await request(path, { method: 'HEAD' }, true) + assert.equal(denied.status, 401) + assert.equal(denied.headers.get('content-disposition'), null) + assert.match(denied.headers.get('content-security-policy') ?? '', /default-src/) + const bypass = await request(`/api/files/serve/${required(file.key)}`) + assert.equal(bypass.status, 404) + } + const generated = await create( + 'project', + 'head.pdf', + 'text/x-pdflibjs', + 'throw new Error("HEAD must never execute this source")' + ) + const head = await request(`${prefix}/${required(generated.id)}/artifact`, { method: 'HEAD' }) + assert.equal(head.status, 200) + } +) +await check( + 'v2 Project SVG delivery and current, retained, missing and generated HEAD admission', + async () => { + const source = '' + const file = await create('project', 'v2-sandbox.svg', 'image/svg+xml', source) + const detail = `/api/v2/projects/${projectId}/files/${required(file.id)}` + const headers = { 'X-API-Key': personalKey } + const response = await request(`${detail}/content`, { headers }, true) + assert.equal(response.status, 200) + assert.equal(await response.text(), source) + assert.match(response.headers.get('content-security-policy') ?? '', /sandbox/) + assert.equal(response.headers.get('cache-control'), 'private, no-store') + const metadataResponse = await request(`${detail}/metadata`, { headers }, true) + assert.equal(metadataResponse.status, 200) + const metadata = toRecord(toRecord(await metadataResponse.json()).data) + const updated = await request( + `${detail}/content`, + { + method: 'PUT', + headers, + body: JSON.stringify({ + content: `${source}\n`, + encoding: 'utf-8', + expectedRevision: required(metadata.revision), + }), + }, + true + ) + assert.equal(updated.status, 200) + for (const suffix of ['/content', '/versions/1/content']) { + const head = await request(`${detail}${suffix}`, { method: 'HEAD', headers }, true) + assert.equal(head.status, 200) + assert.equal(await head.text(), '') + assert.equal(head.headers.get('cache-control'), 'private, no-store') + const denied = await request(`${detail}${suffix}`, { method: 'HEAD' }, true) + assert.equal(denied.status, 401) + assert.equal(denied.headers.get('content-disposition'), null) + } + const missing = await request( + `${detail}/versions/999999/content`, + { method: 'HEAD', headers }, + true + ) + assert.equal(missing.status, 404) + const generated = await create( + 'project', + 'v2-head.pdf', + 'text/x-pdflibjs', + 'throw new Error("HEAD must not compile")' + ) + for (const path of [ + `/api/v2/projects/${projectId}/files/${required(generated.id)}/content`, + `/api/v2/projects/${projectId}/files/bulk-download?fileIds=${required(generated.id)}`, + ]) { + const head = await request(path, { method: 'HEAD', headers }, true) + assert.equal(head.status, 200) + assert.equal(await head.text(), '') + assert.equal(head.headers.get('cache-control'), 'private, no-store') + } + } +) +await check('Workspace immutable URLs, authorized 304 and browser cache reuse', async () => { + const file = await create('workspace', 'cache.txt', 'text/plain', 'workspace cache bytes') + const path = `/api/files/serve/${required(file.key)}` + const first = await request(path) + assert.equal(first.status, 200) + const etag = required(first.headers.get('etag')) + const conditional = await request(path, { headers: { 'If-None-Match': etag } }) + assert.equal(conditional.status, 304) + assert.equal(conditional.headers.get('x-content-type-options'), 'nosniff') + const denied = await request(path, { headers: { 'If-None-Match': etag } }, true) + assert.equal(denied.status, 401) + const immutable = await request(`${path}?v=1`) + assert.equal(immutable.headers.get('cache-control'), 'private, max-age=31536000, immutable') + const browser = await chromium.launch({ channel: 'chrome', headless: true }) + try { + const context = await browser.newContext() + await context.addCookies( + cookie.split('; ').map((entry) => ({ + name: entry.slice(0, entry.indexOf('=')), + value: entry.slice(entry.indexOf('=') + 1), + url: base.origin, + })) + ) + const page = await context.newPage() + await page.goto(new URL('/api/health', base).href) + const cdp = await context.newCDPSession(page) + await cdp.send('Network.enable') + const cached: string[] = [] + cdp.on('Network.requestServedFromCache', (event) => cached.push(event.requestId)) + for (let attempt = 0; attempt < 2; attempt++) { + const text = await page.evaluate( + async (url) => (await fetch(url)).text(), + `${path}?v=browser` + ) + assert.equal(text, 'workspace cache bytes') + } + const timing = await page.evaluate(() => + performance.getEntriesByType('resource').map((entry) => { + const resource = entry as PerformanceResourceTiming + return { + name: resource.name, + transferSize: resource.transferSize, + encodedBodySize: resource.encodedBodySize, + } + }) + ) + await writeFile(`${reportPath}.cache.json`, JSON.stringify({ cached, timing }, null, 2)) + assert.ok( + cached.length > 0 || + timing + .filter((entry) => entry.name.endsWith('?v=browser')) + .some((entry) => entry.transferSize === 0 && entry.encodedBodySize > 0), + 'Second browser fetch must use its private cache' + ) + const svg = await create( + 'workspace', + 'sandbox.svg', + 'image/svg+xml', + '' + ) + await page.goto(new URL(`/api/files/serve/${required(svg.key)}`, base).href) + assert.equal(await page.evaluate('globalThis.deliveryExecuted'), undefined) + await page.screenshot({ path: `${reportPath}.svg.png` }) + } finally { + await browser.close() + } +}) +await check('Nonfile and file JSON responses retain the application CSP', async () => { + for (const path of ['/api/health', prefix]) { + const response = await request(path) + assert.equal(response.status, 200) + assert.match(response.headers.get('content-security-policy') ?? '', /default-src/) + assert.doesNotMatch(response.headers.get('content-security-policy') ?? '', /sandbox/) + } +}) +if (checks.some((check) => !check.passed)) process.exitCode = 1 diff --git a/apps/sim/scripts/test-file-list-realtime-e2e.ts b/apps/sim/scripts/test-file-list-realtime-e2e.ts new file mode 100644 index 00000000000..808c291be3f --- /dev/null +++ b/apps/sim/scripts/test-file-list-realtime-e2e.ts @@ -0,0 +1,526 @@ +import assert from 'node:assert/strict' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { db } from '@sim/db' +import { member, permissions, projectWorkspace, workspace as workspaceTable } from '@sim/db/schema' +import { insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures' +import { ROOM_ACCESS_REVOKED_EVENT } from '@sim/realtime-protocol/events' +import { getErrorMessage } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' +import { generateId } from '@sim/utils/id' +import { toArray, toRecord } from '@sim/utils/object' +import { and, eq } from 'drizzle-orm' +import { io, type Socket } from 'socket.io-client' + +function required(value: unknown): string { + assert.ok(typeof value === 'string' && value, 'Required local runtime value missing') + return value +} +const base = new URL(required(process.env.FILE_LIST_REALTIME_BASE_URL)) +const relay = new URL(required(process.env.FILE_LIST_REALTIME_RELAY_URL)) +const database = new URL(required(process.env.DATABASE_URL)) +for (const value of [base, relay, database]) + assert.ok(['localhost', '127.0.0.1', '[::1]'].includes(value.hostname), 'Local runtime required') +assert.match(database.pathname, /test/i, 'Disposable database required') +const reportPath = required(process.env.FILE_LIST_REALTIME_REPORT_PATH) +const fixtureDir = required(process.env.FILE_LIST_REALTIME_FIXTURE_DIR) +const account = toRecord(JSON.parse(await readFile(join(fixtureDir, 'owner-account.json'), 'utf8'))) +const ownerCookie = toArray(account.cookies) + .map((value) => required(value).split(';')[0]) + .join('; ') +const sockets: Socket[] = [] +const checks: { name: string; status: 'passed' | 'failed'; durationMs: number; error?: string }[] = + [] +const statuses: { method: string; path: string; status: number }[] = [] +const fixture = { workspaceId: '', projectId: '', secondEnvironmentId: '', readerId: '' } +let readerCookie = '' +const readerAccount = { + email: `list-reader-${generateId()}@example.test`, + password: `${generateId()}Aa9!`, +} +let ownerId = '' +let fileId = '' +let folderId = '' +let revision = '' +let reader: Socket +let owner: Socket +let workspaceObserver: Socket +const projectEvents: Record[] = [] +const workspaceEvents: Record[] = [] + +async function saveReport() { + await mkdir(dirname(reportPath), { recursive: true }) + await writeFile( + reportPath, + JSON.stringify( + { + checks, + statuses, + fixture, + fixtureMethod: + 'Existing real owner session and supported HTTP workspace/file writes; second environment and reader membership use isolated database fixtures; reader session uses real email signup.', + }, + null, + 2 + ) + ) +} +async function check(name: string, action: () => Promise) { + const start = performance.now() + try { + await action() + checks.push({ name, status: 'passed', durationMs: performance.now() - start }) + } catch (error) { + checks.push({ + name, + status: 'failed', + durationMs: performance.now() - start, + error: getErrorMessage(error), + }) + throw error + } finally { + await saveReport() + } +} +async function request( + path: string, + method = 'GET', + body?: object, + cookie = ownerCookie, + extraHeaders?: Record +) { + // boundary-raw-fetch: standalone E2E exercises actual authenticated HTTP and the internal service boundary. + const response = await fetch(new URL(path, base), { + method, + headers: { + Origin: base.origin, + 'Content-Type': 'application/json', + Cookie: cookie, + ...extraHeaders, + }, + body: body === undefined ? undefined : JSON.stringify(body), + redirect: 'manual', + signal: AbortSignal.timeout(90_000), + }) + statuses.push({ method, path: new URL(path, base).pathname, status: response.status }) + return { status: response.status, headers: response.headers, text: await response.text() } +} +function json(response: Awaited>, expected = 200) { + assert.equal(response.status, expected, 'Unexpected HTTP status; inspect private app logs') + return toRecord(JSON.parse(response.text)) +} +async function waitEvent( + socket: Socket, + event: string, + rejectEvent?: string, + timeout = 15_000, + action?: () => Promise +) { + let timer: ReturnType | undefined + let settled = false + let startDeadline: () => void = () => undefined + let accept: (value: unknown) => void + let deny: (value: unknown) => void + const cleanup = () => { + clearTimeout(timer) + socket.off(event, accept) + if (rejectEvent) socket.off(rejectEvent, deny) + } + const signal = new Promise>((resolve, reject) => { + function finish(error?: Error, value?: unknown) { + settled = true + cleanup() + if (error) reject(error) + else resolve(toRecord(value)) + } + accept = (value: unknown) => finish(undefined, value) + deny = (value: unknown) => + finish(new Error(`${event} rejected: ${String(toRecord(value).code ?? 'connection error')}`)) + socket.once(event, accept) + if (rejectEvent) socket.once(rejectEvent, deny) + /** The HTTP action has its own deadline; compilation time must not consume delivery time. */ + startDeadline = () => { + if (!settled) + timer = setTimeout(() => finish(new Error(`Timed out waiting for ${event}`)), timeout) + } + }) + /** Observe early rejection while the bounded HTTP action is still running. */ + void signal.catch(() => undefined) + try { + if (action) await action() + startDeadline() + return await signal + } finally { + cleanup() + } +} +async function connect(cookie: string) { + const token = required( + json(await request('/api/auth/socket-token', 'POST', undefined, cookie)).token + ) + const socket = io(relay.origin, { + autoConnect: false, + transports: ['websocket'], + reconnection: false, + auth: { token }, + extraHeaders: { Origin: base.origin }, + }) + sockets.push(socket) + const connected = waitEvent(socket, 'connect', 'connect_error') + socket.connect() + await connected + return socket +} +async function joinRoom(socket: Socket, type: 'project-files' | 'workspace-files', id: string) { + const result = waitEvent(socket, `join-${type}-success`, `join-${type}-error`) + socket.emit(`join-${type}`, { [type === 'project-files' ? 'projectId' : 'workspaceId']: id }) + return result +} +async function changed(action: () => Promise) { + const count = projectEvents.length + const event = await waitEvent(reader, 'project-files-changed', undefined, 15_000, action) + assert.deepEqual(Object.keys(event).sort(), ['projectId', 'timestamp']) + assert.equal(event.projectId, fixture.projectId) + assert.equal(typeof event.timestamp, 'number') + assert.equal(projectEvents.length, count + 1, 'One committed operation must emit one signal') +} +async function unchanged(action: () => Promise) { + const count = projectEvents.length + await action() + await sleep(200) + assert.equal(projectEvents.length, count, 'Rejected or staged work must not invalidate the list') +} +const prefix = () => `/api/projects/${fixture.projectId}/files` + +try { + await check('real sessions and canonical multi-environment Project fixture', async () => { + ownerId = required(toRecord(json(await request('/api/auth/get-session')).user).id) + const workspace = toRecord( + json( + await request('/api/workspaces', 'POST', { + name: `Realtime list proof ${generateId()}`, + skipDefaultWorkflow: true, + }) + ).workspace + ) + fixture.workspaceId = required(workspace.id) + const [binding] = await db + .select() + .from(projectWorkspace) + .where(eq(projectWorkspace.workspaceId, fixture.workspaceId)) + assert.ok(binding) + fixture.projectId = binding.projectId + const [canonicalWorkspace] = await db + .select() + .from(workspaceTable) + .where(eq(workspaceTable.id, fixture.workspaceId)) + assert.ok(canonicalWorkspace) + fixture.secondEnvironmentId = generateId() + await insertWorkspaceFixture(db, { + id: fixture.secondEnvironmentId, + name: 'Hidden realtime environment', + ownerId, + billedAccountUserId: canonicalWorkspace.billedAccountUserId, + organizationId: canonicalWorkspace.organizationId, + workspaceMode: canonicalWorkspace.workspaceMode, + forkedFromWorkspaceId: fixture.workspaceId, + }) + const signup = await request( + '/api/auth/sign-up/email', + 'POST', + { + ...readerAccount, + name: 'List proof reader', + }, + '' + ) + fixture.readerId = required(toRecord(json(signup).user).id) + readerCookie = signup.headers + .getSetCookie() + .map((cookie) => cookie.split(';')[0]) + .join('; ') + assert.ok(readerCookie) + await writeFile( + join(fixtureDir, 'file-list-realtime-fixture.json'), + JSON.stringify({ ...fixture, readerAccount, readerCookie }), + { mode: 0o600 } + ) + if (canonicalWorkspace.organizationId) + await db.insert(member).values({ + id: generateId(), + userId: fixture.readerId, + organizationId: canonicalWorkspace.organizationId, + role: 'member', + createdAt: new Date(), + }) + await db.insert(permissions).values({ + id: generateId(), + userId: fixture.readerId, + entityType: 'workspace', + entityId: fixture.workspaceId, + permissionType: 'read', + }) + const files = json(await request(prefix(), 'GET', undefined, readerCookie)) + assert.equal(toRecord(files.capabilities).canRead, true) + assert.equal(toRecord(files.capabilities).canWrite, false) + }) + await check( + 'service callback rejects an ordinary session or missing socket subject', + async () => { + const path = `/api/internal/project-file-list/${fixture.projectId}/access` + assert.equal((await request(path, 'POST')).status, 401) + assert.equal( + ( + await request(path, 'POST', undefined, '', { + 'x-api-key': required(process.env.INTERNAL_API_SECRET), + }) + ).status, + 401 + ) + } + ) + await check( + 'partial-access reader joins Project list while workspace wire remains unchanged', + async () => { + reader = await connect(readerCookie) + owner = await connect(ownerCookie) + workspaceObserver = await connect(ownerCookie) + assert.deepEqual(await joinRoom(reader, 'project-files', fixture.projectId), { + projectId: fixture.projectId, + }) + assert.deepEqual(await joinRoom(owner, 'project-files', fixture.projectId), { + projectId: fixture.projectId, + }) + assert.deepEqual(await joinRoom(workspaceObserver, 'workspace-files', fixture.workspaceId), { + workspaceId: fixture.workspaceId, + }) + reader.on('project-files-changed', (event) => projectEvents.push(toRecord(event))) + workspaceObserver.on('workspace-files-changed', (event) => + workspaceEvents.push(toRecord(event)) + ) + } + ) + await check( + 'unknown Project and malformed room IDs cannot widen or evict current membership', + async () => { + const denied = waitEvent(owner, 'join-project-files-error', 'join-project-files-success') + owner.emit('join-project-files', { projectId: generateId() }) + assert.equal((await denied).code, 'NOT_FOUND') + const malformed = waitEvent(reader, 'join-project-files-error', 'join-project-files-success') + reader.emit('join-project-files', { projectId: `${fixture.projectId}:other` }) + assert.equal((await malformed).code, 'INVALID_PAYLOAD') + const whitespace = waitEvent(reader, 'join-project-files-error', 'join-project-files-success') + reader.emit('join-project-files', { projectId: `${fixture.projectId} other` }) + assert.equal((await whitespace).code, 'INVALID_PAYLOAD') + } + ) + await check( + 'create broadcasts only after the canonical file is readable and never to the workspace room', + async () => { + const retainedMembership = await waitEvent( + owner, + 'project-files-changed', + undefined, + 15_000, + () => + changed(async () => { + const file = toRecord( + json( + await request(prefix(), 'POST', { + name: `live-${generateId()}.md`, + content: 'first version', + contentType: 'text/markdown', + encoding: 'utf-8', + }), + 201 + ).file + ) + fileId = required(file.id) + revision = required(json(await request(`${prefix()}/${fileId}/versions`)).revision) + }) + ) + assert.equal( + toRecord(json(await request(`${prefix()}/${fileId}`, 'GET', undefined, readerCookie)).file) + .id, + fileId + ) + assert.equal(retainedMembership.projectId, fixture.projectId) + assert.equal(workspaceEvents.length, 0) + } + ) + await check('read-only mutation and stale revision do not broadcast', async () => { + await changed(async () => { + json( + await request(`${prefix()}/${fileId}/content`, 'PUT', { + content: 'advance before the stale-write check', + encoding: 'utf-8', + expectedRevision: revision, + }) + ) + }) + await unchanged(async () => { + assert.equal( + (await request(`${prefix()}/${fileId}`, 'PATCH', { name: 'denied.md' }, readerCookie)) + .status, + 403 + ) + assert.equal( + ( + await request(`${prefix()}/${fileId}/content`, 'PUT', { + content: 'must not commit', + encoding: 'utf-8', + expectedRevision: revision, + }) + ).status, + 409 + ) + }) + }) + await check( + 'rename, folder creation, move, content save, archive and restore each notify current viewers', + async () => { + await changed(async () => { + json(await request(`${prefix()}/${fileId}`, 'PATCH', { name: 'renamed-live.md' })) + }) + await changed(async () => { + folderId = required( + toRecord( + json(await request(`${prefix()}/folders`, 'POST', { name: 'Live folder' })).folder + ).id + ) + }) + await changed(async () => { + json( + await request(`${prefix()}/move`, 'POST', { + fileIds: [fileId], + folderIds: [], + targetFolderId: folderId, + }) + ) + }) + revision = required(json(await request(`${prefix()}/${fileId}/versions`)).revision) + await changed(async () => { + json( + await request(`${prefix()}/${fileId}/content`, 'PUT', { + content: 'second version', + encoding: 'utf-8', + expectedRevision: revision, + }) + ) + }) + await changed(async () => { + json(await request(`${prefix()}/archive`, 'POST', { fileIds: [fileId], folderIds: [] })) + }) + await changed(async () => { + json(await request(`${prefix()}/${fileId}/restore`, 'POST', {})) + }) + } + ) + await check('copy emits only to the canonical destination owner', async () => { + const workspaceFile = toRecord( + json( + await request(`/api/workspaces/${fixture.workspaceId}/files`, 'POST', { + name: 'copy-source.txt', + content: 'copy bytes', + contentType: 'text/plain', + encoding: 'utf-8', + }), + 201 + ).file + ) + await sleep(200) + const before = workspaceEvents.length + await changed(async () => { + json( + await request('/api/files/copy', 'POST', { + source: { + owner: { entityType: 'workspace', entityId: fixture.workspaceId }, + fileIds: [required(workspaceFile.id)], + folderIds: [], + }, + destination: { + owner: { entityType: 'project', entityId: fixture.projectId }, + folderId: null, + }, + }), + 201 + ) + }) + assert.equal(workspaceEvents.length, before) + }) + await check( + 'upload allocation stays silent and only completed registration invalidates', + async () => { + let upload: Record = {} + const bytes = new TextEncoder().encode('streamed fixture bytes') + await unchanged(async () => { + upload = json( + await request(`${prefix()}/uploads`, 'POST', { + name: 'streamed.txt', + contentType: 'text/plain', + size: bytes.length, + }), + 201 + ) + }) + const transfer = toRecord(upload.transfer) + assert.equal(transfer.method, 'put') + const target = new URL(required(transfer.url), base) + assert.equal(target.origin, base.origin, 'Local storage transfer required') + const headers = new Headers() + for (const [key, value] of Object.entries(toRecord(transfer.headers))) + headers.set(key, required(value)) + // boundary-raw-fetch: the real local provider receives binary bytes at its signed upload URL. + const uploaded = await fetch(target, { method: 'PUT', headers, body: bytes }) + assert.ok(uploaded.ok) + await changed(async () => { + const result = json( + await request( + `${prefix()}/uploads/${required(toRecord(upload.session).id)}/complete`, + 'POST', + {}, + ownerCookie, + { 'upload-token': required(upload.uploadToken) } + ) + ) + assert.equal(result.status, 'completed') + assert.equal(toRecord(result.result).name, 'streamed.txt') + }) + } + ) + await check( + 'same live socket loses Project collection access after reader membership revocation', + async () => { + const revoked = waitEvent(reader, ROOM_ACCESS_REVOKED_EVENT, undefined, 75_000) + await db + .delete(permissions) + .where( + and( + eq(permissions.userId, fixture.readerId), + eq(permissions.entityType, 'workspace'), + eq(permissions.entityId, fixture.workspaceId) + ) + ) + const event = await revoked + assert.deepEqual(event.room, { type: 'project-files', id: fixture.projectId }) + const denied = await request(prefix(), 'GET', undefined, readerCookie) + assert.ok([403, 404].includes(denied.status)) + await unchanged(async () => { + json(await request(`${prefix()}/${fileId}`, 'PATCH', { name: 'after-revocation.md' })) + }) + const join = waitEvent(reader, 'join-project-files-error', 'join-project-files-success') + reader.emit('join-project-files', { projectId: fixture.projectId }) + assert.ok(['NOT_FOUND', 'ACCESS_DENIED'].includes(required((await join).code))) + } + ) +} catch { + process.exitCode = 1 +} finally { + for (const socket of sockets) socket.disconnect() + await saveReport() +} + +process.stdout.write( + `${JSON.stringify({ reportPath, passed: checks.filter((check) => check.status === 'passed').length, failed: checks.filter((check) => check.status === 'failed').length })}\n` +) +process.exit(checks.length > 0 && checks.every((check) => check.status === 'passed') ? 0 : 1) diff --git a/apps/sim/scripts/test-project-file-browser-e2e.ts b/apps/sim/scripts/test-project-file-browser-e2e.ts new file mode 100644 index 00000000000..b03f29d3693 --- /dev/null +++ b/apps/sim/scripts/test-project-file-browser-e2e.ts @@ -0,0 +1,206 @@ +import assert from 'node:assert/strict' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { toArray, toRecord } from '@sim/utils/object' + +const base = new URL(process.env.PROJECT_FILE_BROWSER_BASE_URL ?? 'http://127.0.0.1:3300') +if (!['127.0.0.1', 'localhost', '[::1]'].includes(base.hostname)) + throw new Error('Browser proof requires a disposable local runtime') +function required(value: unknown): string { + if (typeof value !== 'string' || !value) throw new Error('Required fixture field missing') + return value +} +const reportPath = required(process.env.PROJECT_FILE_BROWSER_REPORT_PATH) +const fixtureDir = required(process.env.PROJECT_FILE_BROWSER_FIXTURE_DIR) +const fixture = toRecord( + JSON.parse(await readFile(join(fixtureDir, 'http-project-fixture.json'), 'utf8')) +) +const account = toRecord(JSON.parse(await readFile(join(fixtureDir, 'owner-account.json'), 'utf8'))) +const cookie = toArray(account.cookies) + .map((cookie) => required(cookie).split(';')[0]) + .join('; ') +const projectId = required(toRecord(fixture.project).id) +const prefix = `/api/projects/${projectId}/files` +const checks: { name: string; passed: boolean; durationMs: number; error?: string }[] = [] +let folderId: string | undefined +let secondProjectId: string | undefined +async function request( + method: string, + path: string, + body?: Record, + anonymous = false +) { + // boundary-raw-fetch: this repeatable proof exercises actual session-authenticated HTTP against the disposable local app. + const response = await fetch(new URL(path, base), { + method, + headers: { + Origin: base.origin, + 'Content-Type': 'application/json', + ...(anonymous ? {} : { Cookie: cookie }), + }, + body: body === undefined ? undefined : JSON.stringify(body), + signal: AbortSignal.timeout(90_000), + }) + return { status: response.status, text: await response.text() } +} +function json(response: Awaited>, status = 200) { + assert.equal(response.status, status, response.text) + return toRecord(JSON.parse(response.text)) +} +async function check(name: string, run: () => Promise) { + const start = performance.now() + try { + await run() + checks.push({ name, passed: true, durationMs: performance.now() - start }) + } catch (error) { + checks.push({ + name, + passed: false, + durationMs: performance.now() - start, + error: getErrorMessage(error), + }) + } + await mkdir(dirname(reportPath), { recursive: true }) + await writeFile(reportPath, JSON.stringify({ checks }, null, 2)) +} +function listPath(query: Record, project = projectId) { + const params = new URLSearchParams({ + folderId: required(folderId), + limit: '1', + sortBy: 'name', + sortOrder: 'asc', + }) + for (const [key, value] of Object.entries(query)) { + params.delete(key) + for (const item of Array.isArray(value) ? value : [value]) params.append(key, item) + } + return `/api/projects/${project}/files?${params}` +} +try { + const record = toRecord(json(await request('GET', `/api/projects/${projectId}`)).project) + const second = json( + await request('POST', '/api/projects', { + organizationId: record.organizationId, + name: `Browser cursor ${generateId()}`, + initialEnvironment: { name: 'Sandbox' }, + }), + 201 + ) + secondProjectId = required(toRecord(second.project).id) + folderId = required( + toRecord( + json(await request('POST', `${prefix}/folders`, { name: `Browser proof ${generateId()}` })) + .folder + ).id + ) + const created = new Map() + for (const name of ['a.txt', 'b.txt', 'c.txt', 'y.png', 'z.png']) { + const file = toRecord( + json( + await request('POST', prefix, { + name, + content: 'x', + contentType: 'application/octet-stream', + encoding: 'utf-8', + folderId, + }), + 201 + ).file + ) + created.set(name, required(file.id)) + } + await check('complete-set type filter finds later matches with a one-row page', async () => { + const first = json(await request('GET', listPath({ types: 'image' }))) + assert.deepEqual( + toArray(first.items).map((item) => toRecord(item).id), + [created.get('y.png')] + ) + const second = json( + await request('GET', listPath({ types: 'image', cursor: required(first.nextCursor) })) + ) + assert.deepEqual( + toArray(second.items).map((item) => toRecord(item).id), + [created.get('z.png')] + ) + assert.equal(second.nextCursor, null) + }) + await check('equal size keys retain ascending names across both cursor directions', async () => { + for (const sortOrder of ['asc', 'desc']) { + const ids: string[] = [] + let cursor: string | undefined + for (let page = 0; page < 6; page++) { + const result = json( + await request( + 'GET', + listPath({ sortBy: 'size', sortOrder, ...(cursor ? { cursor } : {}) }) + ) + ) + ids.push(...toArray(result.items).map((item) => required(toRecord(item).id))) + if (!result.nextCursor) break + cursor = required(result.nextCursor) + } + assert.deepEqual(ids, [...created.values()]) + } + }) + const first = json(await request('GET', listPath({}))) + const cursor = required(first.nextCursor) + await check('a cursor cannot cross a filter or folder scope', async () => { + assert.equal((await request('GET', listPath({ cursor, types: 'image' }))).status, 400) + assert.equal((await request('GET', listPath({ cursor, creatorIds: generateId() }))).status, 400) + assert.equal((await request('GET', listPath({ cursor, folderId: generateId() }))).status, 400) + }) + await check('a cursor cannot cross an accessible Project or ordering', async () => { + assert.equal( + (await request('GET', listPath({ cursor }, required(secondProjectId)))).status, + 400 + ) + assert.equal((await request('GET', listPath({ cursor, sortOrder: 'desc' }))).status, 400) + assert.equal((await request('GET', listPath({ cursor, sortBy: 'size' }))).status, 400) + }) + await check('equivalent unordered filter sets preserve cursor continuity', async () => { + const first = json(await request('GET', listPath({ types: ['image', 'video'] }))) + const second = json( + await request( + 'GET', + listPath({ types: ['video', 'image'], cursor: required(first.nextCursor) }) + ) + ) + assert.equal(toRecord(toArray(second.items)[0]).id, created.get('z.png')) + }) + await check( + 'browser list requires a session and reports observed canonical creator labels', + async () => { + assert.equal((await request('GET', listPath({}), undefined, true)).status, 401) + const page = json(await request('GET', listPath({ limit: '100' }))) + for (const item of toArray(page.items)) { + const creator = toRecord(toRecord(item).creator) + assert.equal(typeof creator.name, 'string') + assert.equal(creator.deleted, false) + assert.equal('environmentIds' in creator, false) + assert.equal('permissions' in creator, false) + } + assert.equal(toArray(page.items).length, created.size) + } + ) +} catch (error) { + checks.push({ + name: 'fixture setup', + passed: false, + durationMs: 0, + error: getErrorMessage(error), + }) +} finally { + await check('fixture resources are archived', async () => { + if (folderId) + json(await request('POST', `${prefix}/archive`, { fileIds: [], folderIds: [folderId] })) + if (secondProjectId) json(await request('DELETE', `/api/projects/${secondProjectId}`)) + }) + await mkdir(dirname(reportPath), { recursive: true }) + await writeFile(reportPath, JSON.stringify({ checks }, null, 2)) +} +process.stdout.write( + `${JSON.stringify({ passed: checks.filter((check) => check.passed).length, total: checks.length, report: reportPath })}\n` +) +if (checks.some((check) => !check.passed)) process.exitCode = 1 diff --git a/apps/sim/scripts/test-project-file-history-e2e.ts b/apps/sim/scripts/test-project-file-history-e2e.ts new file mode 100644 index 00000000000..7d2a7914351 --- /dev/null +++ b/apps/sim/scripts/test-project-file-history-e2e.ts @@ -0,0 +1,466 @@ +import assert from 'node:assert/strict' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { toArray, toRecord } from '@sim/utils/object' + +const base = new URL(process.env.PROJECT_FILE_HISTORY_BASE_URL ?? 'http://127.0.0.1:3300') +if (!['127.0.0.1', 'localhost', '[::1]'].includes(base.hostname)) + throw new Error('History proof requires a disposable local runtime') +const fixtureDir = required(process.env.PROJECT_FILE_HISTORY_FIXTURE_DIR) +const reportPath = required( + process.env.PROJECT_FILE_HISTORY_REPORT_PATH, + 'PROJECT_FILE_HISTORY_REPORT_PATH is required' +) +const checks: { name: string; passed: boolean; durationMs: number; error?: string }[] = [] +function required(value: unknown, message = 'Required fixture field missing'): string { + if (typeof value !== 'string' || !value) throw new Error(message) + return value +} +async function fixture(name: string) { + return toRecord(JSON.parse(await readFile(join(fixtureDir, name), 'utf8'))) +} +const keys = await fixture('v2-fixture-keys.json') +const cookie = toArray((await fixture('owner-account.json')).cookies) + .map((value) => required(value).split(';')[0]) + .join('; ') +const projectFixture = await fixture('http-project-fixture.json') +const projectId = required(toRecord(projectFixture.project).id) +const workspaceId = required(toRecord(projectFixture.initialEnvironment).id) +const prefix = `/api/v2/projects/${projectId}/files` +const internalPrefix = `/api/projects/${projectId}/files` +const ids: string[] = [] +const workspaceIds: string[] = [] +const pendingUploads: { id: string; token: string }[] = [] +async function request( + method: string, + path: string, + body?: Record, + auth: 'personal' | 'workspace' | 'session' | 'none' = 'personal', + extraHeaders?: Record +) { + const headers = new Headers({ Origin: base.origin, 'Content-Type': 'application/json' }) + if (auth === 'session') headers.set('Cookie', cookie) + if (auth === 'personal' || auth === 'workspace') headers.set('X-API-Key', required(keys[auth])) + for (const [key, value] of Object.entries(extraHeaders ?? {})) headers.set(key, value) + // boundary-raw-fetch: this standalone proof exercises real authenticated HTTP against a disposable local runtime. + const response = await fetch(new URL(path, base), { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + signal: AbortSignal.timeout(90_000), + }) + return { status: response.status, headers: response.headers, text: await response.text() } +} +function json(response: Awaited>, status = 200) { + assert.equal(response.status, status) + return toRecord(JSON.parse(response.text)) +} +async function check(name: string, run: () => Promise) { + const start = performance.now() + try { + await run() + checks.push({ name, passed: true, durationMs: performance.now() - start }) + } catch (error) { + checks.push({ + name, + passed: false, + durationMs: performance.now() - start, + error: getErrorMessage(error), + }) + } + await mkdir(dirname(reportPath), { recursive: true }) + await writeFile(reportPath, JSON.stringify({ checks }, null, 2)) +} +try { + const create = async (content: string) => { + const file = toRecord( + json( + await request('POST', prefix, { + name: `history-${generateId()}.txt`, + content, + contentType: 'text/plain', + encoding: 'utf-8', + }), + 201 + ).data + ) + ids.push(required(file.id)) + return file + } + const created = await create('alpha') + const other = await create('other') + const detail = `${prefix}/${required(created.id)}` + const internal = `${internalPrefix}/${required(created.id)}` + const saved = toRecord( + json( + await request('PUT', `${detail}/content`, { + content: 'beta', + encoding: 'utf-8', + expectedRevision: created.revision, + }) + ).data + ) + json( + await request( + 'PUT', + `${internal}/content`, + { content: 'gamma', encoding: 'utf-8', expectedRevision: saved.revision }, + 'session' + ) + ) + const current = toRecord(json(await request('GET', `${detail}/metadata`)).data) + let cursor: string | undefined + await check('v2 pages preserve source and authorized author attribution', async () => { + const first = json(await request('GET', `${detail}/versions?limit=1`)) + const head = toRecord(toArray(first.data)[0]) + assert.equal(head.version, 3) + assert.equal(head.source, 'user') + assert.equal(typeof toRecord(toArray(head.authors)[0]).email, 'string') + assert.equal('key' in head, false) + assert.equal('authorUserIds' in head, false) + cursor = required(first.nextCursor) + const second = json( + await request('GET', `${detail}/versions?limit=1&cursor=${encodeURIComponent(cursor)}`) + ) + assert.equal(toRecord(toArray(second.data)[0]).version, 2) + assert.equal(toRecord(toArray(second.data)[0]).source, 'api') + const third = json( + await request( + 'GET', + `${detail}/versions?limit=1&cursor=${encodeURIComponent(required(second.nextCursor))}` + ) + ) + assert.equal(toRecord(toArray(third.data)[0]).version, 1) + assert.equal(third.nextCursor, null) + }) + await check('history cursors cannot cross file or ordering', async () => { + assert.ok(cursor) + assert.equal( + ( + await request( + 'GET', + `${prefix}/${required(other.id)}/versions?cursor=${encodeURIComponent(cursor)}` + ) + ).status, + 400 + ) + assert.equal( + ( + await request( + 'GET', + `${detail}/versions?sortOrder=asc&cursor=${encodeURIComponent(cursor)}` + ) + ).status, + 400 + ) + }) + await check('internal session list and metadata share authorized history', async () => { + const list = json(await request('GET', `${internal}/versions?limit=2`, undefined, 'session')) + assert.equal(toArray(list.versions).length, 2) + assert.equal(list.revision, current.revision) + const version = toRecord( + json(await request('GET', `${internal}/versions/1`, undefined, 'session')).version + ) + assert.equal(version.version, 1) + assert.equal(version.source, 'upload') + }) + await check('both surfaces deliver historical source bytes privately', async () => { + for (const [path, auth] of [ + [`${detail}/versions/1/content`, 'personal'], + [`${internal}/versions/1/content`, 'session'], + ] as const) { + const response = await request('GET', path, undefined, auth) + assert.equal(response.status, 200) + assert.equal(response.text, 'alpha') + assert.match(response.headers.get('cache-control') ?? '', /private/) + assert.equal(response.headers.get('x-content-type-options'), 'nosniff') + } + }) + await check('workspace keys and anonymous sessions cannot use Project history', async () => { + for (const [method, path, body] of [ + ['GET', `${detail}/versions`, undefined], + ['GET', `${detail}/versions/1/content`, undefined], + ['POST', `${detail}/versions/1/revert`, {}], + ['DELETE', `${detail}/versions/1`, undefined], + ] as const) + assert.equal((await request(method, path, body, 'workspace')).status, 403) + assert.equal((await request('GET', `${internal}/versions`, undefined, 'none')).status, 401) + }) + await check('revert rejects stale content and appends a source-attributed version', async () => { + assert.equal( + (await request('POST', `${detail}/versions/1/revert`, { expectedRevision: created.revision })) + .status, + 409 + ) + const result = toRecord( + json( + await request('POST', `${detail}/versions/1/revert`, { + expectedRevision: current.revision, + expectedCurrentVersion: 3, + }) + ).data + ) + assert.equal(result.reverted, true) + assert.equal(toRecord(result.version).version, 4) + assert.equal(toRecord(result.version).source, 'revert') + assert.equal(toRecord(result.version).restoredFromVersion, 1) + assert.notEqual(result.revision, current.revision) + assert.equal((await request('GET', `${detail}/content`)).text, 'alpha') + assert.equal( + (await request('POST', `${detail}/versions/2/revert`, { expectedRevision: current.revision })) + .status, + 409 + ) + }) + await check('current history survives while a superseded version can be deleted', async () => { + assert.equal((await request('DELETE', `${detail}/versions/4`)).status, 409) + assert.equal( + json(await request('DELETE', `${internal}/versions/2`, undefined, 'session')).deleted, + true + ) + assert.equal((await request('GET', `${detail}/versions/2`)).status, 404) + assert.equal((await request('GET', `${detail}/content`)).text, 'alpha') + }) + await check('history validates pagination and conceals foreign ownership', async () => { + assert.equal((await request('GET', `${detail}/versions?limit=1.5`)).status, 400) + assert.equal((await request('GET', `${detail}/versions?workspaceId=forged`)).status, 400) + assert.equal( + ( + await request( + 'GET', + `/api/v2/projects/${generateId()}/files/${required(created.id)}/versions` + ) + ).status, + 404 + ) + }) + await check( + 'workspace session history preserves source bytes and rejects foreign ownership', + async () => { + const file = toRecord( + json( + await request('POST', '/api/v2/files', { + workspaceId, + name: `workspace-history-${generateId()}.txt`, + content: 'workspace original', + }), + 201 + ).data + ) + const fileId = required(file.id) + workspaceIds.push(fileId) + const path = `/api/workspaces/${workspaceId}/files/${fileId}/versions` + const list = json(await request('GET', path, undefined, 'session')) + assert.equal(list.revision, file.revision) + assert.equal(toRecord(toArray(list.versions)[0]).version, 1) + assert.equal('key' in toRecord(toArray(list.versions)[0]), false) + const source = await request('GET', `${path}/1/content`, undefined, 'session') + assert.equal(source.status, 200) + assert.equal(source.text, 'workspace original') + assert.match(source.headers.get('cache-control') ?? '', /private/) + assert.equal((await request('GET', path, undefined, 'none')).status, 401) + assert.equal( + ( + await request( + 'GET', + `/api/workspaces/${generateId()}/files/${fileId}/versions`, + undefined, + 'session' + ) + ).status, + 404 + ) + assert.equal( + ( + await request( + 'GET', + `/api/workspaces/${workspaceId}/files/${required(created.id)}/versions`, + undefined, + 'session' + ) + ).status, + 404 + ) + } + ) + await check( + 'workspace session revert fences the observed history revision and retains later versions', + async () => { + const fileId = required(workspaceIds[0]) + const path = `/api/workspaces/${workspaceId}/files/${fileId}/versions` + const observed = json(await request('GET', path, undefined, 'session')) + const saved = toRecord( + json( + await request('PUT', `/api/v2/files/${fileId}/content`, { + workspaceId, + content: 'workspace concurrent save', + expectedRevision: observed.revision, + }) + ).data + ) + assert.equal( + ( + await request( + 'POST', + `${path}/1/revert`, + { expectedRevision: observed.revision }, + 'session' + ) + ).status, + 409 + ) + const head = json(await request('GET', path, undefined, 'session')) + assert.equal(head.revision, saved.revision) + const reverted = json( + await request('POST', `${path}/1/revert`, { expectedRevision: head.revision }, 'session') + ) + assert.equal(reverted.reverted, true) + assert.notEqual(reverted.revision, head.revision) + assert.equal( + (await request('GET', `/api/v2/files/${fileId}?workspaceId=${workspaceId}`)).text, + 'workspace original' + ) + const retained = await request('GET', `${path}/2/content`, undefined, 'session') + assert.equal(retained.status, 200) + assert.equal(retained.text, 'workspace concurrent save') + assert.equal( + toRecord(toArray(json(await request('GET', path, undefined, 'session')).versions)[0]) + .source, + 'revert' + ) + } + ) + await check('workspace session revert accepts an omitted optional revision guard', async () => { + const fileId = required(workspaceIds[0]) + const path = `/api/workspaces/${workspaceId}/files/${fileId}/versions` + const result = json(await request('POST', `${path}/2/revert`, {}, 'session')) + assert.equal(result.reverted, true) + assert.ok(required(result.revision)) + assert.equal( + (await request('GET', `/api/v2/files/${fileId}?workspaceId=${workspaceId}`)).text, + 'workspace concurrent save' + ) + }) + await check( + 'oversized historical bytes return 413 without changing the current head', + async () => { + const bytes = Buffer.alloc(100 * 1024 * 1024 + 1) + const upload = json( + await request( + 'POST', + `${internalPrefix}/uploads`, + { + name: `history-size-${generateId()}.bin`, + contentType: 'application/octet-stream', + size: bytes.length, + }, + 'session' + ), + 201 + ) + const transfer = toRecord(upload.transfer) + const uploadId = required(toRecord(upload.session).id) + const uploadToken = required(upload.uploadToken) + pendingUploads.push({ id: uploadId, token: uploadToken }) + assert.equal(transfer.method, 'multipart') + const partSize = transfer.partSize + const partCount = transfer.partCount + assert.equal(typeof partSize, 'number') + assert.equal(typeof partCount, 'number') + if (typeof partSize !== 'number' || typeof partCount !== 'number') + throw new Error('Multipart transfer sizes are missing') + assert.equal(partCount, Math.ceil(bytes.length / partSize)) + assert.ok(partCount <= 100) + const partNumbers = Array.from({ length: partCount }, (_, index) => index + 1) + const parts = toArray( + json( + await request( + 'POST', + `${internalPrefix}/uploads/${uploadId}/parts`, + { partNumbers }, + 'session', + { 'upload-token': uploadToken } + ) + ).parts + ) + assert.equal(parts.length, partCount) + for (const value of parts) { + const part = toRecord(value) + const partNumber = part.partNumber + if (typeof partNumber !== 'number') throw new Error('Multipart part number is missing') + const transferHeaders = new Headers() + for (const [key, header] of Object.entries(toRecord(part.headers))) + transferHeaders.set(key, required(header)) + // boundary-raw-fetch: this proof uploads actual oversized history through signed multipart transfer URLs. + const transferred: Response = await fetch(required(part.url), { + method: 'PUT', + headers: transferHeaders, + body: bytes.subarray((partNumber - 1) * partSize, partNumber * partSize), + signal: AbortSignal.timeout(90_000), + }) + assert.equal(transferred.ok, true) + } + const completed = json( + await request('POST', `${internalPrefix}/uploads/${uploadId}/complete`, {}, 'session', { + 'upload-token': uploadToken, + }) + ) + const fileId = required(toRecord(completed.result).id) + ids.push(fileId) + const largeDetail = `${prefix}/${fileId}` + const largeInternal = `${internalPrefix}/${fileId}` + const initial = toRecord(json(await request('GET', `${largeDetail}/metadata`)).data) + const small = toRecord( + json( + await request('PUT', `${largeDetail}/content`, { + content: 'small replacement', + encoding: 'utf-8', + expectedRevision: initial.revision, + }) + ).data + ) + const statuses: number[] = [] + for (const [path, auth] of [ + [largeDetail, 'personal'], + [largeInternal, 'session'], + ] as const) { + statuses.push((await request('GET', `${path}/versions/1/content`, undefined, auth)).status) + statuses.push( + ( + await request( + 'POST', + `${path}/versions/1/revert`, + { expectedRevision: small.revision }, + auth + ) + ).status + ) + } + assert.deepEqual(statuses, [413, 413, 413, 413]) + assert.equal( + toRecord(json(await request('GET', `${largeDetail}/metadata`)).data).revision, + small.revision + ) + assert.equal((await request('GET', `${largeDetail}/content`)).text, 'small replacement') + } + ) +} finally { + if (ids.length) await request('POST', `${prefix}/archive`, { fileIds: ids }) + if (workspaceIds.length) + await request( + 'POST', + `/api/workspaces/${workspaceId}/files/bulk-archive`, + { fileIds: workspaceIds, folderIds: [] }, + 'session' + ) + for (const upload of pendingUploads) + await request('DELETE', `${internalPrefix}/uploads/${upload.id}`, undefined, 'session', { + 'upload-token': upload.token, + }) +} +process.stdout.write( + `${JSON.stringify({ passed: checks.filter((check) => check.passed).length, total: checks.length, report: reportPath })}\n` +) +process.exitCode = checks.every((check) => check.passed) ? 0 : 1 diff --git a/apps/sim/scripts/test-project-file-inline-source-e2e.ts b/apps/sim/scripts/test-project-file-inline-source-e2e.ts new file mode 100644 index 00000000000..c594e4a0cbd --- /dev/null +++ b/apps/sim/scripts/test-project-file-inline-source-e2e.ts @@ -0,0 +1,129 @@ +import assert from 'node:assert/strict' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { getErrorMessage } from '@sim/utils/errors' +import { toArray, toRecord } from '@sim/utils/object' +import { createPublicFileContentSource } from '@/hooks/use-file-content-source' + +const base = new URL(required(process.env.PROJECT_INLINE_SOURCE_BASE_URL)) +assert.ok(['127.0.0.1', 'localhost', '[::1]'].includes(base.hostname)) +const fixturePath = required(process.env.PROJECT_INLINE_SOURCE_FIXTURE_PATH) +const reportPath = required(process.env.PROJECT_INLINE_SOURCE_REPORT_PATH) +const fixture = toRecord(JSON.parse(await readFile(fixturePath, 'utf8'))) +const account = toRecord( + JSON.parse(await readFile(join(dirname(fixturePath), 'owner-account.json'), 'utf8')) +) +const cookie = toArray(account.cookies) + .map((value) => required(value).split(';')[0]) + .join('; ') +const doc = toRecord(toArray(fixture.files).find((value) => toRecord(value).owner === 'project')) +const foreign = toRecord(fixture.wrongOwner) +const owner = { entityType: 'project', entityId: required(fixture.projectId) } as const +const token = required(doc.token) +const contentPath = `/api/files/public/${token}/content` +const ownPath = `/api/projects/${owner.entityId}/files/${required(doc.imageId)}/content` +const foreignPath = `/api/projects/${required(foreign.projectId)}/files/${required(foreign.fileId)}/content` +const publicSource = createPublicFileContentSource(token, contentPath, owner) +const checks: { name: string; passed: boolean; durationMs: number; error?: string }[] = [] + +function required(value: unknown): string { + assert.ok(typeof value === 'string' && value, 'Required fixture or environment field missing') + return value +} +async function request(path: string, authenticated = false) { + // boundary-raw-fetch: this proof pairs the actual browser source adapter with real private and public image delivery. + return fetch(new URL(path, base), { + headers: { ...(authenticated ? { Cookie: cookie } : {}), 'X-Forwarded-For': '203.0.113.242' }, + signal: AbortSignal.timeout(90_000), + }) +} +async function check(name: string, run: () => Promise) { + const start = performance.now() + try { + await run() + checks.push({ name, passed: true, durationMs: performance.now() - start }) + } catch (error) { + checks.push({ + name, + passed: false, + durationMs: performance.now() - start, + error: getErrorMessage(error), + }) + } +} + +await check( + 'The foreign image exists and the signed-in viewer can read its private URL', + async () => { + const response = await request(foreignPath, true) + assert.equal(response.status, 200) + assert.equal(response.headers.get('content-type'), 'image/png') + assert.ok((await response.arrayBuffer()).byteLength > 0) + } +) +await check('Private Project inline responses disable browser cache reuse', async () => { + const metadata = await request( + `/api/projects/${owner.entityId}/files/${required(doc.imageId)}`, + true + ) + assert.equal(metadata.status, 200) + const key = required(toRecord(toRecord(await metadata.json()).file).key) + for (const query of [ + `key=${encodeURIComponent(key)}`, + `fileId=${encodeURIComponent(required(doc.imageId))}`, + ]) { + const response = await request(`/api/projects/${owner.entityId}/files/inline?${query}`, true) + assert.equal(response.status, 200) + assert.equal(response.headers.get('cache-control'), 'private, no-store') + assert.ok((await response.arrayBuffer()).byteLength > 0) + } +}) +await check( + 'The document token grants its own image and conceals the foreign Project image', + async () => { + assert.equal( + (await request(`/api/files/public/${token}/inline?fileId=${required(doc.imageId)}`)).status, + 200 + ) + assert.equal( + (await request(`/api/files/public/${token}/inline?fileId=${required(foreign.fileId)}`)) + .status, + 404 + ) + const response = await request(contentPath) + assert.equal(response.status, 200) + assert.ok( + (await response.text()).includes(foreignPath), + 'Fixture must actually embed the foreign private URL' + ) + } +) +await check( + 'Canonical and private same-owner image references stay on the public token pipeline', + async () => { + const expected = `/api/files/public/${token}/inline?fileId=${required(doc.imageId)}` + assert.equal(publicSource.resolveImageSrc(ownPath), expected) + assert.equal( + publicSource.resolveImageSrc(`sim:file/${required(doc.imageId)}?project=${owner.entityId}`), + expected + ) + } +) +await check('Public sources omit foreign-owner private image URLs', async () => { + assert.equal(publicSource.resolveImageSrc(foreignPath), undefined) +}) + +await mkdir(dirname(reportPath), { recursive: true }) +await writeFile( + reportPath, + JSON.stringify( + { + boundary: + 'Real browser source adapter plus actual HTTP; native browser screenshot remains a separate check', + checks, + }, + null, + 2 + ) +) +process.exitCode = checks.some((check) => !check.passed) ? 1 : 0 diff --git a/apps/sim/scripts/test-project-file-public-e2e.ts b/apps/sim/scripts/test-project-file-public-e2e.ts new file mode 100644 index 00000000000..589ef6c9949 --- /dev/null +++ b/apps/sim/scripts/test-project-file-public-e2e.ts @@ -0,0 +1,495 @@ +import assert from 'node:assert/strict' +import { mkdir, readFile, truncate, unlink, writeFile } from 'node:fs/promises' +import { dirname, join, resolve, sep } from 'node:path' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId, generateShortId } from '@sim/utils/id' +import { toArray, toRecord } from '@sim/utils/object' +import Redis from 'ioredis' +import postgres from 'postgres' +import sharp from 'sharp' +import { encryptSecret } from '@/lib/core/security/encryption' +import { getStorageProvider } from '@/lib/uploads/config' +import { UPLOAD_DIR_SERVER } from '@/lib/uploads/core/setup.server' +import { storeCompiledDoc } from '@/lib/uploads/documents/compiled-store' + +/** Real public wire/cookie proof; SQL and Redis only seed disposable sharing and OTP fixtures. */ +const base = new URL(required(process.env.PROJECT_FILE_PUBLIC_BASE_URL)) +const databaseUrl = new URL(required(process.env.DATABASE_URL)) +const redisUrl = new URL(required(process.env.REDIS_URL)) +for (const url of [base, databaseUrl, redisUrl]) + assert.ok(['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname), 'Local runtime required') +assert.match(databaseUrl.pathname, /test/i, 'Disposable database required') +assert.equal(base.protocol, 'http:') +const reportPath = required(process.env.PROJECT_FILE_PUBLIC_REPORT_PATH) +const fixtureDir = required(process.env.PROJECT_FILE_PUBLIC_FIXTURE_DIR) +const keepFixtures = process.env.PROJECT_FILE_PUBLIC_KEEP_FIXTURES === '1' +const sql = postgres(databaseUrl.toString(), { max: 2 }) +const redis = new Redis(redisUrl.toString(), { maxRetriesPerRequest: 1 }) +const checks: { name: string; passed: boolean; durationMs: number; error?: string }[] = [] +const files: { owner: 'workspace' | 'project'; ownerId: string; id: string }[] = [] +const shares: { id: string; token: string; fileId: string; owner: 'workspace' | 'project' }[] = [] +const otpKeys: string[] = [] +const proofIp = '203.0.113.241' +function required(value: unknown): string { + assert.ok(typeof value === 'string' && value, 'Required environment or fixture field missing') + return value +} +async function fixture(name: string) { + return toRecord(JSON.parse(await readFile(join(fixtureDir, name), 'utf8'))) +} +const account = await fixture('owner-account.json') +const sessionCookie = toArray(account.cookies) + .map((value) => required(value).split(';')[0]) + .join('; ') +const projectId = required(toRecord((await fixture('http-project-fixture.json')).project).id) +const workspaceId = required(process.env.PROJECT_FILE_PUBLIC_WORKSPACE_ID) +const [actor] = await sql<{ email: string }[]>` + SELECT u.email FROM project p JOIN "user" u ON u.id = p.owner_id WHERE p.id = ${projectId} +` +assert.ok(actor, 'Project owner fixture missing') +const allowedEmail = actor.email +const password = 'Local-public-file-proof-password' +const encryptedPassword = (await encryptSecret(password)).encrypted +const image = await sharp({ + create: { width: 96, height: 96, channels: 4, background: '#1976d2' }, +}) + .png() + .toBuffer() + +async function request( + path: string, + options: { method?: string; body?: Record; cookie?: string } = {} +) { + const headers = new Headers({ + Origin: base.origin, + 'Content-Type': 'application/json', + 'X-Forwarded-For': proofIp, + }) + if (options.cookie) headers.set('Cookie', options.cookie) + // boundary-raw-fetch: this proof exercises actual public binary, RSC, and credential HTTP surfaces. + const response = await fetch(new URL(path, base), { + method: options.method ?? 'GET', + headers, + body: options.body === undefined ? undefined : JSON.stringify(options.body), + signal: AbortSignal.timeout(90_000), + redirect: 'manual', + }) + const buffer = Buffer.from(await response.arrayBuffer()) + return { status: response.status, headers: response.headers, buffer, text: buffer.toString() } +} +function json(response: Awaited>, status = 200) { + assert.equal(response.status, status) + return toRecord(JSON.parse(response.text)) +} +function assertBinaryError(response: Awaited>, status: 404 | 413) { + const body = json(response, status) + assert.deepEqual(Object.keys(body).sort(), ['error', 'message']) + assert.equal(body.error, status === 404 ? 'FileNotFoundError' : 'PayloadSizeLimitError') + if (status === 404) assert.equal(body.message, 'Not found') + else assert.ok(typeof body.message === 'string' && body.message.includes('104857600')) +} +async function check(name: string, run: () => Promise) { + const start = performance.now() + try { + await run() + checks.push({ name, passed: true, durationMs: performance.now() - start }) + } catch (error) { + checks.push({ + name, + passed: false, + durationMs: performance.now() - start, + error: getErrorMessage(error), + }) + } + await mkdir(dirname(reportPath), { recursive: true }) + await writeFile(reportPath, JSON.stringify({ checks }, null, 2)) +} +async function withEmptyRateBucket(key: string, run: () => Promise) { + const redisKey = `ratelimit:tb:${key}` + const original = await redis.hgetall(redisKey) + const ttl = await redis.pttl(redisKey) + await redis.hset(redisKey, { tokens: '0', lastRefillAt: String(Date.now()) }) + await redis.expire(redisKey, 120) + try { + await run() + } finally { + await redis.del(redisKey) + if (Object.keys(original).length) { + await redis.hset(redisKey, original) + if (ttl > 0) await redis.pexpire(redisKey, ttl) + } + } +} +async function expectRateLimit(path: string, options?: Parameters[1]) { + const response = await request(path, options) + assert.equal(response.status, 429) + assert.ok(Number(response.headers.get('retry-after')) > 0, 'Retry-After missing') +} +async function withSparseFile(fileId: string, bytes: number, run: () => Promise) { + assert.equal(getStorageProvider(), 'Local', 'Sparse boundary proof requires local storage') + const [row] = await sql<{ key: string }[]>`SELECT key FROM workspace_files WHERE id=${fileId}` + assert.ok(row && files.some((file) => file.id === fileId), 'Only this run fixture may be resized') + const path = resolve(UPLOAD_DIR_SERVER, row.key) + assert.ok(path.startsWith(`${resolve(UPLOAD_DIR_SERVER)}${sep}`)) + const original = await readFile(path) + try { + await truncate(path, bytes) + await run() + } finally { + await writeFile(path, original) + } +} +async function createFile( + owner: 'workspace' | 'project', + name: string, + content: string, + type: string, + encoding = 'utf-8' +) { + const ownerId = owner === 'project' ? projectId : workspaceId + const file = toRecord( + json( + await request(`/api/${owner === 'project' ? 'projects' : 'workspaces'}/${ownerId}/files`, { + method: 'POST', + cookie: sessionCookie, + body: { name, content, contentType: type, encoding }, + }), + 201 + ).file + ) + files.push({ owner, ownerId, id: required(file.id) }) + return file +} +async function shareFile(owner: 'workspace' | 'project', fileId: string) { + const id = generateId() + const token = generateShortId() + await sql`INSERT INTO public_share (id, resource_type, resource_id, entity_type, entity_id, workspace_id, created_by, token) + SELECT ${id}, 'file', f.id, CASE WHEN f.project_id IS NOT NULL THEN 'project' ELSE 'workspace' END, + coalesce(f.project_id, f.workspace_id), f.workspace_id, f.user_id, ${token} + FROM workspace_files f WHERE f.id = ${fileId}` + const share = { id, token, fileId, owner } + shares.push(share) + return share +} +function cookieFrom(response: Awaited>, shareId: string) { + const value = response.headers.get('set-cookie') ?? '' + assert.ok(value.startsWith(`file_auth_${shareId}=`), 'Expected resource-bound auth cookie') + for (const attribute of ['HttpOnly', 'SameSite=Lax', 'Path=/', 'Max-Age=86400']) + assert.ok( + value.toLowerCase().includes(attribute.toLowerCase()), + `Missing ${attribute} cookie attribute` + ) + return value.split(';')[0] +} +try { + const browserFixtures = [] + for (const owner of ['workspace', 'project'] as const) { + const suffix = generateShortId(8) + const photo = await createFile( + owner, + `public-photo-${suffix}.png`, + image.toString('base64'), + 'image/png', + 'base64' + ) + const reference = + owner === 'project' + ? `sim:file/${required(photo.id)}?project=${projectId}` + : `/api/files/view/${required(photo.id)}` + const privateReference = + owner === 'project' + ? `/api/projects/${projectId}/files/${required(photo.id)}/content` + : `/api/files/view/${required(photo.id)}` + const source = `# Public owner proof\n\n![Canonical image](${reference})\n\n![Private URL image](${privateReference})\n` + const doc = await createFile(owner, `public-document-${suffix}.md`, source, 'text/markdown') + const share = await shareFile(owner, required(doc.id)) + browserFixtures.push({ + owner, + fileId: doc.id, + imageId: photo.id, + token: share.token, + source, + privateUrl: `/workspace/${workspaceId}/files/${required(doc.id)}${owner === 'project' ? `?owner=project&projectId=${projectId}` : ''}`, + publicUrl: `/f/${share.token}`, + }) + const path = `/api/files/public/${share.token}` + await check(`${owner}: public metadata and source preserve safe wire and headers`, async () => { + const metadata = json(await request(path)) + assert.equal(metadata.name, doc.name) + assert.deepEqual(Object.keys(metadata).sort(), [ + 'name', + 'ownerName', + 'size', + 'token', + 'type', + 'workspaceName', + ]) + assert.ok( + typeof metadata.workspaceName === 'string' && metadata.workspaceName, + 'Canonical owner display name missing' + ) + const bytes = await request(`${path}/content`) + assert.equal(bytes.status, 200) + assert.equal(bytes.text, source) + assert.equal(bytes.headers.get('cache-control'), 'private, no-cache, must-revalidate') + assert.equal(bytes.headers.get('x-content-type-options'), 'nosniff') + assert.match(bytes.headers.get('content-disposition') ?? '', /filename=/) + }) + await check( + `${owner}: HEAD rejects a missing stored source without generating content`, + async () => { + assert.equal(getStorageProvider(), 'Local', 'Missing-object fixture requires local storage') + const file = await createFile( + owner, + `head-missing-${suffix}.txt`, + 'head source', + 'text/plain' + ) + const share = await shareFile(owner, required(file.id)) + const contentPath = `/api/files/public/${share.token}/content` + assert.equal((await request(contentPath, { method: 'HEAD' })).status, 200) + const [stored] = await sql< + { key: string }[] + >`SELECT key FROM workspace_files WHERE id = ${required(file.id)}` + const path = resolve(UPLOAD_DIR_SERVER, required(stored?.key)) + assert.ok(path.startsWith(`${resolve(UPLOAD_DIR_SERVER)}${sep}`)) + const bytes = await readFile(path) + try { + await unlink(path) + const missing = await request(contentPath, { method: 'HEAD' }) + assert.equal(missing.status, 404) + assert.equal(missing.buffer.length, 0) + } finally { + await writeFile(path, bytes) + } + } + ) + await check(`${owner}: inline image requires the current document grant`, async () => { + const bytes = await request(`${path}/inline?fileId=${required(photo.id)}`) + assert.equal(bytes.status, 200) + assert.ok(bytes.buffer.equals(image), 'Inline bytes differ from fixture') + assert.equal(bytes.headers.get('content-type'), 'image/png') + assertBinaryError(await request(`${path}/inline?fileId=unreferenced`), 404) + assert.equal( + (await request(`${path}/inline?fileId=${required(photo.id)}&key=other`)).status, + 400 + ) + }) + await check( + `${owner}: source and inline scan enforce independent real byte ceilings`, + async () => { + await withSparseFile(required(doc.id), 100 * 1024 * 1024 + 1, async () => { + assertBinaryError(await request(`${path}/content`), 413) + assertBinaryError(await request(`${path}/inline?fileId=${required(photo.id)}`), 404) + }) + await withSparseFile(required(photo.id), 100 * 1024 * 1024 + 1, async () => { + assertBinaryError(await request(`${path}/inline?fileId=${required(photo.id)}`), 413) + }) + } + ) + await check( + `${owner}: a small generated source cannot bypass the compiled output ceiling`, + async () => { + assert.equal(getStorageProvider(), 'Local', 'Artifact fixture requires local storage') + const source = `// Public output ceiling fixture ${suffix}` + const generated = await createFile( + owner, + `public-output-${suffix}.pptx`, + source, + 'text/x-pptxgenjs' + ) + const artifactShare = await shareFile(owner, required(generated.id)) + const contentPath = `/api/files/public/${artifactShare.token}/content` + const unavailable = await request(contentPath) + assert.equal( + json(unavailable, 409).error, + 'This document is still being prepared. Please try again shortly.' + ) + let key: string | undefined + await storeCompiledDoc( + { entityType: owner, entityId: owner === 'workspace' ? workspaceId : projectId }, + source, + 'pptx', + 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + Buffer.from('PK\u0003\u0004output-fixture'), + undefined, + (writtenKey) => { + key = writtenKey + } + ) + const path = resolve(UPLOAD_DIR_SERVER, required(key)) + assert.ok(path.startsWith(`${resolve(UPLOAD_DIR_SERVER)}${sep}`)) + try { + await truncate(path, 100 * 1024 * 1024 + 1) + assertBinaryError(await request(contentPath), 413) + } finally { + await unlink(path) + } + } + ) + await check(`${owner}: public read budgets precede target lookup`, async () => { + const unknown = `/api/files/public/${generateShortId()}` + for (const [scope, target] of [ + ['metadata', unknown], + ['content', `${unknown}/content`], + ['inline', `${unknown}/inline?fileId=unknown`], + ]) + await withEmptyRateBucket(`public-file:${scope}:${proofIp}`, () => expectRateLimit(target)) + }) + await check(`${owner}: public mode cannot mint a password cookie`, async () => { + const response = await request(path, { method: 'POST', body: { password } }) + json(response, 400) + assert.equal(response.headers.get('set-cookie'), null) + }) + await sql`UPDATE public_share SET auth_type='password', password=${encryptedPassword} WHERE id=${share.id}` + await check( + `${owner}: protected RSC hides filename while password cookie unlocks bytes`, + async () => { + const page = await request(`/f/${share.token}`) + assert.equal(page.status, 200) + assert.equal( + page.text.includes(required(doc.name)), + false, + 'Protected page disclosed filename' + ) + assert.equal(json(await request(path), 401).error, 'auth_required_password') + assert.equal( + json(await request(path, { method: 'POST', body: { password: 'wrong' } }), 401).error, + 'Invalid password' + ) + for (const rateKey of [ + `file-password:ip:${share.id}:${proofIp}`, + `file-password:resource:${share.id}`, + ]) + await withEmptyRateBucket(rateKey, () => + expectRateLimit(path, { method: 'POST', body: { password } }) + ) + const accepted = await request(path, { method: 'POST', body: { password } }) + assert.equal(json(accepted).authType, 'password') + const cookie = cookieFrom(accepted, share.id) + assert.equal((await request(`${path}/content`, { cookie })).text, source) + await sql`UPDATE public_share SET password=${(await encryptSecret(`${password}-changed`)).encrypted} WHERE id=${share.id}` + assert.equal((await request(`${path}/content`, { cookie })).status, 401) + } + ) + await sql`UPDATE public_share SET auth_type='email', password=NULL, allowed_emails=${sql.json([allowedEmail])} WHERE id=${share.id}` + await check( + `${owner}: email OTP denial, consumption, and cookie policy stay current`, + async () => { + const rejectedEmail = 'public-proof-rejected@example.invalid' + await withEmptyRateBucket(`file-otp:ip:${proofIp}`, () => + expectRateLimit(`${path}/otp`, { method: 'POST', body: { email: rejectedEmail } }) + ) + assert.equal( + json(await request(`${path}/otp`, { method: 'POST', body: { email: rejectedEmail } })) + .message, + 'Verification code sent' + ) + assert.equal( + ( + await request(`${path}/otp`, { + method: 'PUT', + body: { email: rejectedEmail, otp: '123456' }, + }) + ).status, + 403 + ) + const key = `otp:file:${allowedEmail}:${share.id}` + otpKeys.push(key) + await redis.set(key, '123456:0', 'EX', 900) + assert.equal( + ( + await request(`${path}/otp`, { + method: 'PUT', + body: { email: allowedEmail, otp: '000000' }, + }) + ).status, + 400 + ) + const accepted = await request(`${path}/otp`, { + method: 'PUT', + body: { email: allowedEmail, otp: '123456' }, + }) + assert.equal(json(accepted).authType, 'email') + const cookie = cookieFrom(accepted, share.id) + assert.equal(await redis.get(key), null, 'Successful verification did not consume OTP') + await redis.set(key, '123456:4', 'EX', 900) + assert.equal( + ( + await request(`${path}/otp`, { + method: 'PUT', + body: { email: allowedEmail, otp: '000000' }, + }) + ).status, + 429 + ) + assert.equal(await redis.get(key), null, 'Attempt exhaustion did not consume OTP') + assert.equal((await request(path, { cookie })).status, 200) + await sql`UPDATE public_share SET allowed_emails='[]'::json WHERE id=${share.id}` + assert.equal((await request(path, { cookie })).status, 401) + } + ) + await sql`UPDATE public_share SET auth_type='sso', allowed_emails=${sql.json([allowedEmail])} WHERE id=${share.id}` + await check(`${owner}: SSO eligibility alone never grants file access`, async () => { + for (const rateKey of [`file-sso:ip:${proofIp}`, `file-sso:resource:${share.id}`]) + await withEmptyRateBucket(rateKey, () => + expectRateLimit(`${path}/sso`, { method: 'POST', body: { email: allowedEmail } }) + ) + assert.equal( + json(await request(`${path}/sso`, { method: 'POST', body: { email: allowedEmail } })) + .eligible, + true + ) + assert.equal( + json( + await request(`${path}/sso`, { + method: 'POST', + body: { email: 'rejected@example.invalid' }, + }) + ).eligible, + false + ) + assert.equal(json(await request(path), 401).error, 'auth_required_sso') + assert.equal((await request(path, { cookie: sessionCookie })).status, 200) + }) + await sql`UPDATE public_share SET auth_type='public', password=NULL, allowed_emails='[]'::json WHERE id=${share.id}` + } + await writeFile( + join(fixtureDir, 'public-browser-fixtures.json'), + JSON.stringify({ projectId, workspaceId, files: browserFixtures }, null, 2), + { mode: 0o600 } + ) +} catch (error) { + checks.push({ + name: 'fixture setup', + passed: false, + durationMs: 0, + error: getErrorMessage(error), + }) +} finally { + if (!keepFixtures) { + for (const share of shares) + await sql`UPDATE public_share SET is_active=false WHERE id=${share.id}` + for (const owner of ['workspace', 'project'] as const) { + const owned = files.filter((file) => file.owner === owner) + const first = owned[0] + if (!first) continue + await check(`${owner}: fixture cleanup archives only this run's files`, async () => { + const prefix = `/api/${owner === 'project' ? 'projects' : 'workspaces'}/${first.ownerId}/files` + json( + await request(`${prefix}/${owner === 'project' ? 'archive' : 'bulk-archive'}`, { + method: 'POST', + cookie: sessionCookie, + body: { fileIds: owned.map((file) => file.id) }, + }) + ) + }) + } + } + for (const key of otpKeys) await redis.del(key) + await redis.quit() + await sql.end() + await mkdir(dirname(reportPath), { recursive: true }) + await writeFile(reportPath, JSON.stringify({ checks }, null, 2)) +} +process.exitCode = checks.some((entry) => !entry.passed) ? 1 : 0 diff --git a/apps/sim/scripts/test-project-file-rendered-e2e.ts b/apps/sim/scripts/test-project-file-rendered-e2e.ts new file mode 100644 index 00000000000..914ee96977d --- /dev/null +++ b/apps/sim/scripts/test-project-file-rendered-e2e.ts @@ -0,0 +1,429 @@ +import assert from 'node:assert/strict' +import { createHash } from 'node:crypto' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { basename, dirname, join } from 'node:path' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { toArray, toRecord } from '@sim/utils/object' +import JSZip from 'jszip' +import { PDFDocument, PDFName } from 'pdf-lib' +import sharp from 'sharp' + +type Auth = 'session' | 'personal' | 'workspace' | 'none' +type Format = 'docx' | 'pptx' | 'pdf' +interface CreatedFile { + id: string + projectId: string + name: string + revision: string + source?: string +} +interface Check { + name: string + passed: boolean + durationMs: number + error?: string +} + +const reportPath = required( + process.env.PROJECT_FILE_RENDERED_REPORT_PATH, + 'PROJECT_FILE_RENDERED_REPORT_PATH is required' +) +const fixtureDir = required(process.env.PROJECT_FILE_RENDERED_FIXTURE_DIR) +const runId = generateId() +const artifactDir = join(dirname(reportPath), `${basename(reportPath, '.json')}-${runId}`) +const checks: Check[] = [] +const requests: { method: string; path: string; auth: Auth; status: number; durationMs: number }[] = + [] +const artifacts: { format: Format; path: string; size: number; sha256: string }[] = [] +const files: CreatedFile[] = [] +const archived = new Set() +const documents = new Map() +const rendered = new Map() +const marker = `ProjectRendered${runId.replaceAll('-', '')}` +const mimeTypes = { + docx: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', + pptx: 'application/vnd.openxmlformats-officedocument.presentationml.presentation', + pdf: 'application/pdf', +} as const +const sourceMimeTypes = { + docx: 'text/x-docxjs', + pptx: 'text/x-pptxgenjs', + pdf: 'text/x-pdflibjs', +} as const +let base: URL +let cookie = '' +let keys: Record = {} +let projectId = '' +let foreignProjectId = '' +let asset: CreatedFile | undefined +let foreignAsset: CreatedFile | undefined +let firstImage = Buffer.alloc(0) +let secondImage = Buffer.alloc(0) + +function required(value: unknown, message = 'Required fixture field missing'): string { + assert.ok(typeof value === 'string' && value.length > 0, message) + return value +} + +async function fixture(name: string) { + return toRecord(JSON.parse(await readFile(join(fixtureDir, name), 'utf8'))) +} + +async function saveReport() { + await mkdir(dirname(reportPath), { recursive: true }) + await writeFile( + reportPath, + JSON.stringify( + { + runId, + boundary: 'Running HTTP application, real isolated-vm document tasks and Project assets', + limitation: 'Remote Python/XLSX rendering is not exercised without configured credentials', + checks, + requests, + artifacts, + }, + null, + 2 + ) + ) +} + +async function check(name: string, run: () => Promise) { + const start = performance.now() + try { + await run() + checks.push({ name, passed: true, durationMs: performance.now() - start }) + return true + } catch (error) { + checks.push({ + name, + passed: false, + durationMs: performance.now() - start, + error: getErrorMessage(error), + }) + return false + } finally { + await saveReport() + } +} + +async function request( + method: string, + path: string, + auth: Auth = 'personal', + body?: Record +) { + const headers = new Headers({ Origin: base.origin, 'Content-Type': 'application/json' }) + if (auth === 'session') headers.set('Cookie', cookie) + if (auth === 'personal' || auth === 'workspace') headers.set('X-API-Key', required(keys[auth])) + const start = performance.now() + // boundary-raw-fetch: this standalone proof validates real document bytes through the disposable local HTTP app. + const response = await fetch(new URL(path, base), { + method, + headers, + body: body === undefined ? undefined : JSON.stringify(body), + signal: AbortSignal.timeout(120_000), + }) + const buffer = Buffer.from(await response.arrayBuffer()) + requests.push({ + method, + path, + auth, + status: response.status, + durationMs: performance.now() - start, + }) + return { status: response.status, headers: response.headers, buffer } +} + +function json(response: Awaited>, status = 200) { + assert.equal(response.status, status, `Expected HTTP ${status}, received ${response.status}`) + assert.match(response.headers.get('content-type') ?? '', /application\/json/) + return toRecord(JSON.parse(response.buffer.toString('utf8'))) +} + +function filePath(file: CreatedFile) { + return `/api/v2/projects/${file.projectId}/files/${file.id}` +} + +function artifactPath(file: CreatedFile) { + return `/api/projects/${file.projectId}/files/${file.id}/artifact` +} + +function archivePath(file: CreatedFile) { + return `/api/v2/projects/${file.projectId}/files/bulk-download?fileIds=${encodeURIComponent(file.id)}` +} + +async function create( + ownerId: string, + name: string, + content: string | Buffer, + contentType: string +) { + const data = toRecord( + json( + await request('POST', `/api/v2/projects/${ownerId}/files`, 'personal', { + name, + content: typeof content === 'string' ? content : content.toString('base64'), + encoding: typeof content === 'string' ? 'utf-8' : 'base64', + contentType, + }), + 201 + ).data + ) + const file = { + id: required(data.id), + projectId: ownerId, + name, + revision: required(data.revision), + ...(typeof content === 'string' ? { source: content } : {}), + } + files.push(file) + return file +} + +function program(format: Format, imageId: string) { + if (format === 'docx') + return `addSection({ children: [new docx.Paragraph(${JSON.stringify(marker)}), new docx.Paragraph({ children: [await addImage(${JSON.stringify(imageId)}, {width: 32, height: 32})] })] });` + if (format === 'pptx') + return `const slide = pptx.addSlide(); slide.addText(${JSON.stringify(marker)}, {x: 0.5, y: 0.5, w: 8, h: 0.5}); await addImage(slide, ${JSON.stringify(imageId)}, {x: 1, y: 1, w: 1, h: 1});` + return `const page = pdf.addPage([320, 240]); const font = await pdf.embedFont(StandardFonts.Helvetica); page.drawText(${JSON.stringify(marker)}, {x: 10, y: 210, size: 8, font}); await drawImage(page, ${JSON.stringify(imageId)}, {x: 20, y: 30, width: 64, height: 64});` +} + +async function assertDocument(format: Format, buffer: Buffer, image: Buffer) { + if (format === 'pdf') { + assert.equal(buffer.subarray(0, 5).toString(), '%PDF-') + const pdf = await PDFDocument.load(buffer) + assert.equal(pdf.getPageCount(), 1) + const page = pdf.getPage(0) + assert.deepEqual(page.getSize(), { width: 320, height: 240 }) + assert.ok( + page.node.Resources()?.has(PDFName.of('XObject')), + 'PDF must contain the prepared image' + ) + return + } + const zip = await JSZip.loadAsync(buffer) + const xml = zip.file(format === 'docx' ? 'word/document.xml' : 'ppt/slides/slide1.xml') + assert.ok(xml, 'Rendered Office archive must contain its document XML') + assert.ok( + (await xml.async('string')).includes(marker), + 'Rendered document must contain the source marker' + ) + const media = zip.file(format === 'docx' ? /^word\/media\// : /^ppt\/media\//) + const bytes = await Promise.all(media.map((entry) => entry.async('nodebuffer'))) + assert.ok( + bytes.some((value) => value.equals(image)), + 'Rendered document must embed the current Project image bytes' + ) +} + +async function readArtifact(file: CreatedFile, format: Format) { + const response = await request('GET', artifactPath(file), 'session') + assert.equal(response.status, 200, `Real ${format} compilation returned HTTP ${response.status}`) + assert.equal(response.headers.get('content-type')?.split(';')[0], mimeTypes[format]) + return response.buffer +} + +try { + const ready = await check( + 'Local fixtures create source documents and images through real APIs', + async () => { + base = new URL(process.env.PROJECT_FILE_RENDERED_BASE_URL ?? 'http://127.0.0.1:3300') + assert.ok( + ['127.0.0.1', 'localhost', '[::1]'].includes(base.hostname), + 'Requires a disposable loopback runtime' + ) + keys = await fixture('v2-fixture-keys.json') + cookie = toArray((await fixture('owner-account.json')).cookies) + .map((value) => required(value).split(';')[0]) + .join('; ') + assert.ok(cookie) + projectId = required(toRecord((await fixture('http-project-fixture.json')).project).id) + foreignProjectId = + process.env.PROJECT_FILE_RENDERED_FOREIGN_PROJECT_ID ?? + required( + toRecord((await fixture('public-browser-wrong-owner-fixtures.json')).wrongOwner).projectId + ) + assert.notEqual(projectId, foreignProjectId) + firstImage = await sharp({ + create: { width: 16, height: 16, channels: 3, background: '#0055ff' }, + }) + .png() + .toBuffer() + secondImage = await sharp({ + create: { width: 16, height: 16, channels: 3, background: '#ff2200' }, + }) + .png() + .toBuffer() + asset = await create(projectId, `rendered-${runId}.png`, firstImage, 'image/png') + foreignAsset = await create( + foreignProjectId, + `rendered-foreign-${runId}.png`, + secondImage, + 'image/png' + ) + for (const format of ['docx', 'pptx', 'pdf'] as const) { + documents.set( + format, + await create( + projectId, + `rendered-${runId}.${format}`, + program(format, asset.id), + sourceMimeTypes[format] + ) + ) + } + } + ) + if (ready) { + await check('Artifact HEAD authorizes without executing a document program', async () => { + const file = await create( + projectId, + `head-only-${runId}.pdf`, + 'throw new Error("Artifact HEAD must not execute this program");', + sourceMimeTypes.pdf + ) + const head = await request('HEAD', artifactPath(file), 'session') + assert.equal(head.status, 200) + assert.equal(head.buffer.length, 0) + assert.equal((await request('HEAD', artifactPath(file), 'none')).status, 401) + const rendered = await request('GET', artifactPath(file), 'session') + assert.ok(rendered.status >= 400, 'The program must fail if actually rendered') + }) + for (const format of ['docx', 'pptx', 'pdf'] as const) { + await check( + `Real ${format.toUpperCase()} rendering returns a parseable document with a Project asset`, + async () => { + const file = documents.get(format) + assert.ok(file) + const buffer = await readArtifact(file, format) + await assertDocument(format, buffer, firstImage) + rendered.set(format, buffer) + const path = join(artifactDir, `rendered.${format}`) + await mkdir(artifactDir, { recursive: true }) + await writeFile(path, buffer) + artifacts.push({ + format, + path, + size: buffer.length, + sha256: createHash('sha256').update(buffer).digest('hex'), + }) + } + ) + } + await check('Personal-key bulk download returns the same compiled Office bytes', async () => { + const file = documents.get('docx') + const previous = rendered.get('docx') + assert.ok(file && previous, 'A successful initial render is required') + const response = await request('GET', archivePath(file)) + assert.equal(response.status, 200) + assert.equal(response.headers.get('content-type')?.split(';')[0], 'application/zip') + const archive = await JSZip.loadAsync(response.buffer) + const entry = archive.file(file.name) + assert.ok(entry) + assert.deepEqual(await entry.async('nodebuffer'), previous) + }) + await check( + 'Same-ID asset content revision invalidates the compiled document cache', + async () => { + assert.ok(asset) + const file = documents.get('docx') + const original = rendered.get('docx') + assert.ok(file && original, 'A successful initial render is required') + assert.deepEqual(await readArtifact(file, 'docx'), original) + const updated = toRecord( + json( + await request('PUT', `${filePath(asset)}/content`, 'personal', { + content: secondImage.toString('base64'), + encoding: 'base64', + expectedRevision: asset.revision, + }) + ).data + ) + assert.equal(updated.id, asset.id) + assert.notEqual(required(updated.revision), asset.revision) + const buffer = await readArtifact(file, 'docx') + await assertDocument('docx', buffer, secondImage) + assert.equal(buffer.equals(original), false) + await writeFile(join(artifactDir, 'rendered-updated.docx'), buffer) + } + ) + await check('A caller with both Projects cannot compile a foreign-owner asset', async () => { + assert.ok(foreignAsset) + const ownRead = await request('GET', `${filePath(foreignAsset)}/content`) + assert.equal(ownRead.status, 200) + assert.deepEqual(ownRead.buffer, secondImage) + const file = await create( + projectId, + `rendered-foreign-${runId}.docx`, + program('docx', foreignAsset.id), + sourceMimeTypes.docx + ) + assert.equal((await request('GET', artifactPath(file), 'session')).status, 404) + assert.equal((await request('GET', archivePath(file))).status, 404) + }) + await check( + 'Cached private document bytes still reject anonymous and workspace-key callers', + async () => { + const file = documents.get('docx') + assert.ok(file) + await readArtifact(file, 'docx') + assert.equal((await request('GET', artifactPath(file), 'none')).status, 401) + assert.equal((await request('GET', archivePath(file), 'none')).status, 401) + assert.equal((await request('GET', archivePath(file), 'workspace')).status, 403) + } + ) + await check( + 'An archived dependency cannot be delivered from a warm artifact cache', + async () => { + assert.ok(asset) + const file = documents.get('docx') + assert.ok(file) + await readArtifact(file, 'docx') + json( + await request('POST', `/api/v2/projects/${projectId}/files/archive`, 'personal', { + fileIds: [asset.id], + }) + ) + archived.add(asset.id) + assert.equal((await request('GET', artifactPath(file), 'session')).status, 404) + assert.equal((await request('GET', archivePath(file))).status, 404) + } + ) + await check( + 'Rendering and denied re-reads preserve every editable source and revision', + async () => { + for (const file of files.filter((file) => file.source !== undefined)) { + const response = await request('GET', `${filePath(file)}/content`) + assert.equal(response.status, 200) + assert.equal(response.buffer.toString('utf8'), file.source) + const metadata = toRecord(json(await request('GET', `${filePath(file)}/metadata`)).data) + assert.equal(metadata.revision, file.revision) + } + } + ) + } +} catch (error) { + checks.push({ + name: 'Harness completion', + passed: false, + durationMs: 0, + error: getErrorMessage(error), + }) +} finally { + await check('Archive only fixtures created by this run', async () => { + const owners = new Set(files.map((file) => file.projectId)) + for (const owner of owners) { + const fileIds = files + .filter((file) => file.projectId === owner && !archived.has(file.id)) + .map((file) => file.id) + if (fileIds.length) + json( + await request('POST', `/api/v2/projects/${owner}/files/archive`, 'personal', { fileIds }) + ) + } + }) + await saveReport() + process.exitCode = checks.some((entry) => !entry.passed) ? 1 : 0 +} diff --git a/apps/sim/scripts/test-project-file-sharing-e2e.ts b/apps/sim/scripts/test-project-file-sharing-e2e.ts new file mode 100644 index 00000000000..3275a1fae26 --- /dev/null +++ b/apps/sim/scripts/test-project-file-sharing-e2e.ts @@ -0,0 +1,352 @@ +import assert from 'node:assert/strict' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { dirname, join } from 'node:path' +import { db } from '@sim/db' +import { + member, + organization, + permissionGroup, + permissionGroupWorkspace, + permissions, + project, + projectWorkspace, + subscription, + user, +} from '@sim/db/schema' +import { insertWorkspaceFixture } from '@sim/db/testing/workspace-fixtures' +import { getErrorMessage } from '@sim/utils/errors' +import { generateId } from '@sim/utils/id' +import { toArray, toRecord } from '@sim/utils/object' +import { and, eq, inArray } from 'drizzle-orm' + +/** Actual internal sharing policy and public token proof using disposable local fixture owners. */ +function required(value: unknown): string { + assert.ok(typeof value === 'string' && value, 'Required fixture or environment value missing') + return value +} +const base = new URL(required(process.env.PROJECT_FILE_SHARING_BASE_URL)) +const database = new URL(required(process.env.DATABASE_URL)) +for (const url of [base, database]) + assert.ok(['localhost', '127.0.0.1', '[::1]'].includes(url.hostname), 'Local runtime required') +assert.match(database.pathname, /test/i, 'Disposable database required') +const reportPath = required(process.env.PROJECT_FILE_SHARING_REPORT_PATH) +const fixtureDir = required(process.env.PROJECT_FILE_SHARING_FIXTURE_DIR) +const account = toRecord(JSON.parse(await readFile(join(fixtureDir, 'owner-account.json'), 'utf8'))) +const cookie = toArray(account.cookies) + .map((value) => required(value).split(';')[0]) + .join('; ') +const parent = toRecord( + JSON.parse(await readFile(join(fixtureDir, 'http-project-fixture.json'), 'utf8')) +) +const [actor] = await db + .select({ id: project.ownerId }) + .from(project) + .where(eq(project.id, required(toRecord(parent.project).id))) +assert.ok(actor) +const [existingMembership] = await db + .select({ organizationId: member.organizationId, name: organization.name }) + .from(member) + .innerJoin(organization, eq(organization.id, member.organizationId)) + .where(eq(member.userId, actor.id)) +if (existingMembership) + assert.equal( + existingMembership.name, + 'Sharing policy proof', + 'Use an unassociated disposable account' + ) +const [existingOwner] = existingMembership + ? await db + .select({ id: member.userId }) + .from(member) + .where( + and(eq(member.organizationId, existingMembership.organizationId), eq(member.role, 'owner')) + ) + : [] +const ownerId = existingOwner?.id ?? generateId() +const organizationId = existingMembership?.organizationId ?? generateId() +const workspaceId = generateId() +const otherWorkspaceId = generateId() +const groupId = generateId() +if (!existingMembership) { + await db.insert(user).values({ + id: ownerId, + name: 'Sharing proof owner', + email: `${ownerId}@sharing.invalid`, + emailVerified: true, + createdAt: new Date(), + updatedAt: new Date(), + }) + await db.insert(organization).values({ + id: organizationId, + name: 'Sharing policy proof', + slug: organizationId, + createdAt: new Date(), + }) + await db.insert(member).values( + [ownerId, actor.id].map((userId) => ({ + id: generateId(), + organizationId, + userId, + role: userId === ownerId ? 'owner' : 'member', + createdAt: new Date(), + })) + ) +} +await insertWorkspaceFixture(db, { + id: workspaceId, + name: 'Share policy environment', + ownerId, + organizationId, + billedAccountUserId: ownerId, + workspaceMode: 'organization', +}) +await insertWorkspaceFixture(db, { + id: otherWorkspaceId, + name: 'Restricted sharing environment', + ownerId, + organizationId, + billedAccountUserId: ownerId, + workspaceMode: 'organization', + forkedFromWorkspaceId: workspaceId, +}) +const [binding] = await db + .select() + .from(projectWorkspace) + .where(eq(projectWorkspace.workspaceId, workspaceId)) +assert.ok(binding) +const projectId = binding.projectId +await db.insert(permissions).values( + [workspaceId, otherWorkspaceId].map((entityId) => ({ + id: generateId(), + userId: actor.id, + entityType: 'workspace', + entityId, + permissionType: 'admin' as const, + })) +) +await db.insert(subscription).values({ + id: generateId(), + plan: 'enterprise', + referenceId: organizationId, + status: 'active', + metadata: {}, +}) +await db.insert(permissionGroup).values({ + id: groupId, + organizationId, + createdBy: ownerId, + name: `Sharing policy proof ${groupId}`, + membershipMode: 'inherit', + config: {}, +}) +await db.insert(permissionGroupWorkspace).values({ + id: generateId(), + permissionGroupId: groupId, + workspaceId: otherWorkspaceId, + organizationId, +}) +const checks: { name: string; passed: boolean; durationMs: number; error?: string }[] = [] +async function request(path: string, method = 'GET', body?: object, authenticated = true) { + // boundary-raw-fetch: E2E exercises the running app's internal JSON and anonymous binary surfaces. + const response = await fetch(new URL(path, base), { + method, + headers: { + Origin: base.origin, + 'Content-Type': 'application/json', + ...(authenticated ? { Cookie: cookie } : {}), + }, + body: body ? JSON.stringify(body) : undefined, + signal: AbortSignal.timeout(90_000), + redirect: 'manual', + }) + return { status: response.status, text: await response.text() } +} +function json(response: Awaited>, status = 200) { + assert.equal(response.status, status) + return toRecord(JSON.parse(response.text)) +} +async function check(name: string, run: () => Promise) { + const started = performance.now() + try { + await run() + checks.push({ name, passed: true, durationMs: performance.now() - started }) + } catch (error) { + checks.push({ + name, + passed: false, + durationMs: performance.now() - started, + error: getErrorMessage(error), + }) + } + await mkdir(dirname(reportPath), { recursive: true }) + await writeFile( + reportPath, + JSON.stringify( + { checks, fixture: { projectId, workspaceId, otherWorkspaceId, organizationId } }, + null, + 2 + ) + ) +} +const content = 'Shared Project architecture fixture' +const projectFile = toRecord( + json( + await request(`/api/projects/${projectId}/files`, 'POST', { + name: 'sharing-project.txt', + content, + contentType: 'text/plain', + encoding: 'utf-8', + }), + 201 + ).file +) +const workspaceFile = toRecord( + json( + await request(`/api/workspaces/${workspaceId}/files`, 'POST', { + name: 'sharing-environment.txt', + content: 'Environment only fixture', + contentType: 'text/plain', + encoding: 'utf-8', + }), + 201 + ).file +) +const fileId = required(projectFile.id) +const workspaceFileId = required(workspaceFile.id) +const path = `/api/projects/${projectId}/files/${fileId}/share` +let token = '' +await check('Project sharing read projects current effective policy', async () => { + const result = json(await request(path)) + assert.equal(result.share, null) + assert.deepEqual(result.policy, { + canPublish: true, + allowedAuthTypes: ['public', 'password', 'email', 'sso'], + }) + assert.deepEqual(result.capabilities, { canRead: true, canWrite: true }) +}) +await check('Read-only Project access cannot publish or revoke', async () => { + const ownPermissions = and( + eq(permissions.userId, actor.id), + inArray(permissions.entityId, [workspaceId, otherWorkspaceId]) + ) + await db.update(permissions).set({ permissionType: 'read' }).where(ownPermissions) + try { + assert.equal(toRecord(json(await request(path)).capabilities).canWrite, false) + assert.equal((await request(path, 'PUT', { isActive: true })).status, 403) + assert.equal((await request(path, 'PUT', { isActive: false })).status, 403) + } finally { + await db.update(permissions).set({ permissionType: 'admin' }).where(ownPermissions) + } +}) +await check('Wrong file owner is concealed and unauthenticated calls are refused', async () => { + assert.equal( + (await request(`/api/projects/${projectId}/files/${workspaceFileId}/share`)).status, + 404 + ) + assert.equal((await request(path, 'GET', undefined, false)).status, 401) +}) +await check('Project publication serves actual bytes with stable token', async () => { + const share = toRecord( + json(await request(path, 'PUT', { isActive: true, authType: 'public' })).share + ) + token = required(share.token) + assert.equal(share.resourceId, fileId) + assert.equal( + (await request(`/api/files/public/${token}/content`, 'GET', undefined, false)).text, + content + ) + assert.equal(toRecord(json(await request(path)).share).token, token) +}) +await check( + 'Another environment policy controls offered modes and actual publication', + async () => { + await db + .update(permissionGroup) + .set({ config: { allowedFileShareAuthTypes: ['password'] } }) + .where(eq(permissionGroup.id, groupId)) + assert.deepEqual(json(await request(path)).policy, { + canPublish: true, + allowedAuthTypes: ['password'], + }) + assert.equal((await request(path, 'PUT', { isActive: true, authType: 'public' })).status, 403) + const share = toRecord( + json( + await request(path, 'PUT', { + isActive: true, + authType: 'password', + password: 'Local-sharing-proof-password', + }) + ).share + ) + assert.equal(share.authType, 'password') + assert.equal(share.token, token) + assert.equal( + (await request(`/api/files/public/${token}/content`, 'GET', undefined, false)).status, + 401 + ) + } +) +await check('Disabled publication preserves the right to revoke current sharing', async () => { + await db + .update(permissionGroup) + .set({ config: { disablePublicFileSharing: true, allowedFileShareAuthTypes: ['password'] } }) + .where(eq(permissionGroup.id, groupId)) + assert.deepEqual(json(await request(path)).policy, { + canPublish: false, + allowedAuthTypes: ['password'], + }) + assert.equal((await request(path, 'PUT', { isActive: true, authType: 'password' })).status, 403) + const share = toRecord(json(await request(path, 'PUT', { isActive: false })).share) + assert.equal(share.isActive, false) + assert.equal(share.token, token) + assert.equal( + (await request(`/api/files/public/${token}/content`, 'GET', undefined, false)).status, + 404 + ) +}) +await check('Environment sharing keeps its separate legacy policy and identity', async () => { + const workspacePath = `/api/workspaces/${workspaceId}/files/${workspaceFileId}/share` + const shared = json(await request(workspacePath, 'PUT', { isActive: true, authType: 'public' })) + assert.deepEqual(Object.keys(shared), ['share']) + const share = toRecord(shared.share) + assert.equal(share.resourceId, workspaceFileId) + assert.notEqual(share.token, token) + assert.equal(toRecord(json(await request(path)).share).isActive, false) + assert.equal( + (await request(`/api/files/public/${required(share.token)}/content`, 'GET', undefined, false)) + .text, + 'Environment only fixture' + ) + json(await request(workspacePath, 'PUT', { isActive: false })) +}) +await request(`/api/workspaces/${workspaceId}/files/${workspaceFileId}/share`, 'PUT', { + isActive: false, +}) +await request(path, 'PUT', { isActive: false }) +await writeFile( + join(fixtureDir, 'sharing-browser-fixtures.json'), + JSON.stringify( + { + projectId, + workspaceId, + otherWorkspaceId, + organizationId, + groupId, + actorId: actor.id, + fileId, + workspaceFileId, + token, + }, + null, + 2 + ), + { mode: 0o600 } +) +process.stdout.write( + `${JSON.stringify({ + reportPath, + passed: checks.filter((c) => c.passed).length, + failed: checks.filter((c) => !c.passed).length, + })}\n` +) +process.exit(checks.every((c) => c.passed) ? 0 : 1) diff --git a/apps/sim/scripts/test-project-files-e2e.ts b/apps/sim/scripts/test-project-files-e2e.ts new file mode 100644 index 00000000000..d81218fd902 --- /dev/null +++ b/apps/sim/scripts/test-project-files-e2e.ts @@ -0,0 +1,528 @@ +import assert from 'node:assert/strict' +import { spawn } from 'node:child_process' +import { closeSync, openSync } from 'node:fs' +import { mkdir, mkdtemp, readdir, readFile, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { dirname, join, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { createLogger } from '@sim/logger' +import { sha256Hex } from '@sim/security/hash' +import { getErrorMessage } from '@sim/utils/errors' +import { sleep } from '@sim/utils/helpers' +import { generateId, generateShortId } from '@sim/utils/id' +import { toRecord } from '@sim/utils/object' +import { makeSignature } from 'better-auth/crypto' +import postgres from 'postgres' + +/** Owns disposable infrastructure and prerequisite credentials for the existing real HTTP suites. */ +const logger = createLogger('ProjectFilesHttpE2E') +const required = (name: string) => { + const value = process.env[name] + assert.ok(value, `${name} is required`) + return value +} +function requiredString(value: unknown): string { + assert.ok(typeof value === 'string' && value.length > 0, 'Fixture response field is missing') + return value +} +const reportPath = resolve(required('PROJECT_FILES_E2E_REPORT_PATH')) +const reportDir = dirname(reportPath) +const adminUrl = new URL(required('PROJECT_FILES_E2E_ADMIN_DATABASE_URL')) +const base = new URL(required('PROJECT_FILES_E2E_BASE_URL')) +const relay = new URL(required('PROJECT_FILES_E2E_RELAY_URL')) +const redis = new URL(required('PROJECT_FILES_E2E_REDIS_URL')) +for (const url of [adminUrl, base, relay, redis]) { + assert.ok( + ['127.0.0.1', 'localhost', '[::1]'].includes(url.hostname), + 'Loopback services required' + ) +} +for (const url of [base, relay]) { + assert.equal(url.protocol, 'http:') + assert.ok(url.port && !url.username && !url.password && url.pathname === '/') +} +assert.notEqual(base.origin, relay.origin) +assert.ok(['postgres:', 'postgresql:'].includes(adminUrl.protocol)) +assert.equal(redis.protocol, 'redis:') +const appDir = resolve(dirname(fileURLToPath(import.meta.url)), '..') +const rootDir = resolve(appDir, '../..') +const privateDir = await mkdtemp(join(tmpdir(), 'sim-project-files-http-')) +const databaseName = `sim_project_files_http_test_${generateId().replaceAll('-', '')}` +const databaseUrl = new URL(adminUrl) +databaseUrl.pathname = `/${databaseName}` +const authSecret = generateShortId(48) +const internalSecret = generateShortId(48) +const secrets = new Set([adminUrl.toString(), databaseUrl.toString(), authSecret, internalSecret]) +if (adminUrl.password) secrets.add(adminUrl.password) +if (redis.password) secrets.add(redis.password) +const checks: { name: string; status: 'passed' | 'failed'; durationMs: number; error?: string }[] = + [] +const logs: { source: string; target: string }[] = [] +const exits: { + name: string + code: number | null + signal: NodeJS.Signals | null + requestedStop: boolean + error?: string +}[] = [] +const processes: ReturnType[] = [] +const admin = postgres(adminUrl.toString(), { max: 1 }) +let sql: ReturnType | undefined +let createdDatabase = false +let migrated = false +let interrupted = false +const environment: NodeJS.ProcessEnv = { + PATH: process.env.PATH, + HOME: process.env.HOME, + TMPDIR: process.env.TMPDIR, + SYSTEMROOT: process.env.SYSTEMROOT, + NODE_ENV: 'development', + __NEXT_PROCESSED_ENV: 'true', + DATABASE_URL: databaseUrl.toString(), + MIGRATION_DATABASE_URL: databaseUrl.toString(), + REDIS_URL: redis.toString(), + BETTER_AUTH_SECRET: authSecret, + INTERNAL_API_SECRET: internalSecret, + ENCRYPTION_KEY: '0'.repeat(64), + NEXT_PUBLIC_APP_URL: base.origin, + BETTER_AUTH_URL: base.origin, + INTERNAL_API_BASE_URL: base.origin, + SOCKET_SERVER_URL: relay.origin, + NEXT_PUBLIC_SOCKET_URL: relay.origin, + ALLOWED_ORIGINS: base.origin, + NEXT_PUBLIC_FORCE_HOSTED: 'true', + BILLING_ENABLED: 'true', + NEXT_PUBLIC_BILLING_ENABLED: 'true', + ENTERPRISE_ENABLED: 'true', + NEXT_PUBLIC_ENTERPRISE_ENABLED: 'true', + ACCESS_CONTROL_ENABLED: 'true', + NEXT_PUBLIC_ACCESS_CONTROL_ENABLED: 'true', + ORGANIZATIONS_ENABLED: 'true', + NEXT_PUBLIC_ORGANIZATIONS_ENABLED: 'true', + SSO_ENABLED: 'true', + NEXT_PUBLIC_SSO_ENABLED: 'true', + PROJECT_API_ENABLED: 'true', + PROJECT_FILES_ENABLED: 'true', + STORAGE_PROVIDER: 'local', + EMAIL_VERIFICATION_ENABLED: 'false', + NEXT_PUBLIC_CHAT_DISABLED: 'true', + DISABLE_TELEMETRY: 'true', + NEXT_TELEMETRY_DISABLED: '1', + DB_TX_TRIPWIRE: 'throw', +} + +function redact(text: string) { + for (const secret of secrets) text = text.replaceAll(secret, '[redacted]') + return text +} + +async function saveReport() { + await mkdir(reportDir, { recursive: true }) + await writeFile( + reportPath, + redact(JSON.stringify({ databaseName, checks, exits, reportsDirectory: reportDir }, null, 2)) + ) +} + +async function check(name: string, action: () => Promise) { + const started = performance.now() + try { + await action() + checks.push({ name, status: 'passed', durationMs: performance.now() - started }) + logger.info(`PASS ${name}`) + return true + } catch (error) { + checks.push({ + name, + status: 'failed', + durationMs: performance.now() - started, + error: redact(getErrorMessage(error)), + }) + logger.error(`FAIL ${name}`) + return false + } finally { + await saveReport() + } +} + +function start(name: string, command: string[], cwd: string, env = environment) { + const source = join(privateDir, `${name}.log`) + const descriptor = openSync(source, 'w', 0o600) + const child = spawn(command[0], command.slice(1), { + cwd, + env, + detached: true, + stdio: ['ignore', descriptor, descriptor], + }) + closeSync(descriptor) + const lifecycle = { requestedStop: false } + const done = new Promise((resolveExit) => { + child.once('error', (error) => { + exits.push({ + name, + code: null, + signal: null, + requestedStop: lifecycle.requestedStop, + error: getErrorMessage(error), + }) + resolveExit(-1) + }) + child.once('exit', (code, signal) => { + exits.push({ name, code, signal, requestedStop: lifecycle.requestedStop }) + resolveExit(code ?? -1) + }) + }) + logs.push({ source, target: join(reportDir, `${name}.log`) }) + return { child, done, lifecycle } +} + +async function stop(process: ReturnType) { + const { child, done } = process + if (child.exitCode === null && child.signalCode === null && child.pid) { + process.lifecycle.requestedStop = true + try { + globalThis.process.kill(-child.pid, 'SIGTERM') + } catch {} + for ( + let attempt = 0; + attempt < 100 && child.exitCode === null && child.signalCode === null; + attempt++ + ) + await sleep(100) + if (child.exitCode === null && child.signalCode === null) { + try { + globalThis.process.kill(-child.pid, 'SIGKILL') + } catch {} + } + } + await done +} + +async function run(name: string, command: string[], cwd: string, env = environment) { + assert.equal(interrupted, false, 'Acceptance run interrupted') + const child = start(name, command, cwd, env) + processes.push(child) + const timeout = setTimeout(() => { + if (child.child.pid) { + try { + process.kill(-child.child.pid, 'SIGKILL') + } catch {} + } + }, 10 * 60_000) + try { + assert.equal(await child.done, 0, `${name} failed; inspect ${name}.log`) + } finally { + clearTimeout(timeout) + await stop(child) + } +} + +async function ready(url: URL, path: string, child: ReturnType) { + const deadline = Date.now() + 300_000 + while (Date.now() < deadline) { + assert.equal(interrupted, false, 'Acceptance run interrupted') + assert.equal(child.child.exitCode, null, 'Service exited before becoming ready') + assert.equal(child.child.signalCode, null, 'Service stopped before becoming ready') + try { + // boundary-raw-fetch: the harness waits for its own loopback app and relay processes. + const response = await fetch(new URL(path, url), { signal: AbortSignal.timeout(5_000) }) + await response.body?.cancel() + if (response.ok) return + } catch {} + await sleep(1_000) + } + throw new Error('Local service readiness deadline exceeded') +} + +async function request(cookie: string, path: string, body: object, method = 'POST') { + // boundary-raw-fetch: fixture resources enter through the running application's actual authenticated APIs. + const response = await fetch(new URL(path, base), { + method, + headers: { Cookie: cookie, Origin: base.origin, 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(90_000), + }) + assert.ok(response.ok, `${method} ${path} returned ${response.status}`) + return toRecord(await response.json()) +} + +async function seed(name: string) { + assert.ok(sql) + const directory = join(privateDir, name) + await mkdir(directory, { mode: 0o700 }) + const ownerId = generateId() + const token = generateShortId(48) + const expiresAt = new Date(Date.now() + 2 * 60 * 60 * 1000).toISOString() + const cookie = `better-auth.session_token=${encodeURIComponent(`${token}.${await makeSignature(token, authSecret)}`)}` + const personalKey = `sk-sim-http-${generateShortId(32)}` + const workspaceKey = `sk-sim-http-${generateShortId(32)}` + for (const value of [token, cookie, personalKey, workspaceKey]) secrets.add(value) + await sql.begin(async (tx) => { + const email = `${ownerId}@project-files-http.test` + await tx`insert into "user" (id,name,email,normalized_email,email_verified,created_at,updated_at) + values (${ownerId},'Project files HTTP owner',${email},${email},true,now(),now())` + await tx`insert into user_stats (id,user_id) values (${generateId()},${ownerId})` + await tx`insert into subscription (id,plan,reference_id,status) values (${generateId()},'pro',${ownerId},'active')` + await tx`insert into session (id,token,user_id,expires_at,created_at,updated_at) + values (${generateId()},${token},${ownerId},${expiresAt},now(),now())` + await tx`insert into api_key (id,user_id,name,key,key_hash,type) + values (${generateId()},${ownerId},'Disposable HTTP personal key',${personalKey},${sha256Hex(personalKey)},'personal')` + }) + const project = await request(cookie, '/api/projects', { + name: `HTTP ${name}`, + organizationId: null, + initialEnvironment: { name: 'Sandbox' }, + }) + const foreign = await request(cookie, '/api/projects', { + name: `HTTP ${name} foreign`, + organizationId: null, + initialEnvironment: { name: 'Sandbox' }, + }) + const projectId = requiredString(toRecord(project.project).id) + const workspaceId = requiredString(toRecord(project.initialEnvironment).id) + const foreignProjectId = requiredString(toRecord(foreign.project).id) + await sql`insert into api_key (id,user_id,workspace_id,name,key,key_hash,type) + values (${generateId()},${ownerId},${workspaceId},'Disposable HTTP workspace key',${workspaceKey},${sha256Hex(workspaceKey)},'workspace')` + for (const [filename, data] of [ + ['owner-account.json', { userId: ownerId, cookies: [cookie] }], + ['v2-fixture-keys.json', { personal: personalKey, workspace: workspaceKey }], + ['http-project-fixture.json', project], + ] as const) + await writeFile(join(directory, filename), JSON.stringify(data), { mode: 0o600 }) + return { directory, projectId, workspaceId, foreignProjectId, cookie } +} + +async function seedInline(fixture: Awaited>) { + const image = + 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+jF9sAAAAASUVORK5CYII=' + const create = async ( + projectId: string, + name: string, + content: string, + contentType: string, + encoding: string + ) => + toRecord( + ( + await request(fixture.cookie, `/api/projects/${projectId}/files`, { + name, + content, + contentType, + encoding, + }) + ).file + ) + const ownImage = await create(fixture.projectId, 'own.png', image, 'image/png', 'base64') + const foreignImage = await create( + fixture.foreignProjectId, + 'foreign.png', + image, + 'image/png', + 'base64' + ) + const foreignPath = `/api/projects/${fixture.foreignProjectId}/files/${foreignImage.id}/content` + const doc = await create( + fixture.projectId, + 'architecture.md', + `![own](sim:file/${ownImage.id}?project=${fixture.projectId})\n![foreign](${foreignPath})`, + 'text/markdown', + 'utf-8' + ) + const share = toRecord( + ( + await request( + fixture.cookie, + `/api/projects/${fixture.projectId}/files/${doc.id}/share`, + { isActive: true, authType: 'public' }, + 'PUT' + ) + ).share + ) + secrets.add(requiredString(share.token)) + const path = join(fixture.directory, 'inline-source-fixture.json') + await writeFile( + path, + JSON.stringify({ + projectId: fixture.projectId, + files: [{ owner: 'project', fileId: doc.id, imageId: ownImage.id, token: share.token }], + wrongOwner: { projectId: fixture.foreignProjectId, fileId: foreignImage.id }, + }), + { mode: 0o600 } + ) + return path +} + +function interrupt() { + interrupted = true + void Promise.all(processes.map(stop)).catch(() => undefined) +} +process.once('SIGINT', interrupt) +process.once('SIGTERM', interrupt) + +const suites = [ + ['delivery', 'FILE_DELIVERY', 'test-file-delivery-e2e.ts'], + ['browser', 'PROJECT_FILE_BROWSER', 'test-project-file-browser-e2e.ts'], + ['history', 'PROJECT_FILE_HISTORY', 'test-project-file-history-e2e.ts'], + ['rendered', 'PROJECT_FILE_RENDERED', 'test-project-file-rendered-e2e.ts'], + ['public', 'PROJECT_FILE_PUBLIC', 'test-project-file-public-e2e.ts'], + ['sharing', 'PROJECT_FILE_SHARING', 'test-project-file-sharing-e2e.ts'], + ['inline-source', 'PROJECT_INLINE_SOURCE', 'test-project-file-inline-source-e2e.ts'], + ['copy', 'FILE_COPY', 'test-file-copy-e2e.ts'], + ['realtime', 'FILE_LIST_REALTIME', 'test-file-list-realtime-e2e.ts'], +] as const + +try { + assert.ok( + await check( + 'Create an exclusively owned disposable database and apply normal migrations', + async () => { + await admin.unsafe(`CREATE DATABASE "${databaseName}"`) + createdDatabase = true + await run( + 'project-files-migrate', + ['bun', '--no-env-file', 'scripts/migrate.ts'], + join(rootDir, 'packages/db') + ) + migrated = true + sql = postgres(databaseUrl.toString(), { max: 2 }) + } + ) + ) + assert.ok( + await check('Start the real app and relay with local-only runtime settings', async () => { + const app = start( + 'project-files-next', + [ + 'node', + join(rootDir, 'node_modules/next/dist/bin/next'), + 'dev', + '--hostname', + base.hostname, + '--port', + base.port, + ], + appDir + ) + processes.push(app) + await ready(base, '/api/health', app) + const socket = start( + 'project-files-relay', + ['bun', '--no-env-file', 'src/index.ts'], + join(rootDir, 'apps/realtime'), + { ...environment, PORT: relay.port, SIM_DB_ROLE: 'realtime', DB_APP_NAME: 'sim-realtime' } + ) + processes.push(socket) + await ready(relay, '/health', socket) + }) + ) + for (const [name, prefix, script] of suites) { + const selectedSuites = process.env.PROJECT_FILES_E2E_SUITES?.split(',') + if (selectedSuites && !selectedSuites.includes(name)) continue + assert.equal(interrupted, false, 'Acceptance run interrupted') + await check(`Existing ${name} acceptance suite`, async () => { + const fixture = await seed(name) + const fixturePath = name === 'inline-source' ? await seedInline(fixture) : undefined + await run( + `project-files-${name}`, + ['bun', '--no-env-file', join('scripts', script)], + appDir, + { + ...environment, + [`${prefix}_BASE_URL`]: base.origin, + [`${prefix}_FIXTURE_DIR`]: fixture.directory, + [`${prefix}_REPORT_PATH`]: join(reportDir, `project-files-${name}.json`), + PROJECT_FILE_RENDERED_FOREIGN_PROJECT_ID: fixture.foreignProjectId, + PROJECT_FILE_PUBLIC_WORKSPACE_ID: fixture.workspaceId, + PROJECT_INLINE_SOURCE_FIXTURE_PATH: fixturePath, + FILE_COPY_WORKSPACE_ID: fixture.workspaceId, + FILE_LIST_REALTIME_RELAY_URL: relay.origin, + } + ) + }) + } +} catch (error) { + checks.push({ + name: 'Orchestration stopped', + status: 'failed', + durationMs: 0, + error: redact(getErrorMessage(error)), + }) +} finally { + await check('Stop all owned child processes', async () => { + for (const child of [...processes].reverse()) await stop(child) + }) + if (sql && migrated) { + const database = sql + await check('Retain token redactions before retiring fixture records', async () => { + const credentials = await database<{ value: string }[]>` + select token as value from session + union all select token as value from public_share + union all select key as value from api_key` + for (const credential of credentials) secrets.add(credential.value) + }) + await check( + 'Remove only storage prefixes belonging to the owned disposable database', + async () => { + const owners = await database< + { id: string; type: string }[] + >`select id,'project' as type from project union all select id,'workspace' as type from workspace` + for (const owner of owners) { + assert.match(owner.id, /^[A-Za-z0-9_-]+$/) + await rm(join(appDir, 'uploads', owner.type, owner.id), { force: true, recursive: true }) + if (owner.type === 'workspace') + await rm(join(appDir, 'uploads', 'copilot-doc-compiled', owner.id), { + force: true, + recursive: true, + }) + } + } + ) + await check('Close fixture database connections', () => database.end()) + } + if (createdDatabase) + await check('Drop only the database successfully created by this run', async () => { + assert.match(databaseName, /^sim_project_files_http_test_[a-f0-9]{32}$/) + await admin.unsafe(`DROP DATABASE "${databaseName}" WITH (FORCE)`) + createdDatabase = false + }) + await check('Close administrative database connection', () => admin.end()) + await check('Publish redacted logs and remove private credentials', async () => { + const collect = async (directory: string): Promise => { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name) + if (entry.isDirectory()) await collect(path) + else if (entry.name.endsWith('.json')) { + const scan = (value: unknown, key = '') => { + if ( + typeof value === 'string' && + /^(cookies?|readerCookie|password|token|uploadToken|personal|workspace)$/i.test( + key + ) && + value.length > 12 + ) + secrets.add(value) + else if (Array.isArray(value)) for (const child of value) scan(child, key) + else if (value && typeof value === 'object') + for (const [name, child] of Object.entries(value)) scan(child, name) + } + scan(JSON.parse(await readFile(path, 'utf8'))) + } + } + } + await collect(privateDir) + for (const log of logs) await writeFile(log.target, redact(await readFile(log.source, 'utf8'))) + for (const [name] of suites) { + const path = join(reportDir, `project-files-${name}.json`) + try { + await writeFile(path, redact(await readFile(path, 'utf8'))) + } catch (error) { + if (toRecord(error).code !== 'ENOENT') throw error + } + } + await rm(privateDir, { force: true, recursive: true }) + }) + await saveReport() +} +process.removeListener('SIGINT', interrupt) +process.removeListener('SIGTERM', interrupt) +process.exitCode = checks.some((check) => check.status === 'failed') ? 1 : 0 diff --git a/bun.lock b/bun.lock index 2426af6efe2..874d20db13f 100644 --- a/bun.lock +++ b/bun.lock @@ -396,6 +396,7 @@ "devDependencies": { "@next/env": "16.4.0", "@opentelemetry/context-async-hooks": "2.10.0", + "@playwright/test": "1.61.1", "@sim/testing": "workspace:*", "@sim/tsconfig": "workspace:*", "@tailwindcss/postcss": "^4.3.3", diff --git a/knip.jsonc b/knip.jsonc index 01189fbd715..c0a54769fe8 100644 --- a/knip.jsonc +++ b/knip.jsonc @@ -49,6 +49,10 @@ "lib/mothership/generated/**": ["exports", "types", "duplicates"], // API conventions require exported named wire schemas and aliases, including before client adoption. "lib/api/contracts/projects.ts": ["exports", "types"], + "lib/api/contracts/file-copy.ts": ["exports", "types"], + "lib/api/contracts/file-copy-input.ts": ["exports", "types"], + "lib/api/contracts/project-file*.ts": ["exports", "types"], + "lib/api/contracts/v2/project-file*.ts": ["exports", "types"], "lib/api/contracts/v2/uploads.ts": ["types"], "sandbox-tasks/index.ts": ["files"], "components/mcp/index.ts": ["files"], diff --git a/packages/auth/src/principal.ts b/packages/auth/src/principal.ts index c5341644749..ccd1973e3a9 100644 --- a/packages/auth/src/principal.ts +++ b/packages/auth/src/principal.ts @@ -4,6 +4,7 @@ export type Principal = | OAuthAccessTokenPrincipal | WorkspaceApiKeyPrincipal | DelegatedPrincipal + | ResourceDelegatedPrincipal | OrganizationDelegatedPrincipal | SystemPrincipal | CredentialGroupEnrollmentPrincipal @@ -186,6 +187,65 @@ export type DelegatedPrincipal = SubjectDelegatedPrincipal | WorkflowExecutionDe export type ResourceEntityType = 'workspace' | 'project' | 'organization' | 'user' +/** An owner grant may be narrowed to a single file, but never widened to another owner. */ +interface ResourceEntityScope { + kind: 'entity' + entityType: ResourceEntityType + entityId: string + fileId?: string +} + +/** Observing a Project collection grants no authority to address or mutate an individual file. */ +interface ResourceFileCollectionObservationScope { + kind: 'file_collection_observation' + entityType: 'project' + entityId: string +} + +/** One compound copy grant binds the exact requested source selection and destination. */ +export interface ResourceFileCopyScope { + kind: 'file_copy' + source: { + owner: { entityType: 'workspace' | 'project'; entityId: string } + fileIds: readonly string[] + folderIds: readonly string[] + } + destination: { + owner: { entityType: 'workspace' | 'project'; entityId: string } + folderId: string | null + } +} + +export type ResourceDelegationScope = + | { kind: 'project_discovery' } + | ResourceEntityScope + | ResourceFileCopyScope + | ResourceFileCollectionObservationScope + +interface ResourceDelegatedPrincipalBase { + kind: 'resource_delegated' + subjectUserId: string + delegationId: string + audience: string + issuedAt: Date + expiresAt: Date +} + +/** Human authority for entity-owned resources; never a workflow execution principal. */ +export type ResourceDelegatedPrincipal = ResourceDelegatedPrincipalBase & + ( + | { + serviceId: 'copilot' + scope: ResourceDelegationScope + invocation: { kind: 'chat'; chatId: string } | { kind: 'workspace'; workspaceId: string } + } + | { + serviceId: 'realtime' + scope: (ResourceEntityScope & { fileId: string }) | ResourceFileCollectionObservationScope + invocation: { kind: 'realtime'; connectionId: string } + } + ) + /** Search-only authority delegated by a current organization member. */ interface OrganizationDelegatedPrincipalBase { kind: 'organization_delegated' @@ -423,6 +483,8 @@ export function serializePrincipal(principal: WorkflowExecutionPrincipal): Seria expiresAt: principal.expiresAt.toISOString(), }, } + default: + throw new Error('Principal cannot be persisted for workflow execution') } } @@ -566,6 +628,8 @@ export function parsePrincipal(value: unknown): WorkflowExecutionPrincipal { : { resourceScope: parseResourceScope(principal.resourceScope) }), } } + case 'resource_delegated': + throw new Error('Resource delegation cannot be persisted for workflow execution') case 'credential_group_enrollment': throw new Error('Credential Group enrollment principals cannot be persisted for execution') default: @@ -575,6 +639,12 @@ export function parsePrincipal(value: unknown): WorkflowExecutionPrincipal { export type PrincipalActor = | Omit + | { + kind: 'resource_delegated' + serviceId: ResourceDelegatedPrincipal['serviceId'] + subjectUserId: string + delegationId: string + } | { kind: 'organization_delegated' serviceId: OrganizationDelegatedPrincipal['serviceId'] @@ -652,6 +722,7 @@ export function resolvePrincipalSubject(principal: Principal): PrincipalSubject case 'personal_api_key': case 'oauth_access_token': return { kind: 'sim_user', userId: principal.userId } + case 'resource_delegated': case 'organization_delegated': return { kind: 'sim_user', userId: principal.subjectUserId } case 'delegated': @@ -727,6 +798,13 @@ export function toPrincipalActor(principal: Principal): PrincipalActor { ...(principal.subjectUserId ? { subjectUserId: principal.subjectUserId } : {}), delegationId: principal.delegationId, } + case 'resource_delegated': + return { + kind: principal.kind, + serviceId: principal.serviceId, + subjectUserId: principal.subjectUserId, + delegationId: principal.delegationId, + } case 'organization_delegated': return { kind: principal.kind, @@ -784,6 +862,7 @@ export function resolvePrincipalAuditAttribution(principal: Principal): Principa case 'personal_api_key': case 'oauth_access_token': return { actor, actorId: actor.userId } + case 'resource_delegated': case 'organization_delegated': return { actor, actorId: actor.subjectUserId } case 'delegated': @@ -830,6 +909,7 @@ export function resolvePrincipalAttribution( } case 'system': throw new Error('System principals do not support user attribution') + case 'resource_delegated': case 'organization_delegated': return { actor, attributedUserId: actor.subjectUserId } case 'delegated': { diff --git a/packages/platform-authz/src/room-policy.ts b/packages/platform-authz/src/room-policy.ts index ba91920c852..831451e5852 100644 --- a/packages/platform-authz/src/room-policy.ts +++ b/packages/platform-authz/src/room-policy.ts @@ -21,6 +21,7 @@ import { isPermissionType, type PermissionType, permissionSatisfies } from './pr export const ROOM_MEMBERSHIP_ACTIONS = { [ROOM_TYPES.WORKFLOW]: 'read', [ROOM_TYPES.WORKSPACE_FILES]: 'read', + [ROOM_TYPES.PROJECT_FILES]: 'read', [ROOM_TYPES.WORKSPACE_TABLES]: 'read', [ROOM_TYPES.WORKSPACE_WORKFLOWS]: 'read', [ROOM_TYPES.WORKSPACE_FILE_DOC]: 'write', diff --git a/packages/realtime-protocol/package.json b/packages/realtime-protocol/package.json index ce02b6f46b0..a698bb54ea1 100644 --- a/packages/realtime-protocol/package.json +++ b/packages/realtime-protocol/package.json @@ -33,6 +33,10 @@ "./table-presence": { "types": "./src/table-presence.ts", "default": "./src/table-presence.ts" + }, + "./file-doc-target": { + "types": "./src/file-doc-target.ts", + "default": "./src/file-doc-target.ts" } }, "scripts": { diff --git a/packages/realtime-protocol/src/file-doc-target.ts b/packages/realtime-protocol/src/file-doc-target.ts new file mode 100644 index 00000000000..114cc5b8574 --- /dev/null +++ b/packages/realtime-protocol/src/file-doc-target.ts @@ -0,0 +1,101 @@ +import { + projectFileDocRoom, + projectFileDocTarget, + ROOM_TYPES, + type RoomRef, +} from '@sim/realtime-protocol/rooms' + +/** An asserted owner; the relay still resolves current access through the owning application. */ +export interface FileDocOwner { + entityType: 'workspace' | 'project' + entityId: string +} + +/** A document target with explicit ownership; asserted ownership still requires authorization. */ +export interface FileDocTarget { + fileId: string + owner: FileDocOwner +} + +/** Incoming workspace messages may omit the owner until canonical authorization resolves it. */ +interface FileDocWireTarget { + fileId: string + owner?: FileDocOwner +} + +interface OwnerCodec { + room(entityId: string, fileId: string): RoomRef + admission(entityId: string, fileId: string): string +} + +const OWNER_CODECS: Record = { + workspace: { + room: (_entityId, fileId) => ({ type: ROOM_TYPES.WORKSPACE_FILE_DOC, id: fileId }), + admission: (_entityId, fileId) => `file-doc-admission:${fileId}`, + }, + project: { + room: projectFileDocRoom, + admission: (entityId, fileId) => `file-doc-admission:${entityId}/${fileId}`, + }, +} + +/** Normalize old wire hints once; unknown owners and conflicting assertions never fall back. */ +export function parseFileDocTarget(input: { + fileId?: unknown + owner?: unknown + projectId?: unknown +}): FileDocWireTarget | null { + if (typeof input.fileId !== 'string' || !input.fileId) return null + const { fileId } = input + const candidate = + input.owner ?? + (input.projectId === undefined + ? undefined + : { entityType: 'project', entityId: input.projectId }) + if (input.owner === null) return null + if (candidate === undefined) return { fileId } + if (typeof candidate !== 'object' || candidate === null) return null + const owner = candidate as Partial + if ( + typeof owner.entityType !== 'string' || + !Object.hasOwn(OWNER_CODECS, owner.entityType) || + typeof owner.entityId !== 'string' || + !owner.entityId || + owner.entityId.length > 200 || + owner.entityId !== owner.entityId.trim() || + /[/:]/.test(owner.entityId) || + /[/:]/.test(fileId) || + (input.projectId !== undefined && + (owner.entityType !== 'project' || owner.entityId !== input.projectId)) + ) + return null + return { fileId, owner: { entityType: owner.entityType, entityId: owner.entityId } } +} + +/** Encode owner-qualified targets without renaming deployed Socket.IO or Redis addresses. */ +export function fileDocRoom(target: FileDocWireTarget): RoomRef { + return target.owner + ? OWNER_CODECS[target.owner.entityType].room(target.owner.entityId, target.fileId) + : { type: ROOM_TYPES.WORKSPACE_FILE_DOC, id: target.fileId } +} + +/** Pending admissions use the same compatibility codec as their live document. */ +export function fileDocAdmissionRoom(target: FileDocWireTarget): string { + return target.owner + ? OWNER_CODECS[target.owner.entityType].admission(target.owner.entityId, target.fileId) + : `file-doc-admission:${target.fileId}` +} + +/** Workspace ownership is resolved after authorization because legacy room names omit it. */ +export function fileDocTargetFromRoom(room: RoomRef): FileDocWireTarget | null { + if (room.type === ROOM_TYPES.WORKSPACE_FILE_DOC) return { fileId: room.id } + const project = projectFileDocTarget(room) + return project + ? { fileId: project.fileId, owner: { entityType: 'project', entityId: project.projectId } } + : null +} + +/** Include explicit ownership while retaining the Project hint understood by older relays. */ +export function fileDocOwnerWireFields(owner: FileDocOwner) { + return { owner, ...(owner.entityType === 'project' ? { projectId: owner.entityId } : {}) } +} diff --git a/packages/realtime-protocol/src/file-doc.ts b/packages/realtime-protocol/src/file-doc.ts index f5243104889..6f7e977bc27 100644 --- a/packages/realtime-protocol/src/file-doc.ts +++ b/packages/realtime-protocol/src/file-doc.ts @@ -1,3 +1,5 @@ +import type { FileDocOwner } from '@sim/realtime-protocol/file-doc-target' + /** * Wire protocol for the collaborative file-document room * ({@link ROOM_TYPES.WORKSPACE_FILE_DOC}). Live carets and text selection ride @@ -148,6 +150,7 @@ export const FILE_DOC_LIMITS = { /** Client → server join request. `fileId` is the `workspace_files.id`. */ export interface JoinFileDocPayload { + owner?: FileDocOwner projectId?: string fileId: string /** @@ -163,6 +166,7 @@ export interface JoinFileDocPayload { /** Server → client acceptance of a {@link FILE_DOC_EVENTS.JOIN}. */ export interface JoinFileDocSuccess { canWrite?: boolean + owner?: FileDocOwner projectId?: string fileId: string /** The provider whose join was accepted. Optional while older relays are still deployed. */ @@ -194,6 +198,7 @@ export interface JoinFileDocError { /** Client → server leave request. */ export interface LeaveFileDocPayload { + owner?: FileDocOwner projectId?: string fileId: string } @@ -210,6 +215,7 @@ export interface FileDocInvalidated { /** A bounded, retry-safe batch of user-authored changes. */ export interface FileDocUpdatePayload { + owner?: FileDocOwner projectId?: string fileId: string docId: string @@ -270,7 +276,8 @@ export const FILE_DOC_INTERNAL_HEADERS = { /** Current write capability for a subscribed Project document. */ export interface FileDocPermission { - projectId: string + owner?: FileDocOwner + projectId?: string fileId: string canWrite: boolean } diff --git a/packages/realtime-protocol/src/rooms.ts b/packages/realtime-protocol/src/rooms.ts index 6a00246d7a7..53b4eb663a8 100644 --- a/packages/realtime-protocol/src/rooms.ts +++ b/packages/realtime-protocol/src/rooms.ts @@ -22,6 +22,7 @@ export const ROOM_TYPES = { WORKFLOW: 'workflow', /** The workspace file browser (one room per workspace). */ WORKSPACE_FILES: 'workspace-files', + PROJECT_FILES: 'project-files', /** * A single collaborative file document — the rich-text editor for one file * (one room per file). Carries Yjs document sync + awareness (live carets and @@ -61,17 +62,27 @@ export type RoomType = (typeof ROOM_TYPES)[keyof typeof ROOM_TYPES] /** Every known room type, for exhaustive iteration/validation. */ const ALL_ROOM_TYPES = Object.values(ROOM_TYPES) as readonly RoomType[] -/** - * The presence-free, workspace-scoped live-list rooms. They share one contract derived entirely - * from the room-type token: clients join via `join-${type}`, the app server fans a mutation out via - * `POST /api/${type}-changed`, and members receive a lossy `${type}-changed` invalidation signal. - * Adding a room type here wires it into the shared socket handler and HTTP relay branch. - */ -export const WORKSPACE_LIST_ROOM_TYPES = [ - ROOM_TYPES.WORKSPACE_FILES, - ROOM_TYPES.WORKSPACE_TABLES, - ROOM_TYPES.WORKSPACE_WORKFLOWS, -] as const +/** Owner-specific wire addresses share the same presence-free invalidation lifecycle. */ +const INVALIDATION_ROOM_ID_KEYS = { + [ROOM_TYPES.WORKSPACE_FILES]: 'workspaceId', + [ROOM_TYPES.WORKSPACE_TABLES]: 'workspaceId', + [ROOM_TYPES.WORKSPACE_WORKFLOWS]: 'workspaceId', + [ROOM_TYPES.PROJECT_FILES]: 'projectId', +} as const + +export type InvalidationRoomType = keyof typeof INVALIDATION_ROOM_ID_KEYS + +export const INVALIDATION_ROOM_TYPES = Object.keys( + INVALIDATION_ROOM_ID_KEYS +) as InvalidationRoomType[] + +/** Legacy workspace callers keep their payload key; unsupported rooms never inherit it. */ +export function invalidationRoomIdKey(type: RoomType): 'workspaceId' | 'projectId' { + if (!Object.hasOwn(INVALIDATION_ROOM_ID_KEYS, type)) { + throw new Error('Room does not support list invalidation') + } + return INVALIDATION_ROOM_ID_KEYS[type as InvalidationRoomType] +} /** Universal address of a realtime room. */ export interface RoomRef { @@ -79,6 +90,26 @@ export interface RoomRef { id: string } +const ROOM_AUTHORIZATION_OWNERS = { + [ROOM_TYPES.WORKFLOW]: 'workspace', + [ROOM_TYPES.WORKSPACE_FILES]: 'workspace', + [ROOM_TYPES.WORKSPACE_TABLES]: 'workspace', + [ROOM_TYPES.WORKSPACE_WORKFLOWS]: 'workspace', + [ROOM_TYPES.WORKSPACE_FILE_DOC]: 'workspace', + [ROOM_TYPES.TABLE]: 'workspace', + [ROOM_TYPES.PROJECT_FILE_DOC]: 'project', + [ROOM_TYPES.PROJECT_FILES]: 'project', +} as const satisfies Record + +export type ProjectRoomRef = RoomRef & { + type: typeof ROOM_TYPES.PROJECT_FILE_DOC | typeof ROOM_TYPES.PROJECT_FILES +} + +/** Every room declares the authority that both admission and revalidation must consult. */ +export function isProjectRoom(room: RoomRef): room is ProjectRoomRef { + return ROOM_AUTHORIZATION_OWNERS[room.type] === 'project' +} + /** Type guard: whether an arbitrary string is a known {@link RoomType}. */ function isRoomType(value: string): value is RoomType { return (ALL_ROOM_TYPES as readonly string[]).includes(value) diff --git a/packages/sim-cli/src/commands/protocol/files-get.ts b/packages/sim-cli/src/commands/protocol/files-get.ts index be36e1feaec..d7967b27df0 100644 --- a/packages/sim-cli/src/commands/protocol/files-get.ts +++ b/packages/sim-cli/src/commands/protocol/files-get.ts @@ -9,7 +9,15 @@ import { writeStdout } from '#sim-cli/output/io' import { clientFrom } from '../../context' import { embedStore } from '../../embed-context' import { V2_OPERATIONS } from '../../generated/v2-api' -import { isRequestTimeout, RAISE_TIMEOUT_HINT, resolvePath, SimApiError } from '../../http/client' +import { + isRequestTimeout, + RAISE_TIMEOUT_HINT, + type RequestOptions, + resolvePath, + SimApiError, +} from '../../http/client' +import { describeOperation } from '../../runtime/build' +import { buildRequest, readArgumentSource } from '../../runtime/request' import { printProtocolResult } from './result' function writeFailure(path: WriteStream['path'], error: unknown): SimApiError { @@ -282,7 +290,13 @@ interface DownloadOutputOptions { force?: boolean } -type DownloadOperation = (typeof V2_OPERATIONS)['downloadFile' | 'downloadFileVersion'] +type DownloadOperation = (typeof V2_OPERATIONS)[ + | 'downloadFile' + | 'downloadFileVersion' + | 'downloadProjectFileItems' + | 'exportProjectFileSnapshot' + | 'readProjectFileContent' + | 'readProjectFileVersionContent'] /** * Streams a binary v2 download to stdout or atomically to `--output-file`. Shared by every @@ -292,7 +306,9 @@ async function downloadToOutput( command: Command, operation: DownloadOperation, pathParams: Record, - options: DownloadOutputOptions + options: DownloadOutputOptions, + scope: 'workspace' | 'owner', + requestInput: Pick = {} ): Promise { const target = options.outputFile const writesToStdout = target === undefined || target === '-' @@ -301,10 +317,10 @@ async function downloadToOutput( } const { client, profile } = clientFrom(command) - const workspaceId = client.requireWorkspace() const response = await client.requestRaw(resolvePath(operation.path, pathParams), { method: operation.method, - query: { workspaceId }, + ...requestInput, + ...(scope === 'workspace' ? { query: { workspaceId: client.requireWorkspace() } } : {}), }) if (!response.body) { throw new SimApiError('File content response was empty.', response.status) @@ -329,12 +345,91 @@ async function downloadToOutput( const savedTarget = await saveToFile(response.body, target, Boolean(options.force)) printProtocolResult(profile.output, { - id: pathParams.fileId, + ...(pathParams.fileId ? { id: pathParams.fileId } : {}), path: savedTarget, status: 'saved', }) } +interface ProjectFileDownloadOptions extends DownloadOutputOptions { + fileIds?: string[] + folderIds?: string[] +} + +/** Owner-specific selection uses the same bounded transfer and atomic output path as file reads. */ +export function attachProjectFileDownload(files: Command): void { + files + .command('bulk-download') + .argument('', 'Project that owns the files') + .allowExcessArguments(false) + .description( + describeOperation( + V2_OPERATIONS.downloadProjectFileItems, + 'Download Project files and recursive folder contents as a zip archive' + ) + ) + .option('--file-ids ', 'File identifiers to include') + .option('--folder-ids ', 'Folder identifiers to include recursively') + .option('-o, --output-file ', 'Write the archive to a file instead of stdout') + .option('--force', 'Overwrite --output-file if it already exists') + .action(async (projectId: string, options: ProjectFileDownloadOptions, command: Command) => { + const input = await buildRequest( + 'downloadProjectFileItems', + [projectId], + { ...options }, + null + ) + await downloadToOutput( + command, + V2_OPERATIONS.downloadProjectFileItems, + { projectId }, + options, + 'owner', + { query: input.query } + ) + }) +} + +interface ProjectFileSnapshotOptions extends DownloadOutputOptions { + content: string +} + +/** Snapshot text comes from the caller's file reader, including the embedded workbench boundary. */ +export function attachProjectFileSnapshotExport(files: Command): void { + files + .command('export') + .argument('', 'Project that owns the file') + .argument('', 'Markdown file whose visible snapshot to export') + .allowExcessArguments(false) + .description( + describeOperation( + V2_OPERATIONS.exportProjectFileSnapshot, + 'Export a visible Project Markdown snapshot with its embedded assets' + ) + ) + .requiredOption('--content ', 'Visible Markdown content or a file to read') + .option('-o, --output-file ', 'Write the export to a file instead of stdout') + .option('--force', 'Overwrite --output-file if it already exists') + .action( + async ( + projectId: string, + fileId: string, + options: ProjectFileSnapshotOptions, + command: Command + ) => { + const source = await readArgumentSource(options.content, 'content') + await downloadToOutput( + command, + V2_OPERATIONS.exportProjectFileSnapshot, + { projectId, fileId }, + options, + 'owner', + { body: { content: source.text } } + ) + } + ) +} + export function attachFileGet(files: Command): void { files .command('get') @@ -344,7 +439,7 @@ export function attachFileGet(files: Command): void { .option('-o, --output-file ', 'Write content to a file instead of stdout') .option('--force', 'Overwrite --output-file if it already exists') .action((fileId: string, options: DownloadOutputOptions, command: Command) => - downloadToOutput(command, V2_OPERATIONS.downloadFile, { fileId }, options) + downloadToOutput(command, V2_OPERATIONS.downloadFile, { fileId }, options, 'workspace') ) } @@ -358,6 +453,71 @@ export function attachFileVersionDownload(versions: Command): void { .option('-o, --output-file ', 'Write content to a file instead of stdout') .option('--force', 'Overwrite --output-file if it already exists') .action((fileId: string, version: string, options: DownloadOutputOptions, command: Command) => - downloadToOutput(command, V2_OPERATIONS.downloadFileVersion, { fileId, version }, options) + downloadToOutput( + command, + V2_OPERATIONS.downloadFileVersion, + { fileId, version }, + options, + 'workspace' + ) + ) +} + +/** Project source downloads share transfer protections without selecting a workspace. */ +export function attachProjectFileSource(files: Command): void { + files + .command('source') + .argument('', 'Project that owns the file') + .argument('', 'File whose stored source to read') + .allowExcessArguments(false) + .description( + describeOperation( + V2_OPERATIONS.readProjectFileContent, + 'Download a Project file’s stored source to stdout or a local file' + ) + ) + .option('-o, --output-file ', 'Write content to a file instead of stdout') + .option('--force', 'Overwrite --output-file if it already exists') + .action((projectId: string, fileId: string, options: DownloadOutputOptions, command: Command) => + downloadToOutput( + command, + V2_OPERATIONS.readProjectFileContent, + { projectId, fileId }, + options, + 'owner' + ) + ) +} + +export function attachProjectFileVersionSource(versions: Command): void { + versions + .command('source') + .argument('', 'Project that owns the file') + .argument('', 'File identifier.') + .argument('', 'Version number.') + .allowExcessArguments(false) + .description( + describeOperation( + V2_OPERATIONS.readProjectFileVersionContent, + 'Download a Project file version’s stored source to stdout or a local file' + ) + ) + .option('-o, --output-file ', 'Write content to a file instead of stdout') + .option('--force', 'Overwrite --output-file if it already exists') + .action( + ( + projectId: string, + fileId: string, + version: string, + options: DownloadOutputOptions, + command: Command + ) => + downloadToOutput( + command, + V2_OPERATIONS.readProjectFileVersionContent, + { projectId, fileId, version }, + options, + 'owner' + ) ) } diff --git a/packages/sim-cli/src/commands/protocol/files-upload.ts b/packages/sim-cli/src/commands/protocol/files-upload.ts index 2a7ad8e052c..efc758525f0 100644 --- a/packages/sim-cli/src/commands/protocol/files-upload.ts +++ b/packages/sim-cli/src/commands/protocol/files-upload.ts @@ -1,12 +1,71 @@ import type { Command } from 'commander' import { clientFrom } from '../../context' -import type { CompleteFileUploadResponse, CreateFileUploadResponse } from '../../generated/v2-api' +import type { + CompleteFileUploadResponse, + CompleteProjectFileUploadResponse, + CreateFileUploadResponse, + CreateProjectFileUploadResponse, +} from '../../generated/v2-api' import { V2_OPERATIONS } from '../../generated/v2-api' import { encodeFolderPath } from '../../runtime/request' import { contentTypeFor, localFile } from '../../transfer/local-file' import { finishUploadSession } from '../../transfer/upload-session' import { printProtocolResult } from './result' +interface FileUploadOptions { + folder?: string + name?: string +} + +type FileUploadOwner = + | { entityType: 'workspace'; entityId: string } + | { entityType: 'project'; entityId: string } + +/** Both owners share transfer, cancellation and streaming; each carries its own control address. */ +async function uploadFile( + command: Command, + path: string, + options: FileUploadOptions, + owner: FileUploadOwner +): Promise { + const { client, profile } = clientFrom(command) + const { name, size } = await localFile(path, options.name) + const basePath = + owner.entityType === 'workspace' + ? V2_OPERATIONS.createFileUpload.path + : `/api/v2/projects/${encodeURIComponent(owner.entityId)}/files/uploads` + const created = await client.request( + basePath, + { + method: 'POST', + body: { + ...(owner.entityType === 'workspace' ? { workspaceId: owner.entityId } : {}), + name, + contentType: contentTypeFor(name), + size, + ...(options.folder !== undefined ? { folderPath: encodeFolderPath(options.folder) } : {}), + }, + } + ) + const { session, uploadToken, transfer } = created.data + const completed = await finishUploadSession< + CompleteFileUploadResponse['data'] | CompleteProjectFileUploadResponse['data'] + >( + client, + { + basePath: `${basePath}/${encodeURIComponent(session.id)}`, + ...(owner.entityType === 'workspace' ? { query: { workspaceId: owner.entityId } } : {}), + uploadToken, + transfer, + size, + }, + path + ) + if (!completed.file) throw new Error(`File upload ${session.id} completed without a file`) + /** Session credentials never belong in retained CLI output. */ + printProtocolResult(profile.output, completed.file) +} + export function attachFileUpload(files: Command): void { files .command('upload') @@ -15,53 +74,27 @@ export function attachFileUpload(files: Command): void { .description('Upload a file to the workspace') .option('--folder ', 'Folder path as shown in the app; defaults to the root folder') .option('--name ', 'Store it under a different name') - .action(async (path: string, options: { folder?: string; name?: string }, command: Command) => { - const { client, profile } = clientFrom(command) - const workspaceId = client.requireWorkspace() - const { name, size } = await localFile(path, options.name) - - const created = await client.request( - V2_OPERATIONS.createFileUpload.path, - { - method: 'POST', - body: { - workspaceId, - name, - contentType: contentTypeFor(name), - size, - // `` above is a LOCAL file and must stay untouched; only the - // destination folder is a wire-encoded API path. - ...(options.folder !== undefined - ? { folderPath: encodeFolderPath(options.folder) } - : {}), - }, - } - ) - const { session, uploadToken, transfer } = created.data - const completed = await finishUploadSession( - client, - workspaceId, - { - basePath: `/api/v2/files/uploads/${encodeURIComponent(session.id)}`, - uploadToken, - transfer, - size, - }, - path - ) + .action(async (path: string, options: FileUploadOptions, command: Command) => { + const { client } = clientFrom(command) + await uploadFile(command, path, options, { + entityType: 'workspace', + entityId: client.requireWorkspace(), + }) + }) +} - if (!completed.file) { - throw new Error(`File upload ${session.id} completed without a file`) +export function attachProjectFileUpload(files: Command): void { + files + .command('upload') + .argument('', 'Project that will own the file') + .argument('', 'Local file to upload') + .allowExcessArguments(false) + .description('Upload a shared Project file') + .option('--folder ', 'Folder path as shown in the app; defaults to the Project root') + .option('--name ', 'Store it under a different name') + .action( + async (projectId: string, path: string, options: FileUploadOptions, command: Command) => { + await uploadFile(command, path, options, { entityType: 'project', entityId: projectId }) } - /** - * The file record, and nothing about the session that carried it. - * - * The session is over by the time this line runs — completed on success, - * aborted on failure — so its id names nothing a caller can go on to ask - * about, and its token is a live credential that also authorizes - * aborting and completing the transfer. This command runs in CI, where - * stdout is retained and broadly readable; neither belongs in it. - */ - printProtocolResult(profile.output, completed.file) - }) + ) } diff --git a/packages/sim-cli/src/commands/protocol/index.ts b/packages/sim-cli/src/commands/protocol/index.ts index 0b2544745db..edc1209833f 100644 --- a/packages/sim-cli/src/commands/protocol/index.ts +++ b/packages/sim-cli/src/commands/protocol/index.ts @@ -1,7 +1,14 @@ import { Command } from 'commander' import { attachChat } from './chat' -import { attachFileGet, attachFileVersionDownload } from './files-get' -import { attachFileUpload } from './files-upload' +import { + attachFileGet, + attachFileVersionDownload, + attachProjectFileDownload, + attachProjectFileSnapshotExport, + attachProjectFileSource, + attachProjectFileVersionSource, +} from './files-get' +import { attachFileUpload, attachProjectFileUpload } from './files-upload' import { attachKnowledgeDocumentUpload } from './knowledge-document-upload' import { attachKnowledgeExport } from './knowledge-export' import { attachLogsFollow } from './logs-follow' @@ -22,6 +29,12 @@ function group(program: Command, name: string): Command { /** Attaches commands whose multi-request or binary protocols cannot be generated. */ export function attachProtocolCommands(program: Command): void { + const projectFiles = group(group(program, 'projects'), 'files') + attachProjectFileUpload(projectFiles) + attachProjectFileSource(projectFiles) + attachProjectFileDownload(projectFiles) + attachProjectFileSnapshotExport(projectFiles) + attachProjectFileVersionSource(group(projectFiles, 'versions')) const files = group(program, 'files') attachFileUpload(files) attachFileGet(files) diff --git a/packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts index b93ad1e7e71..4fad976627e 100644 --- a/packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts +++ b/packages/sim-cli/src/commands/protocol/knowledge-document-upload.ts @@ -98,11 +98,11 @@ export function attachKnowledgeDocumentUpload(documents: Command): void { CompleteKnowledgeDocumentUploadResponse['data'] >( client, - workspaceId, { basePath: `/api/v2/knowledge/${encodeURIComponent( knowledgeBaseId )}/documents/uploads/${encodeURIComponent(session.id)}`, + query: { workspaceId }, uploadToken, transfer, size, diff --git a/packages/sim-cli/src/commands/protocol/tables-import.ts b/packages/sim-cli/src/commands/protocol/tables-import.ts index 7bc3b6df21a..439f5678e1b 100644 --- a/packages/sim-cli/src/commands/protocol/tables-import.ts +++ b/packages/sim-cli/src/commands/protocol/tables-import.ts @@ -245,9 +245,9 @@ export function attachTableImport(tables: Command): void { } job = await finishUploadSession( client, - workspaceId, { basePath: `/api/v2/tables/imports/${encodeURIComponent(job.id)}`, + query: { workspaceId }, uploadToken: started.data.uploadToken, transfer: started.data.transfer, size: local.size, diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index 891a106eee4..1a8f4580200 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -20,6 +20,14 @@ const ACCESS_REQUEST_COLUMNS: ColumnSpec[] = [ { header: 'created', path: 'createdAt', format: 'timestamp' }, ] +const FILE_SHARE_FIELDS: ColumnSpec[] = [ + { header: 'shared', path: 'isActive', format: 'bool' }, + { header: 'URL', path: 'url' }, + { header: 'auth', path: 'authType' }, + { header: 'password set', path: 'hasPassword', format: 'bool' }, + { header: 'allowed emails', path: 'allowedEmails', format: 'count' }, +] + const TABLE_NAME_HELP = 'Identifier: letters, numbers, and underscores; cannot start with a number' const TABLE_FILTER_HELP = 'Predicate: {"all":[{"field":"status","op":"eq","value":"active"}]}; groups use all/any. Operators: eq, ne, gt, gte, lt, lte, in, nin, contains, ncontains, startsWith, endsWith, like, ilike, nlike, nilike, isEmpty, isNotEmpty, isNull, isNotNull' @@ -117,6 +125,39 @@ const FOLDER_LIST_COLUMNS: ColumnSpec[] = [ { header: 'parent', path: 'parentPath', format: 'folder-path' }, { header: 'updated', path: 'updatedAt', format: 'timestamp' }, ] +const FILE_VERSION_COLUMNS: ColumnSpec[] = [ + { header: 'version' }, + { header: 'current', path: 'isCurrent', format: 'bool' }, + { header: 'source' }, + { header: 'size', format: 'bytes' }, + { header: 'authors', format: 'people' }, + { header: 'created', path: 'createdAt', format: 'timestamp' }, + { header: 'superseded', path: 'supersededAt', format: 'timestamp' }, +] +const FILE_VERSION_FIELDS: ColumnSpec[] = [ + { header: 'file', path: 'fileId' }, + { header: 'version' }, + { header: 'current', path: 'isCurrent', format: 'bool' }, + { header: 'source' }, + { header: 'restored from', path: 'restoredFromVersion' }, + { header: 'size', format: 'bytes' }, + { header: 'type', path: 'contentType' }, + { header: 'authors', format: 'people' }, + { header: 'created', path: 'createdAt', format: 'timestamp' }, + { header: 'updated', path: 'updatedAt', format: 'timestamp' }, + { header: 'superseded', path: 'supersededAt', format: 'timestamp' }, +] +const FILE_REVERT_FIELDS: ColumnSpec[] = [ + { header: 'reverted', format: 'bool' }, + { header: 'file', path: 'file.id' }, + { header: 'name', path: 'file.name' }, + { header: 'version', path: 'version.version' }, + { header: 'source', path: 'version.source' }, + { header: 'restored from', path: 'version.restoredFromVersion' }, + { header: 'size', path: 'version.size', format: 'bytes' }, + { header: 'authors', path: 'version.authors', format: 'people' }, + { header: 'created', path: 'version.createdAt', format: 'timestamp' }, +] function moveResource(command: string, resource: string): CommandVariantSpec { return { @@ -738,6 +779,61 @@ export const CLI_CONTRACT: CliContract = { }, }, createFile: { flags: { folderPath: FOLDER_PATH_FLAG } }, + copyFileItems: { + command: 'files copy', + flags: { + source: { json: true, describe: 'Source owner and selected fileIds or folderIds' }, + destination: { json: true, describe: 'Destination owner and optional folderId' }, + }, + }, + renameProjectFile: { command: 'projects files rename' }, + listProjectFileFolders: { + command: 'projects files folders list', + columns: [{ header: 'id' }, { header: 'name' }, { header: 'parent', path: 'parentId' }], + }, + createProjectFileFolder: { command: 'projects files folders create' }, + updateProjectFileFolder: { command: 'projects files folders update' }, + restoreProjectFileFolder: { command: 'projects files folders restore' }, + searchProjectFileContent: { + command: 'projects files search', + flags: { + folderPaths: FOLDER_PATHS_FLAG, + includeSubfolders: { boolean: true, negatable: true }, + }, + itemsPath: 'results', + columns: [ + { header: 'file', path: 'fileId' }, + { header: 'line', path: 'lineNumber' }, + { header: 'text' }, + ], + }, + moveProjectFileItems: { + command: 'projects files move', + flags: { + fileIds: { list: true }, + folderIds: { list: true }, + targetFolderPath: TARGET_FOLDER_PATH_FLAG, + }, + }, + archiveProjectFileItems: { + command: 'projects files archive', + confirm: 'This archives the selected Project files and folders, including folder contents.', + flags: { fileIds: { list: true }, folderIds: { list: true } }, + }, + restoreProjectFile: { command: 'projects files restore' }, + createProjectFile: { + command: 'projects files create', + flags: { folderPath: FOLDER_PATH_FLAG }, + }, + updateProjectFileContent: { + command: 'projects files set-content', + describe: 'Replace a shared Project file’s contents', + flags: { encoding: { choices: ['utf-8', 'base64'], describe: 'Content encoding' } }, + }, + readProjectFileContent: { + command: 'projects files source', + describe: 'Download the stored source bytes of a Project file', + }, createKnowledgeBase: { flags: { folderPath: FOLDER_PATH_FLAG } }, updateKnowledgeBase: { variants: [moveResource('knowledge mv', 'knowledge base')], @@ -881,6 +977,26 @@ export const CLI_CONTRACT: CliContract = { { header: 'uploaded', path: 'uploadedAt', format: 'timestamp' }, ], }, + listProjectFiles: { + command: 'projects files list', + flags: { + folderPath: FOLDER_PATH_FLAG, + recursive: { boolean: true, negatable: true }, + }, + columns: [ + { header: 'id' }, + { header: 'name' }, + FOLDER_COLUMN, + { header: 'size', format: 'bytes' }, + { header: 'type' }, + { header: 'creator', path: 'uploadedBy' }, + { header: 'uploaded', path: 'uploadedAt', format: 'timestamp' }, + ], + }, + getProjectFileMetadata: { + command: 'projects files describe', + describe: 'Show Project file metadata and ownership', + }, listTableRows: { expand: 'data' }, listKnowledgeBases: { flags: { folderPath: FOLDER_PATH_FLAG }, @@ -1481,32 +1597,12 @@ export const CLI_CONTRACT: CliContract = { listFileVersions: { command: 'files versions list', describe: 'List the recorded versions of a file', - columns: [ - { header: 'version' }, - { header: 'current', path: 'isCurrent', format: 'bool' }, - { header: 'source' }, - { header: 'size', format: 'bytes' }, - { header: 'authors', format: 'people' }, - { header: 'created', path: 'createdAt', format: 'timestamp' }, - { header: 'superseded', path: 'supersededAt', format: 'timestamp' }, - ], + columns: FILE_VERSION_COLUMNS, }, getFileVersion: { command: 'files versions describe', describe: 'Show the metadata of one version of a file', - fields: [ - { header: 'file', path: 'fileId' }, - { header: 'version' }, - { header: 'current', path: 'isCurrent', format: 'bool' }, - { header: 'source' }, - { header: 'restored from', path: 'restoredFromVersion' }, - { header: 'size', format: 'bytes' }, - { header: 'type', path: 'contentType' }, - { header: 'authors', format: 'people' }, - { header: 'created', path: 'createdAt', format: 'timestamp' }, - { header: 'updated', path: 'updatedAt', format: 'timestamp' }, - { header: 'superseded', path: 'supersededAt', format: 'timestamp' }, - ], + fields: FILE_VERSION_FIELDS, }, readFileVersionText: { command: 'files versions read', @@ -1517,23 +1613,37 @@ export const CLI_CONTRACT: CliContract = { revertFileVersion: { command: 'files versions revert', describe: 'Make a previous version of a file current again', - fields: [ - { header: 'reverted', format: 'bool' }, - { header: 'file', path: 'file.id' }, - { header: 'name', path: 'file.name' }, - { header: 'version', path: 'version.version' }, - { header: 'source', path: 'version.source' }, - { header: 'restored from', path: 'version.restoredFromVersion' }, - { header: 'size', path: 'version.size', format: 'bytes' }, - { header: 'authors', path: 'version.authors', format: 'people' }, - { header: 'created', path: 'version.createdAt', format: 'timestamp' }, - ], + fields: FILE_REVERT_FIELDS, }, deleteFileVersion: { command: 'files versions delete', describe: 'Permanently delete a previous version of a file', confirm: 'This permanently deletes the version and its stored content.', }, + listProjectFileVersions: { + command: 'projects files versions list', + describe: 'List the recorded versions of a shared Project file', + columns: FILE_VERSION_COLUMNS, + }, + getProjectFileVersion: { + command: 'projects files versions describe', + describe: 'Show the metadata of one version of a shared Project file', + fields: FILE_VERSION_FIELDS, + }, + readProjectFileVersionContent: { + command: 'projects files versions source', + describe: 'Download the stored source bytes of a Project file version', + }, + revertProjectFileVersion: { + command: 'projects files versions revert', + describe: 'Make a previous version of a shared Project file current again', + fields: FILE_REVERT_FIELDS, + }, + deleteProjectFileVersion: { + command: 'projects files versions delete', + describe: 'Permanently delete a previous version of a shared Project file', + confirm: 'This permanently deletes the version and its stored content.', + }, // Left to derive, the folder restore lands under `files restore` and turns // that leaf back into a group holding a lone `create` — the exact shape the // rename above exists to remove. It belongs beside the other folder verbs. @@ -1619,6 +1729,11 @@ export const CLI_CONTRACT: CliContract = { describe: 'Unzip an archive into a new folder beside it', confirm: 'This writes every file in the archive into the workspace.', }, + unzipProjectFile: { + command: 'projects files unzip', + describe: 'Unzip an archive into a new folder beside it in the Project', + confirm: 'This writes every file in the archive into the Project.', + }, /** * Configured even though `buildGeneratedCommands` skips it: it only builds * operations whose `responseMode` is `json` (runtime/build.ts), so this @@ -1634,6 +1749,18 @@ export const CLI_CONTRACT: CliContract = { folderPaths: FOLDER_PATHS_FLAG, }, }, + downloadProjectFileItems: { + command: 'projects files bulk-download', + describe: 'Download Project files and folders as a zip archive', + flags: { + fileIds: { list: true }, + folderIds: { list: true }, + }, + }, + exportProjectFileSnapshot: { + command: 'projects files export', + describe: 'Export a visible Project Markdown snapshot with its embedded assets', + }, editFileContent: { command: 'files edit', describe: 'Apply one exact or anchor-based edit to a text file', @@ -1653,13 +1780,12 @@ export const CLI_CONTRACT: CliContract = { getFileShare: { command: 'files share get', describe: 'Show a file’s share settings', - fields: [ - { header: 'shared', path: 'isActive', format: 'bool' }, - { header: 'URL', path: 'url' }, - { header: 'auth', path: 'authType' }, - { header: 'password set', path: 'hasPassword', format: 'bool' }, - { header: 'allowed emails', path: 'allowedEmails', format: 'count' }, - ], + fields: FILE_SHARE_FIELDS, + }, + getProjectFileShare: { + command: 'projects files share get', + describe: 'Show a Project file’s share settings', + fields: FILE_SHARE_FIELDS, }, // v2 folds share and unshare into one PATCH; `--is-active false` disables it, // so there is no separate unshare operation to expose. @@ -1669,13 +1795,15 @@ export const CLI_CONTRACT: CliContract = { flags: { allowedEmails: { list: true }, }, - fields: [ - { header: 'shared', path: 'isActive', format: 'bool' }, - { header: 'URL', path: 'url' }, - { header: 'auth', path: 'authType' }, - { header: 'password set', path: 'hasPassword', format: 'bool' }, - { header: 'allowed emails', path: 'allowedEmails', format: 'count' }, - ], + fields: FILE_SHARE_FIELDS, + }, + updateProjectFileShare: { + command: 'projects files share set', + describe: 'Enable or disable sharing for a Project file', + flags: { + allowedEmails: { list: true }, + }, + fields: FILE_SHARE_FIELDS, }, /** @@ -2143,6 +2271,11 @@ export const CLI_CONTRACT: CliContract = { // advertise a protocol whose halfway states leak storage, so `sim files // upload` drives the whole sequence and these stay out of the surface. createFileUpload: { hidden: true }, + createProjectFileUpload: { hidden: true }, + getProjectFileUpload: { hidden: true }, + abortProjectFileUpload: { hidden: true }, + completeProjectFileUpload: { hidden: true }, + getProjectFileUploadPartUrls: { hidden: true }, createFileUploadPartUrls: { hidden: true }, completeFileUpload: { hidden: true }, abortFileUpload: { hidden: true }, diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index 5fa4e1ef22d..4965df3bb5f 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -112,6 +112,59 @@ export type AbortKnowledgeDocumentUploadResponse = { data: AbortKnowledgeDocumentUploadResponseRef1 } +/** `DELETE /api/v2/projects/[projectId]/files/uploads/[uploadId]` */ +export type AbortProjectFileUploadParams = { + projectId: string + uploadId: string +} + +export type AbortProjectFileUploadQuery = Record + +export type AbortProjectFileUploadHeaders = { + 'upload-token': string +} + +type AbortProjectFileUploadResponseRef0 = { + id: string + name: string + size: number + type: string + key: string + folderPath: string + uploadedAt: string + updatedAt: string + deletedAt: string | null + owner: { + entityType: 'project' + entityId: string + } + uploadedBy: string + revision?: string +} + +type AbortProjectFileUploadResponseRef1 = { + id: string + status: + | 'uploading' + | 'completing' + | 'finalizing' + | 'completed' + | 'failed' + | 'aborting' + | 'aborted' + | 'expired' + name: string + contentType: string + size: number + expiresAt: string + error: string | null + file: AbortProjectFileUploadResponseRef0 | null +} + +export type AbortProjectFileUploadResponse = { + data: AbortProjectFileUploadResponseRef1 +} + /** `POST /api/v2/workflows/[workflowId]/versions/[version]/activate` */ export type ActivateWorkflowVersionParams = { version: number @@ -669,6 +722,31 @@ export type ApplyWorkflowVariablesResponse = { data: ApplyWorkflowVariablesResponseRef0 } +/** `POST /api/v2/projects/[projectId]/files/archive` */ +export type ArchiveProjectFileItemsParams = { + projectId: string +} + +export type ArchiveProjectFileItemsQuery = Record + +export type ArchiveProjectFileItemsBody = { + fileIds?: Array + folderIds?: Array +} + +export type ArchiveProjectFileItemsResponse = { + data: { + deletedItems: { + files: number + folders: number + } + affectedIds: { + fileIds: Array + folderIds: Array + } + } +} + /** `POST /api/v2/organizations/[organizationId]/permission-groups/[groupId]/members/bulk` */ export type BulkAddPermissionGroupMembersParams = { organizationId: string @@ -1625,6 +1703,59 @@ export type CompleteKnowledgeDocumentUploadResponse = { data: CompleteKnowledgeDocumentUploadResponseRef1 } +/** `POST /api/v2/projects/[projectId]/files/uploads/[uploadId]/complete` */ +export type CompleteProjectFileUploadParams = { + projectId: string + uploadId: string +} + +export type CompleteProjectFileUploadQuery = Record + +export type CompleteProjectFileUploadHeaders = { + 'upload-token': string +} + +type CompleteProjectFileUploadResponseRef0 = { + id: string + name: string + size: number + type: string + key: string + folderPath: string + uploadedAt: string + updatedAt: string + deletedAt: string | null + owner: { + entityType: 'project' + entityId: string + } + uploadedBy: string + revision?: string +} + +type CompleteProjectFileUploadResponseRef1 = { + id: string + status: + | 'uploading' + | 'completing' + | 'finalizing' + | 'completed' + | 'failed' + | 'aborting' + | 'aborted' + | 'expired' + name: string + contentType: string + size: number + expiresAt: string + error: string | null + file: CompleteProjectFileUploadResponseRef0 | null +} + +export type CompleteProjectFileUploadResponse = { + data: CompleteProjectFileUploadResponseRef1 +} + /** `POST /api/v2/tables/imports/[importId]/complete` */ export type CompleteTableImportParams = { importId: string @@ -1689,6 +1820,67 @@ export type CompleteTableImportResponse = { data: CompleteTableImportResponseRef4 } +/** `POST /api/v2/files/copy` */ +export type CopyFileItemsQuery = Record + +export type CopyFileItemsBody = { + source: { + owner: { + entityType: 'workspace' | 'project' + entityId: string + } + fileIds?: Array + folderIds?: Array + } + destination: { + owner: { + entityType: 'workspace' | 'project' + entityId: string + } + folderId?: string | null + } +} + +export type CopyFileItemsResponse = { + data: { + files: Array<{ + id: string + name: string + size: number + type: string + width?: number | null + height?: number | null + uploadedBy: string + folderId: string | null + folderPath?: string | null + deletedAt: string | null + uploadedAt: string + updatedAt: string + contentUpdatedAt: string | null + revision: string + owner: { + entityType: 'workspace' | 'project' + entityId: string + } + }> + folders: Array<{ + id: string + userId: string + name: string + parentId: string | null + path: string + sortOrder: number + deletedAt: string | null + createdAt: string + updatedAt: string + owner: { + entityType: 'workspace' | 'project' + entityId: string + } + }> + } +} + /** `POST /api/v2/credentials/connections` */ export type CreateCredentialConnectionQuery = Record @@ -2684,6 +2876,152 @@ export type CreatePermissionGroupResponse = { data: CreatePermissionGroupResponseRef0 } +/** `POST /api/v2/projects/[projectId]/files` */ +export type CreateProjectFileParams = { + projectId: string +} + +export type CreateProjectFileQuery = Record + +type CreateProjectFileBodyRef0 = string + +export type CreateProjectFileBody = { + name: string + contentType?: string + content?: string + encoding?: 'utf-8' | 'base64' + folderPath?: CreateProjectFileBodyRef0 +} + +type CreateProjectFileResponseRef0 = { + id: string + name: string + size: number + type: string + key: string + folderPath: string + uploadedAt: string + updatedAt: string + deletedAt: string | null + owner: { + entityType: 'project' + entityId: string + } + uploadedBy: string + revision?: string +} + +export type CreateProjectFileResponse = { + data: CreateProjectFileResponseRef0 +} + +/** `POST /api/v2/projects/[projectId]/files/folders` */ +export type CreateProjectFileFolderParams = { + projectId: string +} + +export type CreateProjectFileFolderQuery = Record + +export type CreateProjectFileFolderBody = { + name: string + parentId?: string | null +} + +export type CreateProjectFileFolderResponse = { + data: { + id: string + userId: string + name: string + parentId: string | null + path: string + sortOrder: number + deletedAt: string | null + createdAt: string + updatedAt: string + owner: { + entityType: 'project' + entityId: string + } + } +} + +/** `POST /api/v2/projects/[projectId]/files/uploads` */ +export type CreateProjectFileUploadParams = { + projectId: string +} + +export type CreateProjectFileUploadQuery = Record + +type CreateProjectFileUploadBodyRef0 = string + +export type CreateProjectFileUploadBody = { + name: string + contentType: string + size: number + folderPath?: CreateProjectFileUploadBodyRef0 + folderId?: string | null +} + +type CreateProjectFileUploadResponseRef0 = { + id: string + name: string + size: number + type: string + key: string + folderPath: string + uploadedAt: string + updatedAt: string + deletedAt: string | null + owner: { + entityType: 'project' + entityId: string + } + uploadedBy: string + revision?: string +} + +type CreateProjectFileUploadResponseRef1 = { + id: string + status: + | 'uploading' + | 'completing' + | 'finalizing' + | 'completed' + | 'failed' + | 'aborting' + | 'aborted' + | 'expired' + name: string + contentType: string + size: number + expiresAt: string + error: string | null + file: CreateProjectFileUploadResponseRef0 | null +} + +type CreateProjectFileUploadResponseRef2 = { + method: 'put' + url: string + headers: Record + expiresAt: string +} + +type CreateProjectFileUploadResponseRef3 = { + method: 'multipart' + partSize: number + partCount: number +} + +type CreateProjectFileUploadResponseRef4 = { + session: CreateProjectFileUploadResponseRef1 + uploadToken: string + transfer: CreateProjectFileUploadResponseRef2 | CreateProjectFileUploadResponseRef3 +} + +export type CreateProjectFileUploadResponse = { + data: CreateProjectFileUploadResponseRef4 +} + /** `POST /api/v2/sandboxes` */ export type CreateSandboxQuery = Record @@ -4020,6 +4358,23 @@ export type DeletePermissionGroupResponse = { data: DeletePermissionGroupResponseRef0 } +/** `DELETE /api/v2/projects/[projectId]/files/[fileId]/versions/[version]` */ +export type DeleteProjectFileVersionParams = { + projectId: string + fileId: string + version: number +} + +export type DeleteProjectFileVersionQuery = Record + +export type DeleteProjectFileVersionResponse = { + data: { + fileId: string + version: number + deleted: true + } +} + /** `DELETE /api/v2/sandboxes/[sandboxId]` */ export type DeleteSandboxParams = { sandboxId: string @@ -4776,6 +5131,19 @@ export type DownloadFileVersionQuery = { /** Non-JSON response (`binary`). */ export type DownloadFileVersionResponse = never +/** `GET /api/v2/projects/[projectId]/files/bulk-download` */ +export type DownloadProjectFileItemsParams = { + projectId: string +} + +export type DownloadProjectFileItemsQuery = { + fileIds?: string + folderIds?: string +} + +/** Non-JSON response (`binary`). */ +export type DownloadProjectFileItemsResponse = never + /** `GET /api/v2/workflows/[workflowId]/runs/[runId]/files/[fileId]` */ export type DownloadRunFileParams = { workflowId: string @@ -5006,6 +5374,21 @@ export type ExportKnowledgeBaseQuery = { /** Non-JSON response (`binary`). */ export type ExportKnowledgeBaseResponse = never +/** `POST /api/v2/projects/[projectId]/files/[fileId]/export` */ +export type ExportProjectFileSnapshotParams = { + projectId: string + fileId: string +} + +export type ExportProjectFileSnapshotQuery = Record + +export type ExportProjectFileSnapshotBody = { + content: string +} + +/** Non-JSON response (`binary`). */ +export type ExportProjectFileSnapshotResponse = never + /** `GET /api/v2/workflows/[workflowId]/export` */ export type ExportWorkflowParams = { workflowId: string @@ -6187,19 +6570,189 @@ export type GetPermissionGroupResponse = { data: GetPermissionGroupResponseRef0 } -/** `GET /api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]` */ -export type GetRowEnrichmentParams = { - tableId: string - rowId: string - groupId: string +/** `GET /api/v2/projects/[projectId]/files/[fileId]/metadata` */ +export type GetProjectFileMetadataParams = { + projectId: string + fileId: string } -export type GetRowEnrichmentQuery = { - workspaceId: string +export type GetProjectFileMetadataQuery = Record + +type GetProjectFileMetadataResponseRef0 = { + id: string + name: string + size: number + type: string + key: string + folderPath: string + uploadedAt: string + updatedAt: string + deletedAt: string | null + owner: { + entityType: 'project' + entityId: string + } + uploadedBy: string + revision?: string } -type GetRowEnrichmentResponseRef0 = { - status: string +export type GetProjectFileMetadataResponse = { + data: GetProjectFileMetadataResponseRef0 +} + +/** `GET /api/v2/projects/[projectId]/files/[fileId]/share` */ +export type GetProjectFileShareParams = { + projectId: string + fileId: string +} + +export type GetProjectFileShareQuery = Record + +type GetProjectFileShareResponseRef0 = { + id: string + token: string + url: string + isActive: boolean + resourceType: 'file' | 'folder' + resourceId: string + authType: 'public' | 'password' | 'email' | 'sso' + hasPassword: boolean + allowedEmails: Array +} + +export type GetProjectFileShareResponse = { + data: GetProjectFileShareResponseRef0 | null +} + +/** `GET /api/v2/projects/[projectId]/files/uploads/[uploadId]` */ +export type GetProjectFileUploadParams = { + projectId: string + uploadId: string +} + +export type GetProjectFileUploadQuery = Record + +export type GetProjectFileUploadHeaders = { + 'upload-token': string +} + +type GetProjectFileUploadResponseRef0 = { + id: string + name: string + size: number + type: string + key: string + folderPath: string + uploadedAt: string + updatedAt: string + deletedAt: string | null + owner: { + entityType: 'project' + entityId: string + } + uploadedBy: string + revision?: string +} + +type GetProjectFileUploadResponseRef1 = { + id: string + status: + | 'uploading' + | 'completing' + | 'finalizing' + | 'completed' + | 'failed' + | 'aborting' + | 'aborted' + | 'expired' + name: string + contentType: string + size: number + expiresAt: string + error: string | null + file: GetProjectFileUploadResponseRef0 | null +} + +export type GetProjectFileUploadResponse = { + data: GetProjectFileUploadResponseRef1 +} + +/** `POST /api/v2/projects/[projectId]/files/uploads/[uploadId]/parts` */ +export type GetProjectFileUploadPartUrlsParams = { + projectId: string + uploadId: string +} + +export type GetProjectFileUploadPartUrlsQuery = Record + +export type GetProjectFileUploadPartUrlsBody = { + partNumbers: Array +} + +export type GetProjectFileUploadPartUrlsHeaders = { + 'upload-token': string +} + +type GetProjectFileUploadPartUrlsResponseRef0 = { + partNumber: number + url: string + headers: Record + expiresAt: string +} + +type GetProjectFileUploadPartUrlsResponseRef1 = { + parts: Array +} + +export type GetProjectFileUploadPartUrlsResponse = { + data: GetProjectFileUploadPartUrlsResponseRef1 +} + +/** `GET /api/v2/projects/[projectId]/files/[fileId]/versions/[version]` */ +export type GetProjectFileVersionParams = { + projectId: string + fileId: string + version: number +} + +export type GetProjectFileVersionQuery = Record + +type GetProjectFileVersionResponseRef0 = GetProjectFileVersionResponseRef1 + +type GetProjectFileVersionResponseRef1 = { + fileId: string + version: number + isCurrent: boolean + size: number + contentType: string + source: 'upload' | 'user' | 'api' | 'copilot' | 'workflow' | 'collab' | 'revert' | 'unknown' + authors: Array<{ + id: string + email: string | null + }> + restoredFromVersion: number | null + createdAt: string + updatedAt: string + supersededAt: string | null +} + +export type GetProjectFileVersionResponse = { + data: GetProjectFileVersionResponseRef0 +} + +/** `GET /api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]` */ +export type GetRowEnrichmentParams = { + tableId: string + rowId: string + groupId: string +} + +export type GetRowEnrichmentQuery = { + workspaceId: string +} + +type GetRowEnrichmentResponseRef0 = { + status: string executionId: string | null workflowId: string error: string | null @@ -9110,6 +9663,124 @@ export type ListPermissionGroupsResponse = { nextCursor: string | null } +/** `GET /api/v2/projects/[projectId]/files/folders` */ +export type ListProjectFileFoldersParams = { + projectId: string +} + +export type ListProjectFileFoldersQuery = { + scope?: 'active' | 'archived' | 'all' +} + +export type ListProjectFileFoldersResponse = { + data: Array<{ + id: string + userId: string + name: string + parentId: string | null + path: string + sortOrder: number + deletedAt: string | null + createdAt: string + updatedAt: string + owner: { + entityType: 'project' + entityId: string + } + }> + nextCursor: string | null +} + +/** `GET /api/v2/projects/[projectId]/files` */ +export type ListProjectFilesParams = { + projectId: string +} + +type ListProjectFilesQueryRef0 = string + +export type ListProjectFilesQuery = { + folderPath?: ListProjectFilesQueryRef0 + recursive?: + | 'true' + | '1' + | 'yes' + | 'on' + | 'y' + | 'enabled' + | 'false' + | '0' + | 'no' + | 'off' + | 'n' + | 'disabled' + scope?: 'active' | 'archived' + search?: string + sortBy?: 'name' | 'size' | 'uploadedAt' | 'updatedAt' + sortOrder?: 'asc' | 'desc' + limit?: number + cursor?: string +} + +type ListProjectFilesResponseRef0 = { + id: string + name: string + size: number + type: string + key: string + folderPath: string + uploadedAt: string + updatedAt: string + deletedAt: string | null + owner: { + entityType: 'project' + entityId: string + } + uploadedBy: string + revision?: string +} + +export type ListProjectFilesResponse = { + data: Array + nextCursor: string | null +} + +/** `GET /api/v2/projects/[projectId]/files/[fileId]/versions` */ +export type ListProjectFileVersionsParams = { + projectId: string + fileId: string +} + +export type ListProjectFileVersionsQuery = { + sortBy?: 'version' + sortOrder?: 'asc' | 'desc' + limit?: number + cursor?: string +} + +type ListProjectFileVersionsResponseRef0 = ListProjectFileVersionsResponseRef1 + +type ListProjectFileVersionsResponseRef1 = { + fileId: string + version: number + isCurrent: boolean + size: number + contentType: string + source: 'upload' | 'user' | 'api' | 'copilot' | 'workflow' | 'collab' | 'revert' | 'unknown' + authors: Array<{ + id: string + email: string | null + }> + restoredFromVersion: number | null + createdAt: string + updatedAt: string + supersededAt: string | null +} + +export type ListProjectFileVersionsResponse = { + data: Array + nextCursor: string | null +} + /** `GET /api/v2/sandboxes` */ export type ListSandboxesQuery = { workspaceId: string @@ -10023,6 +10694,30 @@ export type MoveFileItemsResponse = { data: MoveFileItemsResponseRef0 } +/** `POST /api/v2/projects/[projectId]/files/move` */ +export type MoveProjectFileItemsParams = { + projectId: string +} + +export type MoveProjectFileItemsQuery = Record + +type MoveProjectFileItemsBodyRef0 = string + +export type MoveProjectFileItemsBody = { + fileIds?: Array + folderIds?: Array + targetFolderPath?: MoveProjectFileItemsBodyRef0 +} + +export type MoveProjectFileItemsResponse = { + data: { + movedFiles: number + movedFolders: number + movedFileIds: Array + movedFolderIds: Array + } +} + /** `POST /api/v2/tables/move` */ export type MoveTablesQuery = Record @@ -11421,6 +12116,29 @@ export type ReadFileVersionTextResponse = { data: ReadFileVersionTextResponseRef0 } +/** `GET /api/v2/projects/[projectId]/files/[fileId]/content` */ +export type ReadProjectFileContentParams = { + projectId: string + fileId: string +} + +export type ReadProjectFileContentQuery = Record + +/** Non-JSON response (`binary`). */ +export type ReadProjectFileContentResponse = never + +/** `GET /api/v2/projects/[projectId]/files/[fileId]/versions/[version]/content` */ +export type ReadProjectFileVersionContentParams = { + projectId: string + fileId: string + version: number +} + +export type ReadProjectFileVersionContentQuery = Record + +/** Non-JSON response (`binary`). */ +export type ReadProjectFileVersionContentResponse = never + /** `PATCH /api/v2/files/folders` */ export type RelocateFileFolderQuery = Record @@ -11619,6 +12337,40 @@ export type RenameFileResponse = { data: RenameFileResponseRef0 } +/** `PATCH /api/v2/projects/[projectId]/files/[fileId]` */ +export type RenameProjectFileParams = { + projectId: string + fileId: string +} + +export type RenameProjectFileQuery = Record + +export type RenameProjectFileBody = { + name: string +} + +type RenameProjectFileResponseRef0 = { + id: string + name: string + size: number + type: string + key: string + folderPath: string + uploadedAt: string + updatedAt: string + deletedAt: string | null + owner: { + entityType: 'project' + entityId: string + } + uploadedBy: string + revision?: string +} + +export type RenameProjectFileResponse = { + data: RenameProjectFileResponseRef0 +} + /** `PUT /api/v2/workflows/[workflowId]/deployments/chat` */ export type ReplaceWorkflowChatDeploymentParams = { workflowId: string @@ -12113,21 +12865,87 @@ export type RestoreKnowledgeBaseResponse = { data: RestoreKnowledgeBaseResponseRef1 } -/** `POST /api/v2/tables/[tableId]/restore` */ -export type RestoreTableParams = { - tableId: string +/** `POST /api/v2/projects/[projectId]/files/[fileId]/restore` */ +export type RestoreProjectFileParams = { + projectId: string + fileId: string } -export type RestoreTableQuery = Record +export type RestoreProjectFileQuery = Record -export type RestoreTableBody = { - workspaceId: string -} +export type RestoreProjectFileBody = Record -type RestoreTableResponseRef0 = { - id: string | null - type: 'import' | 'delete' | 'export' | 'backfill' | 'update' | null - status: 'running' | 'ready' | 'failed' | 'canceled' +type RestoreProjectFileResponseRef0 = { + id: string + name: string + size: number + type: string + key: string + folderPath: string + uploadedAt: string + updatedAt: string + deletedAt: string | null + owner: { + entityType: 'project' + entityId: string + } + uploadedBy: string + revision?: string +} + +export type RestoreProjectFileResponse = { + data: RestoreProjectFileResponseRef0 +} + +/** `POST /api/v2/projects/[projectId]/files/folders/[folderId]/restore` */ +export type RestoreProjectFileFolderParams = { + projectId: string + folderId: string +} + +export type RestoreProjectFileFolderQuery = Record + +export type RestoreProjectFileFolderBody = Record + +export type RestoreProjectFileFolderResponse = { + data: { + folder: { + id: string + userId: string + name: string + parentId: string | null + path: string + sortOrder: number + deletedAt: string | null + createdAt: string + updatedAt: string + owner: { + entityType: 'project' + entityId: string + } + } + restoredItems: { + files: number + folders: number + } + } +} + +/** `POST /api/v2/tables/[tableId]/restore` */ +export type RestoreTableParams = { + tableId: string +} + +export type RestoreTableQuery = Record + +export type RestoreTableBody = { + workspaceId: string +} + +type RestoreTableResponseRef0 = { + id: string | null + type: 'import' | 'delete' | 'export' | 'backfill' | 'update' | null + status: 'running' | 'ready' | 'failed' | 'canceled' rowsProcessed: number error: string | null } @@ -12338,6 +13156,66 @@ export type RevertFileVersionResponse = { data: RevertFileVersionResponseRef2 } +/** `POST /api/v2/projects/[projectId]/files/[fileId]/versions/[version]/revert` */ +export type RevertProjectFileVersionParams = { + projectId: string + fileId: string + version: number +} + +export type RevertProjectFileVersionQuery = Record + +export type RevertProjectFileVersionBody = { + expectedCurrentVersion?: number + expectedRevision?: string +} + +type RevertProjectFileVersionResponseRef0 = { + id: string + name: string + size: number + type: string + key: string + folderPath: string + uploadedAt: string + updatedAt: string + deletedAt: string | null + owner: { + entityType: 'project' + entityId: string + } + uploadedBy: string + revision?: string +} + +type RevertProjectFileVersionResponseRef1 = RevertProjectFileVersionResponseRef2 + +type RevertProjectFileVersionResponseRef2 = { + fileId: string + version: number + isCurrent: boolean + size: number + contentType: string + source: 'upload' | 'user' | 'api' | 'copilot' | 'workflow' | 'collab' | 'revert' | 'unknown' + authors: Array<{ + id: string + email: string | null + }> + restoredFromVersion: number | null + createdAt: string + updatedAt: string + supersededAt: string | null +} + +export type RevertProjectFileVersionResponse = { + data: { + reverted: boolean + file: RevertProjectFileVersionResponseRef0 + version: RevertProjectFileVersionResponseRef1 + revision?: string + } +} + /** `POST /api/v2/workflows/[workflowId]/versions/[version]/revert` */ export type RevertWorkflowVersionParams = { version: number | 'active' @@ -12660,6 +13538,53 @@ export type SearchKnowledgeResponse = { data: SearchKnowledgeResponseRef1 } +/** `GET /api/v2/projects/[projectId]/files/search` */ +export type SearchProjectFileContentParams = { + projectId: string +} + +export type SearchProjectFileContentQuery = { + query: string + mode?: 'exact' | 'regex' + maxResults?: number + folderPaths?: string + includeSubfolders?: + | 'true' + | '1' + | 'yes' + | 'on' + | 'y' + | 'enabled' + | 'false' + | '0' + | 'no' + | 'off' + | 'n' + | 'disabled' +} + +type SearchProjectFileContentResponseRef0 = { + results: Array<{ + fileId: string + lineNumber: number + text: string + }> + count: number + truncated: boolean + complete: boolean + indexStatus: { + readyFiles: number + pendingFiles: number + failedFiles: number + skippedFiles: number + partialFiles: number + } +} + +export type SearchProjectFileContentResponse = { + data: SearchProjectFileContentResponseRef0 +} + /** `POST /api/v2/tables/[tableId]/rows/search` */ export type SearchTableRowsParams = { tableId: string @@ -12983,6 +13908,26 @@ export type UnzipFileResponse = { data: UnzipFileResponseRef0 } +/** `POST /api/v2/projects/[projectId]/files/[fileId]/unzip` */ +export type UnzipProjectFileParams = { + projectId: string + fileId: string +} + +export type UnzipProjectFileQuery = Record + +type UnzipProjectFileResponseRef0 = UnzipProjectFileResponseRef1 + +type UnzipProjectFileResponseRef1 = { + folderPath: string + extractedFileCount: number + skippedFileCount: number +} + +export type UnzipProjectFileResponse = { + data: UnzipProjectFileResponseRef0 +} + /** `PATCH /api/v2/credentials/[credentialId]` */ export type UpdateCredentialParams = { credentialId: string @@ -13600,6 +14545,105 @@ export type UpdatePermissionGroupResponse = { data: UpdatePermissionGroupResponseRef0 } +/** `PUT /api/v2/projects/[projectId]/files/[fileId]/content` */ +export type UpdateProjectFileContentParams = { + projectId: string + fileId: string +} + +export type UpdateProjectFileContentQuery = Record + +export type UpdateProjectFileContentBody = { + content: string + encoding?: 'utf-8' | 'base64' + expectedRevision?: string +} + +type UpdateProjectFileContentResponseRef0 = { + id: string + name: string + size: number + type: string + key: string + folderPath: string + uploadedAt: string + updatedAt: string + deletedAt: string | null + owner: { + entityType: 'project' + entityId: string + } + uploadedBy: string + revision?: string +} + +export type UpdateProjectFileContentResponse = { + data: UpdateProjectFileContentResponseRef0 +} + +/** `PATCH /api/v2/projects/[projectId]/files/folders/[folderId]` */ +export type UpdateProjectFileFolderParams = { + projectId: string + folderId: string +} + +export type UpdateProjectFileFolderQuery = Record + +export type UpdateProjectFileFolderBody = { + name?: string + parentId?: string | null + sortOrder?: number +} + +export type UpdateProjectFileFolderResponse = { + data: { + id: string + userId: string + name: string + parentId: string | null + path: string + sortOrder: number + deletedAt: string | null + createdAt: string + updatedAt: string + owner: { + entityType: 'project' + entityId: string + } + } +} + +/** `PATCH /api/v2/projects/[projectId]/files/[fileId]/share` */ +export type UpdateProjectFileShareParams = { + projectId: string + fileId: string +} + +export type UpdateProjectFileShareQuery = Record + +export type UpdateProjectFileShareBody = { + isActive: boolean + authType?: 'public' | 'password' | 'email' | 'sso' + password?: string + allowedEmails?: Array +} + +type UpdateProjectFileShareResponseRef0 = { + id: string + token: string + url: string + isActive: boolean + resourceType: 'file' | 'folder' + resourceId: string + authType: 'public' | 'password' | 'email' | 'sso' + hasPassword: boolean + allowedEmails: Array +} + +export type UpdateProjectFileShareResponse = { + data: UpdateProjectFileShareResponseRef0 +} + /** `PATCH /api/v2/tables/[tableId]/rows` */ export type UpdateRowsByFilterParams = { tableId: string @@ -14608,6 +15652,25 @@ export const V2_OPERATIONS = { }, }, }, + abortProjectFileUpload: { + method: 'DELETE', + path: '/api/v2/projects/[projectId]/files/uploads/[uploadId]', + pathParams: ['projectId', 'uploadId'] as const, + pathParamDocs: { + projectId: 'Project identifier.', + uploadId: 'Upload session identifier within the Project.', + }, + responseMode: 'json', + summary: 'Abort Project File Upload', + workspaceKeyUnsupported: true, + headers: { + 'upload-token': { + kind: 'string', + required: true, + describe: 'Signed upload control token returned when the upload session was created.', + }, + }, + }, activateWorkflowVersion: { method: 'POST', path: '/api/v2/workflows/[workflowId]/versions/[version]/activate', @@ -14767,6 +15830,24 @@ export const V2_OPERATIONS = { }, }, }, + archiveProjectFileItems: { + method: 'POST', + path: '/api/v2/projects/[projectId]/files/archive', + pathParams: ['projectId'] as const, + pathParamDocs: { projectId: 'Project identifier.' }, + responseMode: 'json', + summary: 'Archive Project File Items', + workspaceKeyUnsupported: true, + body: { + fileIds: { kind: 'array', default: [], describe: 'Identifiers of the files to archive.' }, + folderIds: { + kind: 'array', + default: [], + describe: + 'Identifiers of folders to archive recursively, including their files and descendants.', + }, + }, + }, bulkAddPermissionGroupMembers: { method: 'POST', path: '/api/v2/organizations/[organizationId]/permission-groups/[groupId]/members/bulk', @@ -15148,6 +16229,25 @@ export const V2_OPERATIONS = { }, }, }, + completeProjectFileUpload: { + method: 'POST', + path: '/api/v2/projects/[projectId]/files/uploads/[uploadId]/complete', + pathParams: ['projectId', 'uploadId'] as const, + pathParamDocs: { + projectId: 'Project identifier.', + uploadId: 'Upload session identifier within the Project.', + }, + responseMode: 'json', + summary: 'Complete Project File Upload', + workspaceKeyUnsupported: true, + headers: { + 'upload-token': { + kind: 'string', + required: true, + describe: 'Signed upload control token returned when the upload session was created.', + }, + }, + }, completeTableImport: { method: 'POST', path: '/api/v2/tables/imports/[importId]/complete', @@ -15170,6 +16270,26 @@ export const V2_OPERATIONS = { }, }, }, + copyFileItems: { + method: 'POST', + path: '/api/v2/files/copy', + pathParams: [] as const, + responseMode: 'json', + summary: 'Copy File Items', + workspaceKeyUnsupported: true, + body: { + source: { + kind: 'object', + required: true, + describe: 'Selection to read under the source owner.', + }, + destination: { + kind: 'object', + required: true, + describe: 'Destination requiring file write access.', + }, + }, + }, createCredentialConnection: { method: 'POST', path: '/api/v2/credentials/connections', @@ -15668,15 +16788,91 @@ export const V2_OPERATIONS = { }, }, }, - createSandbox: { + createProjectFile: { method: 'POST', - path: '/api/v2/sandboxes', - pathParams: [] as const, + path: '/api/v2/projects/[projectId]/files', + pathParams: ['projectId'] as const, + pathParamDocs: { projectId: 'Project identifier.' }, responseMode: 'json', - summary: 'Create Sandbox', + summary: 'Create Project File', workspaceKeyUnsupported: true, body: { - workspaceId: { + name: { + kind: 'string', + required: true, + describe: + 'File name, including its extension. Path separators and dot segments are rejected.', + }, + contentType: { + kind: 'string', + describe: 'MIME type. When omitted, it is inferred from the file extension.', + }, + content: { + kind: 'string', + default: '', + describe: + 'Initial file content. Omit or send an empty string for a zero-byte file. The 70,000,000-character bound guards the JSON envelope; the decoded bytes must be at most 50 MiB, and a longer base64 payload is rejected with `413`. Use an upload session for anything larger.', + }, + encoding: { + kind: 'enum', + values: ['utf-8', 'base64'] as const, + default: 'utf-8', + describe: 'Encoding of the content field.', + }, + folderPath: { + kind: 'string', + describe: 'Canonical containing-folder path. Omit for the Project root.', + }, + }, + }, + createProjectFileFolder: { + method: 'POST', + path: '/api/v2/projects/[projectId]/files/folders', + pathParams: ['projectId'] as const, + pathParamDocs: { projectId: 'Project identifier.' }, + responseMode: 'json', + summary: 'Create Project File Folder', + workspaceKeyUnsupported: true, + body: { + name: { kind: 'string', required: true, describe: 'Name for the new folder.' }, + parentId: { + kind: 'string', + describe: 'Parent folder identifier; omit or use null for the root.', + }, + }, + }, + createProjectFileUpload: { + method: 'POST', + path: '/api/v2/projects/[projectId]/files/uploads', + pathParams: ['projectId'] as const, + pathParamDocs: { projectId: 'Project identifier.' }, + responseMode: 'json', + summary: 'Create Project File Upload', + workspaceKeyUnsupported: true, + body: { + name: { kind: 'string', required: true, describe: 'File name, including its extension.' }, + contentType: { kind: 'string', required: true, describe: 'MIME type of the uploaded file.' }, + size: { kind: 'integer', required: true, describe: 'Exact file size in bytes.' }, + folderPath: { + kind: 'string', + describe: + 'Canonical destination folder path. Specify either folderId or folderPath, not both.', + }, + folderId: { + kind: 'string', + describe: 'Destination folder identifier; omit or use null for the Project root.', + }, + }, + }, + createSandbox: { + method: 'POST', + path: '/api/v2/sandboxes', + pathParams: [] as const, + responseMode: 'json', + summary: 'Create Sandbox', + workspaceKeyUnsupported: true, + body: { + workspaceId: { kind: 'string', required: true, describe: 'Workspace in which to create the sandbox.', @@ -16306,6 +17502,19 @@ export const V2_OPERATIONS = { summary: 'Delete Permission Group', workspaceKeyUnsupported: true, }, + deleteProjectFileVersion: { + method: 'DELETE', + path: '/api/v2/projects/[projectId]/files/[fileId]/versions/[version]', + pathParams: ['projectId', 'fileId', 'version'] as const, + pathParamDocs: { + projectId: 'Project identifier.', + fileId: 'File identifier within the Project.', + version: 'Version number.', + }, + responseMode: 'json', + summary: 'Delete Project File Version', + workspaceKeyUnsupported: true, + }, deleteSandbox: { method: 'DELETE', path: '/api/v2/sandboxes/[sandboxId]', @@ -16719,6 +17928,26 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', required: true, describe: 'Workspace that owns the file.' }, }, }, + downloadProjectFileItems: { + method: 'GET', + path: '/api/v2/projects/[projectId]/files/bulk-download', + pathParams: ['projectId'] as const, + pathParamDocs: { projectId: 'Project identifier.' }, + responseMode: 'binary', + summary: 'Download Project File Items', + workspaceKeyUnsupported: true, + query: { + fileIds: { + kind: 'string', + describe: 'File identifiers to include, comma-separated. At most 100 entries.', + }, + folderIds: { + kind: 'string', + describe: + 'Folder identifiers to include recursively, comma-separated. The resolved selection allows at most 100 files.', + }, + }, + }, downloadRunFile: { method: 'GET', path: '/api/v2/workflows/[workflowId]/runs/[runId]/files/[fileId]', @@ -16900,6 +18129,26 @@ export const V2_OPERATIONS = { }, }, }, + exportProjectFileSnapshot: { + method: 'POST', + path: '/api/v2/projects/[projectId]/files/[fileId]/export', + pathParams: ['projectId', 'fileId'] as const, + pathParamDocs: { + projectId: 'Project identifier.', + fileId: 'File identifier within the Project.', + }, + responseMode: 'binary', + summary: 'Export Project File Snapshot', + workspaceKeyUnsupported: true, + body: { + content: { + kind: 'string', + required: true, + describe: + 'Visible Markdown snapshot to export. This does not replace the stored file or create a version.', + }, + }, + }, exportWorkflow: { method: 'GET', path: '/api/v2/workflows/[workflowId]/export', @@ -17417,6 +18666,88 @@ export const V2_OPERATIONS = { summary: 'Get Permission Group', workspaceKeyUnsupported: true, }, + getProjectFileMetadata: { + method: 'GET', + path: '/api/v2/projects/[projectId]/files/[fileId]/metadata', + pathParams: ['projectId', 'fileId'] as const, + pathParamDocs: { + projectId: 'Project identifier.', + fileId: 'File identifier within the Project.', + }, + responseMode: 'json', + summary: 'Get Project File Metadata', + workspaceKeyUnsupported: true, + }, + getProjectFileShare: { + method: 'GET', + path: '/api/v2/projects/[projectId]/files/[fileId]/share', + pathParams: ['projectId', 'fileId'] as const, + pathParamDocs: { + projectId: 'Project identifier.', + fileId: 'File identifier within the Project.', + }, + responseMode: 'json', + summary: 'Get Project File Share', + workspaceKeyUnsupported: true, + }, + getProjectFileUpload: { + method: 'GET', + path: '/api/v2/projects/[projectId]/files/uploads/[uploadId]', + pathParams: ['projectId', 'uploadId'] as const, + pathParamDocs: { + projectId: 'Project identifier.', + uploadId: 'Upload session identifier within the Project.', + }, + responseMode: 'json', + summary: 'Get Project File Upload', + workspaceKeyUnsupported: true, + headers: { + 'upload-token': { + kind: 'string', + required: true, + describe: 'Signed upload control token returned when the upload session was created.', + }, + }, + }, + getProjectFileUploadPartUrls: { + method: 'POST', + path: '/api/v2/projects/[projectId]/files/uploads/[uploadId]/parts', + pathParams: ['projectId', 'uploadId'] as const, + pathParamDocs: { + projectId: 'Project identifier.', + uploadId: 'Upload session identifier within the Project.', + }, + responseMode: 'json', + summary: 'Get Project File Upload Part URLs', + workspaceKeyUnsupported: true, + body: { + partNumbers: { + kind: 'array', + required: true, + describe: 'Multipart part numbers for which signed URLs should be created.', + }, + }, + headers: { + 'upload-token': { + kind: 'string', + required: true, + describe: 'Signed upload control token returned when the upload session was created.', + }, + }, + }, + getProjectFileVersion: { + method: 'GET', + path: '/api/v2/projects/[projectId]/files/[fileId]/versions/[version]', + pathParams: ['projectId', 'fileId', 'version'] as const, + pathParamDocs: { + projectId: 'Project identifier.', + fileId: 'File identifier within the Project.', + version: 'Version number.', + }, + responseMode: 'json', + summary: 'Get Project File Version', + workspaceKeyUnsupported: true, + }, getRowEnrichment: { method: 'GET', path: '/api/v2/tables/[tableId]/rows/[rowId]/enrichment/[groupId]', @@ -19549,6 +20880,129 @@ export const V2_OPERATIONS = { }, }, }, + listProjectFileFolders: { + method: 'GET', + path: '/api/v2/projects/[projectId]/files/folders', + pathParams: ['projectId'] as const, + pathParamDocs: { projectId: 'Project identifier.' }, + responseMode: 'json', + summary: 'List Project File Folders', + workspaceKeyUnsupported: true, + query: { + scope: { + kind: 'enum', + values: ['active', 'archived', 'all'] as const, + default: 'active', + describe: 'Folder lifecycle scope.', + }, + }, + }, + listProjectFiles: { + method: 'GET', + path: '/api/v2/projects/[projectId]/files', + pathParams: ['projectId'] as const, + pathParamDocs: { projectId: 'Project identifier.' }, + responseMode: 'json', + summary: 'List Project Files', + workspaceKeyUnsupported: true, + query: { + folderPath: { + kind: 'string', + describe: + 'Restrict files to this folder, including subfolders when `recursive` is true. Unknown folder paths contribute no matches.', + }, + recursive: { + kind: 'enum', + values: [ + 'true', + '1', + 'yes', + 'on', + 'y', + 'enabled', + 'false', + '0', + 'no', + 'off', + 'n', + 'disabled', + ] as const, + describe: + 'Include subfolders in the folder filter. Defaults to true when searching and false otherwise. Ignored without a folder filter.', + }, + scope: { + kind: 'enum', + values: ['active', 'archived'] as const, + default: 'active', + describe: + 'Which lifecycle set to list: `active` (default) for live files, `archived` for files a delete soft-deleted. `folderPath` resolves against active folders only, so pairing it with `scope=archived` returns an empty page when the containing folder was archived too.', + }, + search: { + kind: 'string', + describe: 'Case-insensitive substring match against the file name.', + }, + sortBy: { + kind: 'enum', + values: ['name', 'size', 'uploadedAt', 'updatedAt'] as const, + default: 'uploadedAt', + describe: + 'Field used to sort the result. Sorting by `name` is case-sensitive and follows the storage collation, so do not rely on a case-insensitive order.', + }, + sortOrder: { + kind: 'enum', + values: ['asc', 'desc'] as const, + default: 'asc', + describe: 'Sort direction.', + }, + limit: { + kind: 'integer', + default: 100, + describe: 'Maximum files per page. Must be a whole number from 1 to 1000. Defaults to 100.', + }, + cursor: { + kind: 'string', + describe: + 'Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.', + }, + }, + }, + listProjectFileVersions: { + method: 'GET', + path: '/api/v2/projects/[projectId]/files/[fileId]/versions', + pathParams: ['projectId', 'fileId'] as const, + pathParamDocs: { + projectId: 'Project identifier.', + fileId: 'File identifier within the Project.', + }, + responseMode: 'json', + summary: 'List Project File Versions', + workspaceKeyUnsupported: true, + query: { + sortBy: { + kind: 'enum', + values: ['version'] as const, + default: 'version', + describe: 'Field used to sort the result.', + }, + sortOrder: { + kind: 'enum', + values: ['asc', 'desc'] as const, + default: 'desc', + describe: 'Sort direction.', + }, + limit: { + kind: 'integer', + default: 50, + describe: + 'Maximum versions to return per page. Must be a whole number from 1 to 100. Defaults to 50.', + }, + cursor: { + kind: 'string', + describe: + 'Opaque cursor from the previous page. Send it back with the same sort and filters; only `limit` may change. Change anything else and pagination must restart without a cursor.', + }, + }, + }, listSandboxes: { method: 'GET', path: '/api/v2/sandboxes', @@ -20479,6 +21933,28 @@ export const V2_OPERATIONS = { }, }, }, + moveProjectFileItems: { + method: 'POST', + path: '/api/v2/projects/[projectId]/files/move', + pathParams: ['projectId'] as const, + pathParamDocs: { projectId: 'Project identifier.' }, + responseMode: 'json', + summary: 'Move Project File Items', + workspaceKeyUnsupported: true, + body: { + fileIds: { kind: 'array', default: [], describe: 'Identifiers of the files to move.' }, + folderIds: { + kind: 'array', + default: [], + describe: 'Identifiers of folders to move with their contents.', + }, + targetFolderPath: { + kind: 'string', + describe: + 'Existing destination folder path within the Project. Omit to move items to the Project root.', + }, + }, + }, moveTables: { method: 'POST', path: '/api/v2/tables/move', @@ -20874,6 +22350,31 @@ export const V2_OPERATIONS = { }, }, }, + readProjectFileContent: { + method: 'GET', + path: '/api/v2/projects/[projectId]/files/[fileId]/content', + pathParams: ['projectId', 'fileId'] as const, + pathParamDocs: { + projectId: 'Project identifier.', + fileId: 'File identifier within the Project.', + }, + responseMode: 'binary', + summary: 'Read Project File Source', + workspaceKeyUnsupported: true, + }, + readProjectFileVersionContent: { + method: 'GET', + path: '/api/v2/projects/[projectId]/files/[fileId]/versions/[version]/content', + pathParams: ['projectId', 'fileId', 'version'] as const, + pathParamDocs: { + projectId: 'Project identifier.', + fileId: 'File identifier within the Project.', + version: 'Version number.', + }, + responseMode: 'binary', + summary: 'Read Project File Version Content', + workspaceKeyUnsupported: true, + }, relocateFileFolder: { method: 'PATCH', path: '/api/v2/files/folders', @@ -21011,6 +22512,21 @@ export const V2_OPERATIONS = { name: { kind: 'string', required: true, describe: 'New file name, including its extension.' }, }, }, + renameProjectFile: { + method: 'PATCH', + path: '/api/v2/projects/[projectId]/files/[fileId]', + pathParams: ['projectId', 'fileId'] as const, + pathParamDocs: { + projectId: 'Project identifier.', + fileId: 'File identifier within the Project.', + }, + responseMode: 'json', + summary: 'Rename Project File', + workspaceKeyUnsupported: true, + body: { + name: { kind: 'string', required: true, describe: 'New file name, including its extension.' }, + }, + }, replaceWorkflowChatDeployment: { method: 'PUT', path: '/api/v2/workflows/[workflowId]/deployments/chat', @@ -21232,6 +22748,32 @@ export const V2_OPERATIONS = { }, }, }, + restoreProjectFile: { + method: 'POST', + path: '/api/v2/projects/[projectId]/files/[fileId]/restore', + pathParams: ['projectId', 'fileId'] as const, + pathParamDocs: { + projectId: 'Project identifier.', + fileId: 'File identifier within the Project.', + }, + responseMode: 'json', + summary: 'Restore Project File', + workspaceKeyUnsupported: true, + body: {}, + }, + restoreProjectFileFolder: { + method: 'POST', + path: '/api/v2/projects/[projectId]/files/folders/[folderId]/restore', + pathParams: ['projectId', 'folderId'] as const, + pathParamDocs: { + projectId: 'Project identifier.', + folderId: 'Folder identifier within the Project.', + }, + responseMode: 'json', + summary: 'Restore Project File Folder', + workspaceKeyUnsupported: true, + body: {}, + }, restoreTable: { method: 'POST', path: '/api/v2/tables/[tableId]/restore', @@ -21310,6 +22852,31 @@ export const V2_OPERATIONS = { }, }, }, + revertProjectFileVersion: { + method: 'POST', + path: '/api/v2/projects/[projectId]/files/[fileId]/versions/[version]/revert', + pathParams: ['projectId', 'fileId', 'version'] as const, + pathParamDocs: { + projectId: 'Project identifier.', + fileId: 'File identifier within the Project.', + version: 'Version number.', + }, + responseMode: 'json', + summary: 'Revert Project File Version', + workspaceKeyUnsupported: true, + body: { + expectedCurrentVersion: { + kind: 'integer', + describe: + 'Revert only while this is still the current version; otherwise the request fails with `409`. Omit to revert whatever is current. Collaborative edits and repeated workflow writes that fold into the current version keep its number, so prefer `expectedRevision` to guard content.', + }, + expectedRevision: { + kind: 'string', + describe: + 'Revert only while the file still holds the content this revision names, as returned by Get File Metadata or an earlier write; otherwise the request fails with `409`. Unlike a version number, it also catches edits that folded into the current version.', + }, + }, + }, revertWorkflowVersion: { method: 'POST', path: '/api/v2/workflows/[workflowId]/versions/[version]/revert', @@ -21782,6 +23349,53 @@ export const V2_OPERATIONS = { }, }, }, + searchProjectFileContent: { + method: 'GET', + path: '/api/v2/projects/[projectId]/files/search', + pathParams: ['projectId'] as const, + pathParamDocs: { projectId: 'Project identifier.' }, + responseMode: 'json', + summary: 'Search Project File Content', + workspaceKeyUnsupported: true, + query: { + query: { + kind: 'string', + required: true, + describe: 'Regular expression, or exact text when `mode` is `exact`.', + }, + mode: { + kind: 'enum', + values: ['exact', 'regex'] as const, + default: 'regex', + describe: 'How `query` is read.', + }, + maxResults: { kind: 'integer', default: 50, describe: 'Maximum matching lines to return.' }, + folderPaths: { + kind: 'string', + describe: + 'Comma-separated folder paths within the Project. Omit to search the entire Project; index coverage applies to the selected folders.', + }, + includeSubfolders: { + kind: 'enum', + values: [ + 'true', + '1', + 'yes', + 'on', + 'y', + 'enabled', + 'false', + '0', + 'no', + 'off', + 'n', + 'disabled', + ] as const, + describe: + 'Whether the scope descends into nested folders. Absent means yes. The listed spellings are the whole accepted vocabulary and are case-sensitive; any other value is rejected.', + }, + }, + }, searchTableRows: { method: 'POST', path: '/api/v2/tables/[tableId]/rows/search', @@ -21942,6 +23556,18 @@ export const V2_OPERATIONS = { workspaceId: { kind: 'string', required: true, describe: 'Workspace that owns the archive.' }, }, }, + unzipProjectFile: { + method: 'POST', + path: '/api/v2/projects/[projectId]/files/[fileId]/unzip', + pathParams: ['projectId', 'fileId'] as const, + pathParamDocs: { + projectId: 'Project identifier.', + fileId: 'File identifier within the Project.', + }, + responseMode: 'json', + summary: 'Unzip Project File', + workspaceKeyUnsupported: true, + }, updateCredential: { method: 'PATCH', path: '/api/v2/credentials/[credentialId]', @@ -22383,6 +24009,94 @@ export const V2_OPERATIONS = { }, }, }, + updateProjectFileContent: { + method: 'PUT', + path: '/api/v2/projects/[projectId]/files/[fileId]/content', + pathParams: ['projectId', 'fileId'] as const, + pathParamDocs: { + projectId: 'Project identifier.', + fileId: 'File identifier within the Project.', + }, + responseMode: 'json', + summary: 'Replace Project File Content', + workspaceKeyUnsupported: true, + body: { + content: { + kind: 'string', + required: true, + describe: + 'Complete replacement content for the file. The 70,000,000-character bound guards the JSON envelope; the decoded bytes must be at most 50 MiB, and a longer base64 payload is rejected with `413`.', + }, + encoding: { + kind: 'enum', + values: ['utf-8', 'base64'] as const, + default: 'utf-8', + describe: 'Encoding of the content field.', + }, + expectedRevision: { + kind: 'string', + describe: + 'Revision from Get File Metadata or an earlier write; the request is refused with `409` when the content moved on.', + }, + }, + }, + updateProjectFileFolder: { + method: 'PATCH', + path: '/api/v2/projects/[projectId]/files/folders/[folderId]', + pathParams: ['projectId', 'folderId'] as const, + pathParamDocs: { + projectId: 'Project identifier.', + folderId: 'Folder identifier within the Project.', + }, + responseMode: 'json', + summary: 'Update Project File Folder', + workspaceKeyUnsupported: true, + body: { + name: { kind: 'string', describe: 'New folder name; omit to leave unchanged.' }, + parentId: { + kind: 'string', + describe: + 'New parent folder identifier; null moves to the root, omission leaves the parent unchanged.', + }, + sortOrder: { kind: 'integer', describe: 'New manual position; omit to leave unchanged.' }, + }, + }, + updateProjectFileShare: { + method: 'PATCH', + path: '/api/v2/projects/[projectId]/files/[fileId]/share', + pathParams: ['projectId', 'fileId'] as const, + pathParamDocs: { + projectId: 'Project identifier.', + fileId: 'File identifier within the Project.', + }, + responseMode: 'json', + summary: 'Update Project File Share', + workspaceKeyUnsupported: true, + body: { + isActive: { + kind: 'boolean', + required: true, + describe: + 'Whether the share should resolve. Disabling preserves the token and the whole access configuration, so re-enabling restores the share as it was; enabling rewrites the credentials the resulting mode does not use.', + }, + authType: { + kind: 'enum', + values: ['public', 'password', 'email', 'sso'] as const, + describe: + 'How access to the share is gated. The stored mode is kept when omitted. Enabling `public` clears the stored password and empties `allowedEmails`; `password` empties `allowedEmails`; `email` and `sso` clear the stored password.', + }, + password: { + kind: 'string', + describe: + 'Literal password of 15 to 1024 characters. Kept when omitted; enabling password access without a supplied or stored password is rejected.', + }, + allowedEmails: { + kind: 'array', + describe: + 'Allowed addresses or `@domain` patterns for email and SSO shares. Kept when omitted; enabling `email` or `sso` with an empty resulting list is a 400.', + }, + }, + }, updateRowsByFilter: { method: 'PATCH', path: '/api/v2/tables/[tableId]/rows', diff --git a/packages/sim-cli/src/http/client.test.ts b/packages/sim-cli/src/http/client.test.ts index 4cf4279cc4f..63146764eb6 100644 --- a/packages/sim-cli/src/http/client.test.ts +++ b/packages/sim-cli/src/http/client.test.ts @@ -402,6 +402,7 @@ describe('destructive operations are gated', () => { 'activateWorkflowVersion', 'applyWorkflowOperations', 'applyWorkflowVariables', + 'archiveProjectFileItems', 'bulkDeleteFiles', 'bulkDeleteTables', 'bulkUpdateKnowledgeChunks', @@ -417,6 +418,22 @@ describe('destructive operations are gated', () => { * decision on anything new. */ const NON_DESTRUCTIVE = new Set([ + 'completeProjectFileUpload', + 'copyFileItems', + 'createProjectFile', + 'createProjectFileFolder', + 'createProjectFileUpload', + 'exportProjectFileSnapshot', + 'getProjectFileUploadPartUrls', + 'moveProjectFileItems', + 'renameProjectFile', + 'restoreProjectFile', + 'restoreProjectFileFolder', + 'revertProjectFileVersion', + 'unzipProjectFile', + 'updateProjectFileContent', + 'updateProjectFileFolder', + 'updateProjectFileShare', /** These amend access or request history without discarding a resource. */ 'cancelOrganizationAccessRequest', 'cancelWorkspaceAccessRequest', diff --git a/packages/sim-cli/src/runtime/execute.ts b/packages/sim-cli/src/runtime/execute.ts index 79686b15593..a799cb5170b 100644 --- a/packages/sim-cli/src/runtime/execute.ts +++ b/packages/sim-cli/src/runtime/execute.ts @@ -131,6 +131,13 @@ export const BULK_OUTCOME_CHECKS: Readonly { + const items = payload.deletedItems as { files?: unknown; folders?: unknown } | undefined + if (countOf(items?.files) + countOf(items?.folders) > 0) return null + const requested = lengthOf(body?.fileIds) + lengthOf(body?.folderIds) + if (requested === 0) return null + return `Archived nothing: none of the ${requested} requested ${requested === 1 ? 'item was' : 'items were'} archived.` + }, /** * `added` empty with `failed` populated is a call that indexed nothing. An * empty request — no file resolved to a reference at all — is not a failure, diff --git a/packages/sim-cli/src/transfer/upload-session.ts b/packages/sim-cli/src/transfer/upload-session.ts index 1d8c2713b59..cb9bf068c6d 100644 --- a/packages/sim-cli/src/transfer/upload-session.ts +++ b/packages/sim-cli/src/transfer/upload-session.ts @@ -24,6 +24,7 @@ export type UploadTransfer = export interface UploadSession { basePath: string + query?: Record uploadToken: string transfer: UploadTransfer size: number @@ -62,7 +63,6 @@ async function uploadBytes( async function uploadParts( client: SimClient, - workspaceId: string, session: UploadSession, transfer: Extract, file: Blob | StreamingUpload @@ -90,7 +90,7 @@ async function uploadParts( `${session.basePath}/parts`, { method: 'POST', - query: { workspaceId }, + query: session.query, headers: { 'upload-token': session.uploadToken }, body: { partNumbers }, } @@ -121,7 +121,6 @@ async function uploadParts( /** Uploads and completes a signed transfer, aborting its session if the transfer fails. */ export async function finishUploadSession( client: SimClient, - workspaceId: string, session: UploadSession, path: string ): Promise { @@ -158,7 +157,7 @@ export async function finishUploadSession( 'Upload' ) } else { - await uploadParts(client, workspaceId, session, session.transfer, file) + await uploadParts(client, session, session.transfer, file) } await streamed?.verifyComplete() await streamed?.close() @@ -166,7 +165,7 @@ export async function finishUploadSession( const completed = await client.request<{ data: T }>(`${session.basePath}/complete`, { method: 'POST', - query: { workspaceId }, + query: session.query, headers: { 'upload-token': session.uploadToken }, }) return completed.data @@ -180,7 +179,7 @@ export async function finishUploadSession( await cleanupClient .request(session.basePath, { method: 'DELETE', - query: { workspaceId }, + query: session.query, headers: { 'upload-token': session.uploadToken }, }) .catch(() => undefined) diff --git a/packages/testing/src/mocks/billing-storage.mock.ts b/packages/testing/src/mocks/billing-storage.mock.ts index 334280ec593..3254aef3c7e 100644 --- a/packages/testing/src/mocks/billing-storage.mock.ts +++ b/packages/testing/src/mocks/billing-storage.mock.ts @@ -1,5 +1,7 @@ import { vi } from 'vitest' +const mockApplyFileStorageDelta = vi.fn<(deltaBytes: number) => Promise>(async () => 0) + /** * Stand-in for `StorageLimitExceededError` with the real `name`, constructor, and * `code: 'payload_too_large'`. It is NOT a subclass of the real `OrchestrationError`; a test @@ -17,9 +19,8 @@ export class MockStorageLimitExceededError extends Error { /** * Controllable mock functions for `@/lib/billing/storage`. * - * Every export is a bare `vi.fn()` (the async ones resolve `undefined`, which covers the - * fire-and-forget `maybeNotifyStorageLimitForBillingContext` and the `*InTx` counters); set the - * billing context, quota result, or limit a test needs per case. + * The file preparer returns a controllable signed mutation; other exports are bare `vi.fn()` + * functions. Set the billing context, quota result, or limit a test needs per case. * * @example * ```ts @@ -30,6 +31,11 @@ export class MockStorageLimitExceededError extends Error { * ``` */ export const billingStorageMockFns = { + mockApplyFileStorageDelta, + mockPrepareFileAccountingInTx: vi.fn(async () => ({ + billing: {}, + mutation: { applyDelta: mockApplyFileStorageDelta }, + })), mockResolveStorageBillingContext: vi.fn(), mockCheckStorageQuota: vi.fn(), mockCheckStorageQuotaForBillingContext: vi.fn(), @@ -38,7 +44,6 @@ export const billingStorageMockFns = { mockGetUserStorageLimit: vi.fn(), mockGetUserStorageUsage: vi.fn(), mockApplyStorageUsageDeltasInTx: vi.fn(), - mockLockWorkspaceStorageForMutationInTx: vi.fn(), mockCheckAndIncrementStorageUsageInTx: vi.fn(), mockDecrementStorageUsageForBillingContextInTx: vi.fn(), mockIncrementAdmittedStorageUsageForBillingContextInTx: vi.fn(), @@ -56,6 +61,7 @@ export const billingStorageMockFns = { * ``` */ export const billingStorageMock = { + prepareFileAccountingInTx: billingStorageMockFns.mockPrepareFileAccountingInTx, StorageLimitExceededError: MockStorageLimitExceededError, resolveStorageBillingContext: billingStorageMockFns.mockResolveStorageBillingContext, checkStorageQuota: billingStorageMockFns.mockCheckStorageQuota, @@ -65,8 +71,6 @@ export const billingStorageMock = { getUserStorageLimit: billingStorageMockFns.mockGetUserStorageLimit, getUserStorageUsage: billingStorageMockFns.mockGetUserStorageUsage, applyStorageUsageDeltasInTx: billingStorageMockFns.mockApplyStorageUsageDeltasInTx, - lockWorkspaceStorageForMutationInTx: - billingStorageMockFns.mockLockWorkspaceStorageForMutationInTx, checkAndIncrementStorageUsageInTx: billingStorageMockFns.mockCheckAndIncrementStorageUsageInTx, decrementStorageUsageForBillingContextInTx: billingStorageMockFns.mockDecrementStorageUsageForBillingContextInTx, diff --git a/packages/testing/src/mocks/file-read-receipt.mock.ts b/packages/testing/src/mocks/file-read-receipt.mock.ts new file mode 100644 index 00000000000..1fdacb3a286 --- /dev/null +++ b/packages/testing/src/mocks/file-read-receipt.mock.ts @@ -0,0 +1,14 @@ +import { vi } from 'vitest' + +const fileReadReceiptMockFns = { + mockCreateFileReadReceipt: vi.fn(() => ({ + owner: { entityType: 'workspace' as const, entityId: 'workspace-1' }, + files: [], + })), + mockRecheckFileReadReceipt: vi.fn(async () => ({ status: 'exact' as const, entries: [] })), +} + +export const fileReadReceiptMock = { + createFileReadReceipt: fileReadReceiptMockFns.mockCreateFileReadReceipt, + recheckFileReadReceipt: fileReadReceiptMockFns.mockRecheckFileReadReceipt, +} diff --git a/packages/testing/src/mocks/public-shares.mock.ts b/packages/testing/src/mocks/public-shares.mock.ts index 20f00a584a6..66122f3447e 100644 --- a/packages/testing/src/mocks/public-shares.mock.ts +++ b/packages/testing/src/mocks/public-shares.mock.ts @@ -26,7 +26,7 @@ export class MockShareValidationError extends Error { * ```ts * import { publicSharesMockFns } from '@sim/testing/mocks/public-shares.mock' * - * publicSharesMockFns.mockResolveActiveShareByToken.mockResolvedValue(share) + * publicSharesMockFns.mockGetShareForResource.mockResolvedValue(share) * ``` */ export const publicSharesMockFns = { @@ -42,7 +42,6 @@ export const publicSharesMockFns = { async (..._args: unknown[]): Promise> => new Map() ), mockUpsertFileShare: vi.fn(), - mockResolveActiveShareByToken: vi.fn(), } /** @@ -62,5 +61,4 @@ export const publicSharesMock = { getWorkspaceSharesForResources: publicSharesMockFns.mockGetWorkspaceSharesForResources, getWorkspaceShares: publicSharesMockFns.mockGetWorkspaceShares, upsertFileShare: publicSharesMockFns.mockUpsertFileShare, - resolveActiveShareByToken: publicSharesMockFns.mockResolveActiveShareByToken, } diff --git a/packages/testing/src/mocks/workspace-authorization.mock.ts b/packages/testing/src/mocks/workspace-authorization.mock.ts index 5b067eb371d..c94f99e1f67 100644 --- a/packages/testing/src/mocks/workspace-authorization.mock.ts +++ b/packages/testing/src/mocks/workspace-authorization.mock.ts @@ -120,6 +120,7 @@ export const workspaceAuthorizationMockFns = { case 'personal_api_key': case 'oauth_access_token': return principal.userId ?? null + case 'resource_delegated': case 'organization_delegated': return principal.subjectUserId ?? null case 'delegated': diff --git a/packages/testing/src/mocks/workspace-file-folders.mock.ts b/packages/testing/src/mocks/workspace-file-folders.mock.ts index a056dac1549..791b4109d52 100644 --- a/packages/testing/src/mocks/workspace-file-folders.mock.ts +++ b/packages/testing/src/mocks/workspace-file-folders.mock.ts @@ -74,6 +74,9 @@ export const workspaceFileFoldersMockFns = { mockListWorkspaceFileFolders: vi.fn(async (..._args: unknown[]): Promise => []), mockListFileFolders: vi.fn(async (..._args: unknown[]): Promise => []), mockGetWorkspaceFileFolder: vi.fn(), + mockResolveFileFolderTarget: vi.fn( + async (..._args: unknown[]): Promise<{ id: string } | null> => null + ), mockResolveWorkspaceFileFolderTarget: vi.fn( async (..._args: unknown[]): Promise => null ), @@ -123,6 +126,7 @@ export const workspaceFileFoldersMock = { listWorkspaceFileFolders: fns.mockListWorkspaceFileFolders, listFileFolders: fns.mockListFileFolders, getWorkspaceFileFolder: fns.mockGetWorkspaceFileFolder, + resolveFileFolderTarget: fns.mockResolveFileFolderTarget, resolveWorkspaceFileFolderTarget: fns.mockResolveWorkspaceFileFolderTarget, assertWorkspaceFileFolderTarget: fns.mockAssertWorkspaceFileFolderTarget, createWorkspaceFileFolder: fns.mockCreateWorkspaceFileFolder, diff --git a/packages/utils/src/client-info.ts b/packages/utils/src/client-info.ts index 8e747a6ac4a..f3154f197c1 100644 --- a/packages/utils/src/client-info.ts +++ b/packages/utils/src/client-info.ts @@ -254,6 +254,7 @@ export function resolveClientInfo( const INTERNAL_AUTH_KINDS: ReadonlySet = new Set([ 'internal_jwt', 'delegated', + 'resource_delegated', 'organization_delegated', 'system', ]) diff --git a/scripts/check-explicit-any.baseline.json b/scripts/check-explicit-any.baseline.json index 0976c3bb5b9..c20a8eb5ee4 100644 --- a/scripts/check-explicit-any.baseline.json +++ b/scripts/check-explicit-any.baseline.json @@ -1593,7 +1593,6 @@ "apps/sim/lib/mothership/chat/payload.test.ts": 1, "apps/sim/lib/mothership/chat/persisted-message.ts": 4, "apps/sim/lib/mothership/chat/post.test.ts": 2, - "apps/sim/lib/mothership/chat/post.ts": 1, "apps/sim/lib/mothership/chat/process-contents.test.ts": 17, "apps/sim/lib/mothership/chat/stream-liveness.ts": 1, "apps/sim/lib/mothership/chat/title-context.test.ts": 4, diff --git a/scripts/check-file-names.baseline.json b/scripts/check-file-names.baseline.json index 3ebdab69eef..f759f0a1966 100644 --- a/scripts/check-file-names.baseline.json +++ b/scripts/check-file-names.baseline.json @@ -115,8 +115,6 @@ "stutter\tapps/sim/lib/uploads/contexts/knowledge-base/knowledge-base-file-manager.ts", "stutter\tapps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.test.ts", "stutter\tapps/sim/lib/uploads/contexts/workspace/workspace-file-folder-manager.ts", - "stutter\tapps/sim/lib/uploads/contexts/workspace/workspace-file-live-doc-outbox.test.ts", - "stutter\tapps/sim/lib/uploads/contexts/workspace/workspace-file-live-doc-outbox.ts", "stutter\tapps/sim/lib/uploads/contexts/workspace/workspace-file-manager-download.test.ts", "stutter\tapps/sim/lib/uploads/contexts/workspace/workspace-file-manager-errors.test.ts", "stutter\tapps/sim/lib/uploads/contexts/workspace/workspace-file-manager-page.test.ts", @@ -127,7 +125,6 @@ "stutter\tapps/sim/lib/uploads/contexts/workspace/workspace-file-secret-provenance.test.ts", "stutter\tapps/sim/lib/uploads/contexts/workspace/workspace-file-secret-provenance.ts", "stutter\tapps/sim/lib/uploads/contexts/workspace/workspace-file-storage-accounting.test.ts", - "stutter\tapps/sim/lib/uploads/contexts/workspace/workspace-file-storage-billing.test.ts", "stutter\tapps/sim/lib/uploads/contexts/workspace/workspace-file-storage-cleanup-outbox.test.ts", "stutter\tapps/sim/lib/uploads/contexts/workspace/workspace-file-storage-cleanup-outbox.ts", "stutter\tapps/sim/lib/uploads/contexts/workspace/workspace-file-versions.ts", diff --git a/scripts/check-tool-registry-boundary.baseline.json b/scripts/check-tool-registry-boundary.baseline.json index 869afd33ed9..788dbb76801 100644 --- a/scripts/check-tool-registry-boundary.baseline.json +++ b/scripts/check-tool-registry-boundary.baseline.json @@ -6,68 +6,68 @@ }, "entries": { "app/api/v2/blocks/[blockId]/route.ts": { - "modules": 1751, + "modules": 1800, "gateways": { "apps/sim/triggers/index.ts": 536, "apps/sim/triggers/registry.ts": 534, - "apps/sim/lib/api/server/routes/index.ts": 519, - "apps/sim/lib/api/server/routes/internal-json-route.ts": 463, - "apps/sim/lib/auth/index.ts": 449, - "apps/sim/blocks/registry.ts": 370, - "apps/sim/lib/webhooks/providers/index.ts": 124, - "apps/sim/lib/webhooks/providers/registry.ts": 122 + "apps/sim/lib/api/server/routes/index.ts": 529, + "apps/sim/lib/api/server/routes/internal-json-route.ts": 470, + "apps/sim/lib/auth/index.ts": 456, + "apps/sim/blocks/registry.ts": 373, + "apps/sim/lib/webhooks/providers/index.ts": 130, + "apps/sim/lib/webhooks/providers/registry.ts": 128 } }, "app/api/v2/blocks/route.ts": { - "modules": 1748, + "modules": 1797, "gateways": { - "apps/sim/blocks/registry.ts": 910, + "apps/sim/blocks/registry.ts": 913, "apps/sim/triggers/index.ts": 536, "apps/sim/triggers/registry.ts": 534, - "apps/sim/lib/api/server/routes/index.ts": 512, - "apps/sim/lib/api/server/routes/internal-json-route.ts": 464, - "apps/sim/lib/auth/index.ts": 450, - "apps/sim/lib/webhooks/providers/index.ts": 125, - "apps/sim/lib/webhooks/providers/registry.ts": 122 + "apps/sim/lib/api/server/routes/index.ts": 521, + "apps/sim/lib/api/server/routes/internal-json-route.ts": 471, + "apps/sim/lib/auth/index.ts": 457, + "apps/sim/lib/webhooks/providers/index.ts": 131, + "apps/sim/lib/webhooks/providers/registry.ts": 128 } }, "app/api/v2/connector-types/route.ts": { - "modules": 1817, + "modules": 1866, "gateways": { - "apps/sim/blocks/registry.ts": 911, + "apps/sim/blocks/registry.ts": 914, "apps/sim/triggers/index.ts": 536, "apps/sim/triggers/registry.ts": 534, - "apps/sim/lib/api/server/routes/index.ts": 510, - "apps/sim/lib/api/server/routes/internal-json-route.ts": 462, - "apps/sim/lib/auth/index.ts": 448, - "apps/sim/lib/webhooks/providers/index.ts": 125, - "apps/sim/lib/webhooks/providers/registry.ts": 122 + "apps/sim/lib/api/server/routes/index.ts": 519, + "apps/sim/lib/api/server/routes/internal-json-route.ts": 469, + "apps/sim/lib/auth/index.ts": 455, + "apps/sim/lib/webhooks/providers/index.ts": 131, + "apps/sim/lib/webhooks/providers/registry.ts": 128 } }, "app/api/v2/tools/[toolId]/route.ts": { - "modules": 1746, + "modules": 1795, "gateways": { - "apps/sim/blocks/registry.ts": 910, + "apps/sim/blocks/registry.ts": 913, "apps/sim/triggers/index.ts": 536, "apps/sim/triggers/registry.ts": 534, - "apps/sim/lib/api/server/routes/index.ts": 520, - "apps/sim/lib/api/server/routes/internal-json-route.ts": 464, - "apps/sim/lib/auth/index.ts": 450, - "apps/sim/lib/webhooks/providers/index.ts": 125, - "apps/sim/lib/webhooks/providers/registry.ts": 122 + "apps/sim/lib/api/server/routes/index.ts": 530, + "apps/sim/lib/api/server/routes/internal-json-route.ts": 471, + "apps/sim/lib/auth/index.ts": 457, + "apps/sim/lib/webhooks/providers/index.ts": 131, + "apps/sim/lib/webhooks/providers/registry.ts": 128 } }, "app/api/v2/tools/route.ts": { - "modules": 1747, + "modules": 1796, "gateways": { - "apps/sim/blocks/registry.ts": 910, + "apps/sim/blocks/registry.ts": 913, "apps/sim/triggers/index.ts": 536, "apps/sim/triggers/registry.ts": 534, - "apps/sim/lib/api/server/routes/index.ts": 512, - "apps/sim/lib/api/server/routes/internal-json-route.ts": 464, - "apps/sim/lib/auth/index.ts": 450, - "apps/sim/lib/webhooks/providers/index.ts": 125, - "apps/sim/lib/webhooks/providers/registry.ts": 122 + "apps/sim/lib/api/server/routes/index.ts": 521, + "apps/sim/lib/api/server/routes/internal-json-route.ts": 471, + "apps/sim/lib/auth/index.ts": 457, + "apps/sim/lib/webhooks/providers/index.ts": 131, + "apps/sim/lib/webhooks/providers/registry.ts": 128 } }, "app/workspace/[workspaceId]/access-requests/loading.tsx": { @@ -87,16 +87,16 @@ "gateways": {} }, "app/workspace/[workspaceId]/chat/[chatId]/page.tsx": { - "modules": 3417, + "modules": 3474, "gateways": { - "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1704, - "apps/sim/app/workspace/[workspaceId]/home/components/chat-resource-panel.tsx": 1055, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 1051, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 718, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 715, + "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1715, + "apps/sim/app/workspace/[workspaceId]/home/components/chat-resource-panel.tsx": 1058, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 1054, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 721, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 718, "apps/sim/triggers/registry.ts": 534, - "apps/sim/blocks/registry.ts": 339, - "apps/sim/lib/auth/index.ts": 290 + "apps/sim/blocks/registry.ts": 342, + "apps/sim/lib/auth/index.ts": 305 } }, "app/workspace/[workspaceId]/dashboards/layout.tsx": { @@ -108,14 +108,14 @@ "gateways": {} }, "app/workspace/[workspaceId]/dashboards/page.tsx": { - "modules": 1270, + "modules": 1278, "gateways": { - "apps/sim/components/dashboards/dashboard-resource.tsx": 1255, - "apps/sim/app/workspace/[workspaceId]/files/hooks/use-workspace-files-room.ts": 1113, - "apps/sim/app/workspace/[workspaceId]/hooks/use-workspace-invalidation-room.ts": 1082, + "apps/sim/components/dashboards/dashboard-resource.tsx": 1263, + "apps/sim/app/workspace/[workspaceId]/files/hooks/use-workspace-files-room.ts": 1119, + "apps/sim/app/workspace/[workspaceId]/hooks/use-workspace-invalidation-room.ts": 1086, "apps/sim/triggers/registry.ts": 574, - "apps/sim/blocks/registry.ts": 378, - "apps/sim/blocks/registry-maps.ts": 375, + "apps/sim/blocks/registry.ts": 382, + "apps/sim/blocks/registry-maps.ts": 379, "apps/sim/triggers/plane/index.ts": 46, "apps/sim/lib/api/contracts/index.ts": 45 } @@ -129,23 +129,23 @@ "gateways": {} }, "app/workspace/[workspaceId]/files/[fileId]/page.tsx": { - "modules": 2366, + "modules": 2416, "gateways": { "apps/sim/triggers/registry.ts": 534, - "apps/sim/app/workspace/[workspaceId]/files/files.tsx": 513, - "apps/sim/blocks/registry.ts": 363, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts": 300, - "apps/sim/lib/auth/index.ts": 292, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx": 269, + "apps/sim/app/workspace/[workspaceId]/files/files.tsx": 515, + "apps/sim/blocks/registry.ts": 366, + "apps/sim/lib/auth/index.ts": 308, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts": 301, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx": 270, "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx": 198, - "apps/sim/lib/webhooks/providers/index.ts": 124 + "apps/sim/lib/webhooks/providers/index.ts": 130 } }, "app/workspace/[workspaceId]/files/[fileId]/view/page.tsx": { - "modules": 69, + "modules": 73, "gateways": { - "apps/sim/app/workspace/[workspaceId]/files/[fileId]/view/file-viewer.tsx": 68, - "apps/sim/hooks/queries/workspace-files.ts": 64 + "apps/sim/app/workspace/[workspaceId]/files/[fileId]/view/file-viewer.tsx": 72, + "apps/sim/hooks/queries/workspace-files.ts": 68 } }, "app/workspace/[workspaceId]/files/error.tsx": { @@ -157,16 +157,16 @@ "gateways": {} }, "app/workspace/[workspaceId]/files/page.tsx": { - "modules": 2365, + "modules": 2415, "gateways": { "apps/sim/triggers/registry.ts": 534, - "apps/sim/app/workspace/[workspaceId]/files/files.tsx": 514, - "apps/sim/blocks/registry.ts": 363, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts": 300, - "apps/sim/lib/auth/index.ts": 292, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx": 269, + "apps/sim/app/workspace/[workspaceId]/files/files.tsx": 516, + "apps/sim/blocks/registry.ts": 366, + "apps/sim/lib/auth/index.ts": 308, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/index.ts": 301, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/file-viewer.tsx": 270, "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-editor.tsx": 198, - "apps/sim/lib/webhooks/providers/index.ts": 124 + "apps/sim/lib/webhooks/providers/index.ts": 130 } }, "app/workspace/[workspaceId]/home/error.tsx": { @@ -178,40 +178,40 @@ "gateways": {} }, "app/workspace/[workspaceId]/home/page.tsx": { - "modules": 3417, + "modules": 3474, "gateways": { - "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1704, - "apps/sim/app/workspace/[workspaceId]/home/components/chat-resource-panel.tsx": 1055, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 1051, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 718, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 715, + "apps/sim/app/workspace/[workspaceId]/home/home.tsx": 1715, + "apps/sim/app/workspace/[workspaceId]/home/components/chat-resource-panel.tsx": 1058, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 1054, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/index.ts": 721, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-content/index.ts": 718, "apps/sim/triggers/registry.ts": 534, - "apps/sim/blocks/registry.ts": 339, - "apps/sim/lib/auth/index.ts": 290 + "apps/sim/blocks/registry.ts": 342, + "apps/sim/lib/auth/index.ts": 305 } }, "app/workspace/[workspaceId]/integrations/[block]/page.tsx": { - "modules": 1222, + "modules": 1228, "gateways": { - "apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx": 1144, + "apps/sim/app/workspace/[workspaceId]/integrations/[block]/integration-block-detail.tsx": 1150, "apps/sim/triggers/index.ts": 576, "apps/sim/triggers/registry.ts": 574, - "apps/sim/blocks/registry.ts": 390, + "apps/sim/blocks/registry.ts": 393, "apps/sim/lib/api/contracts/index.ts": 54, "apps/sim/triggers/plane/index.ts": 46, "apps/sim/triggers/clickup/index.ts": 32 } }, "app/workspace/[workspaceId]/integrations/connected/[credentialId]/page.tsx": { - "modules": 1294, + "modules": 1303, "gateways": { - "apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx": 1249, + "apps/sim/app/workspace/[workspaceId]/integrations/connected/[credentialId]/connected-credential-detail.tsx": 1258, "apps/sim/triggers/registry.ts": 574, - "apps/sim/blocks/registry.ts": 381, - "apps/sim/app/workspace/[workspaceId]/components/credential-detail/index.ts": 98, - "apps/sim/components/permissions/index.ts": 86, - "apps/sim/components/permissions/add-people-modal.tsx": 81, - "apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx": 79, + "apps/sim/blocks/registry.ts": 385, + "apps/sim/app/workspace/[workspaceId]/components/credential-detail/index.ts": 101, + "apps/sim/components/permissions/index.ts": 87, + "apps/sim/components/permissions/add-people-modal.tsx": 82, + "apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx": 80, "apps/sim/lib/api/contracts/index.ts": 58 } }, @@ -220,10 +220,10 @@ "gateways": {} }, "app/workspace/[workspaceId]/integrations/page.tsx": { - "modules": 1196, + "modules": 1201, "gateways": { - "apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx": 1192, - "apps/sim/blocks/registry.ts": 971, + "apps/sim/app/workspace/[workspaceId]/integrations/integrations.tsx": 1197, + "apps/sim/blocks/registry.ts": 974, "apps/sim/triggers/index.ts": 576, "apps/sim/triggers/registry.ts": 574, "apps/sim/hooks/queries/credentials.ts": 73, @@ -237,13 +237,13 @@ "gateways": {} }, "app/workspace/[workspaceId]/knowledge/[id]/[documentId]/page.tsx": { - "modules": 1439, + "modules": 1445, "gateways": { - "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx": 1377, + "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/[documentId]/document.tsx": 1383, "apps/sim/triggers/registry.ts": 574, - "apps/sim/blocks/registry.ts": 373, - "apps/sim/blocks/registry-maps.ts": 370, - "apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx": 87, + "apps/sim/blocks/registry.ts": 376, + "apps/sim/blocks/registry-maps.ts": 373, + "apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx": 88, "apps/sim/connectors/registry.ts": 75, "apps/sim/lib/api/contracts/index.ts": 55, "apps/sim/triggers/plane/index.ts": 46 @@ -258,16 +258,16 @@ "gateways": {} }, "app/workspace/[workspaceId]/knowledge/[id]/page.tsx": { - "modules": 1601, + "modules": 1609, "gateways": { - "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx": 1538, + "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/base.tsx": 1546, "apps/sim/triggers/registry.ts": 574, - "apps/sim/blocks/registry.ts": 375, - "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/index.ts": 152, + "apps/sim/blocks/registry.ts": 378, + "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/components/index.ts": 154, "apps/sim/connectors/registry.ts": 70, - "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-scope.ts": 51, + "apps/sim/app/workspace/[workspaceId]/knowledge/[id]/hooks/use-connector-scope.ts": 52, "apps/sim/triggers/plane/index.ts": 46, - "apps/sim/app/o/[organizationId]/providers/organization-provider.tsx": 41 + "apps/sim/app/o/[organizationId]/providers/organization-provider.tsx": 42 } }, "app/workspace/[workspaceId]/knowledge/error.tsx": { @@ -279,29 +279,29 @@ "gateways": {} }, "app/workspace/[workspaceId]/knowledge/page.tsx": { - "modules": 2528, + "modules": 2581, "gateways": { "apps/sim/triggers/registry.ts": 534, - "apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts": 441, - "apps/sim/lib/knowledge/application/knowledge-bases.ts": 375, - "apps/sim/blocks/registry.ts": 364, - "apps/sim/lib/auth/index.ts": 238, - "apps/sim/lib/knowledge/orchestration/index.ts": 230, + "apps/sim/app/workspace/[workspaceId]/knowledge/prefetch.ts": 448, + "apps/sim/lib/knowledge/application/knowledge-bases.ts": 379, + "apps/sim/blocks/registry.ts": 367, + "apps/sim/lib/auth/index.ts": 250, + "apps/sim/lib/knowledge/orchestration/index.ts": 231, "apps/sim/app/workspace/[workspaceId]/knowledge/knowledge.tsx": 228, - "apps/sim/lib/knowledge/orchestration/connectors.ts": 226 + "apps/sim/lib/knowledge/orchestration/connectors.ts": 227 } }, "app/workspace/[workspaceId]/layout.tsx": { - "modules": 2381, + "modules": 2437, "gateways": { "apps/sim/triggers/registry.ts": 534, - "apps/sim/lib/auth/index.ts": 431, - "apps/sim/blocks/registry.ts": 357, - "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx": 279, - "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts": 151, - "apps/sim/lib/webhooks/providers/index.ts": 123, - "apps/sim/lib/webhooks/providers/registry.ts": 121, - "apps/sim/app/workspace/[workspaceId]/prefetch-access.ts": 91 + "apps/sim/lib/auth/index.ts": 435, + "apps/sim/blocks/registry.ts": 361, + "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/sidebar.tsx": 282, + "apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components/index.ts": 153, + "apps/sim/lib/webhooks/providers/index.ts": 129, + "apps/sim/lib/webhooks/providers/registry.ts": 127, + "apps/sim/app/workspace/[workspaceId]/prefetch-access.ts": 90 } }, "app/workspace/[workspaceId]/logs/error.tsx": { @@ -313,16 +313,16 @@ "gateways": {} }, "app/workspace/[workspaceId]/logs/page.tsx": { - "modules": 1881, + "modules": 1893, "gateways": { - "apps/sim/app/workspace/[workspaceId]/logs/logs.tsx": 1865, + "apps/sim/app/workspace/[workspaceId]/logs/logs.tsx": 1877, "apps/sim/triggers/registry.ts": 574, - "apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/execution-snapshot.tsx": 561, - "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 512, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 506, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 437, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts": 427, - "apps/sim/blocks/registry.ts": 357 + "apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/execution-snapshot/execution-snapshot.tsx": 566, + "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 517, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 511, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 442, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts": 432, + "apps/sim/blocks/registry.ts": 360 } }, "app/workspace/[workspaceId]/not-found.tsx": { @@ -342,16 +342,16 @@ "gateways": {} }, "app/workspace/[workspaceId]/settings/[section]/page.tsx": { - "modules": 2911, + "modules": 2965, "gateways": { - "apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx": 1149, - "apps/sim/app/workspace/[workspaceId]/settings/section-warmers.ts": 958, + "apps/sim/app/workspace/[workspaceId]/settings/[section]/settings.tsx": 1154, + "apps/sim/app/workspace/[workspaceId]/settings/section-warmers.ts": 960, "apps/sim/triggers/registry.ts": 534, - "apps/sim/lib/auth/index.ts": 413, - "apps/sim/ee/workspace-forking/components/forks.tsx": 405, - "apps/sim/ee/workspace-forking/components/fork-sync-detail-view/fork-sync-detail-view.tsx": 397, - "apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx": 389, - "apps/sim/ee/workspace-forking/components/fork-sync/fork-comparison-modal.tsx": 383 + "apps/sim/lib/auth/index.ts": 420, + "apps/sim/ee/workspace-forking/components/forks.tsx": 406, + "apps/sim/ee/workspace-forking/components/fork-sync-detail-view/fork-sync-detail-view.tsx": 398, + "apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx": 390, + "apps/sim/ee/workspace-forking/components/fork-sync/fork-comparison-modal.tsx": 384 } }, "app/workspace/[workspaceId]/settings/billing/credit-usage/layout.tsx": { @@ -363,15 +363,15 @@ "gateways": {} }, "app/workspace/[workspaceId]/settings/billing/credit-usage/page.tsx": { - "modules": 1700, + "modules": 1746, "gateways": { - "apps/sim/lib/auth/index.ts": 1544, - "apps/sim/blocks/registry.ts": 914, - "apps/sim/blocks/registry-maps.ts": 911, + "apps/sim/lib/auth/index.ts": 1589, + "apps/sim/blocks/registry.ts": 917, + "apps/sim/blocks/registry-maps.ts": 914, "apps/sim/triggers/index.ts": 536, "apps/sim/triggers/registry.ts": 534, - "apps/sim/lib/webhooks/providers/index.ts": 127, - "apps/sim/lib/webhooks/providers/registry.ts": 124, + "apps/sim/lib/webhooks/providers/index.ts": 133, + "apps/sim/lib/webhooks/providers/registry.ts": 130, "apps/sim/lib/uploads/utils/file-utils.server.ts": 54 } }, @@ -380,16 +380,16 @@ "gateways": {} }, "app/workspace/[workspaceId]/settings/layout.tsx": { - "modules": 2269, + "modules": 2281, "gateways": { - "apps/sim/app/workspace/[workspaceId]/settings/section-warmers.ts": 2232, + "apps/sim/app/workspace/[workspaceId]/settings/section-warmers.ts": 2246, "apps/sim/triggers/registry.ts": 574, - "apps/sim/ee/workspace-forking/components/forks.tsx": 409, - "apps/sim/ee/workspace-forking/components/fork-sync-detail-view/fork-sync-detail-view.tsx": 401, - "apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx": 392, - "apps/sim/ee/workspace-forking/components/fork-sync/fork-comparison-modal.tsx": 386, - "apps/sim/blocks/registry.ts": 357, - "apps/sim/app/workspace/[workspaceId]/w/components/preview/index.ts": 354 + "apps/sim/ee/workspace-forking/components/forks.tsx": 411, + "apps/sim/ee/workspace-forking/components/fork-sync-detail-view/fork-sync-detail-view.tsx": 403, + "apps/sim/ee/workspace-forking/components/fork-sync/fork-sync-view.tsx": 394, + "apps/sim/ee/workspace-forking/components/fork-sync/fork-comparison-modal.tsx": 388, + "apps/sim/blocks/registry.ts": 360, + "apps/sim/app/workspace/[workspaceId]/w/components/preview/index.ts": 356 } }, "app/workspace/[workspaceId]/settings/page.tsx": { @@ -397,28 +397,28 @@ "gateways": {} }, "app/workspace/[workspaceId]/settings/secrets/[credentialId]/loading.tsx": { - "modules": 1229, + "modules": 1237, "gateways": { - "apps/sim/app/workspace/[workspaceId]/components/credential-detail/index.ts": 1226, - "apps/sim/components/permissions/index.ts": 1066, - "apps/sim/components/permissions/add-people-modal.tsx": 1061, - "apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx": 1059, + "apps/sim/app/workspace/[workspaceId]/components/credential-detail/index.ts": 1234, + "apps/sim/components/permissions/index.ts": 1071, + "apps/sim/components/permissions/add-people-modal.tsx": 1066, + "apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx": 1064, "apps/sim/triggers/registry.ts": 574, - "apps/sim/blocks/registry.ts": 383, - "apps/sim/blocks/registry-maps.ts": 380, + "apps/sim/blocks/registry.ts": 387, + "apps/sim/blocks/registry-maps.ts": 384, "apps/sim/lib/api/contracts/index.ts": 60 } }, "app/workspace/[workspaceId]/settings/secrets/[credentialId]/page.tsx": { - "modules": 1251, + "modules": 1259, "gateways": { "apps/sim/triggers/registry.ts": 574, - "apps/sim/blocks/registry.ts": 383, - "apps/sim/blocks/registry-maps.ts": 380, - "apps/sim/app/workspace/[workspaceId]/components/credential-detail/index.ts": 96, - "apps/sim/components/permissions/index.ts": 86, - "apps/sim/components/permissions/add-people-modal.tsx": 81, - "apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx": 79, + "apps/sim/blocks/registry.ts": 387, + "apps/sim/blocks/registry-maps.ts": 384, + "apps/sim/app/workspace/[workspaceId]/components/credential-detail/index.ts": 99, + "apps/sim/components/permissions/index.ts": 87, + "apps/sim/components/permissions/add-people-modal.tsx": 82, + "apps/sim/app/workspace/[workspaceId]/providers/workspace-permissions-provider.tsx": 80, "apps/sim/lib/api/contracts/index.ts": 60 } }, @@ -431,27 +431,27 @@ "gateways": {} }, "app/workspace/[workspaceId]/settings/usage/events/page.tsx": { - "modules": 1707, + "modules": 1753, "gateways": { - "apps/sim/lib/auth/index.ts": 1544, - "apps/sim/blocks/registry.ts": 914, - "apps/sim/blocks/registry-maps.ts": 911, + "apps/sim/lib/auth/index.ts": 1589, + "apps/sim/blocks/registry.ts": 917, + "apps/sim/blocks/registry-maps.ts": 914, "apps/sim/triggers/index.ts": 536, "apps/sim/triggers/registry.ts": 534, - "apps/sim/lib/webhooks/providers/index.ts": 127, - "apps/sim/lib/webhooks/providers/registry.ts": 124, + "apps/sim/lib/webhooks/providers/index.ts": 133, + "apps/sim/lib/webhooks/providers/registry.ts": 130, "apps/sim/lib/uploads/utils/file-utils.server.ts": 54 } }, "app/workspace/[workspaceId]/skills/[skillId]/page.tsx": { - "modules": 1513, + "modules": 1524, "gateways": { - "apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx": 1512, + "apps/sim/app/workspace/[workspaceId]/skills/[skillId]/skill-detail.tsx": 1523, "apps/sim/triggers/registry.ts": 574, - "apps/sim/blocks/registry.ts": 376, - "apps/sim/blocks/registry-maps.ts": 374, - "apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts": 274, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx": 271, + "apps/sim/blocks/registry.ts": 380, + "apps/sim/blocks/registry-maps.ts": 378, + "apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts": 277, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx": 274, "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts": 123, "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find/index.ts": 68 } @@ -461,25 +461,25 @@ "gateways": {} }, "app/workspace/[workspaceId]/skills/new/page.tsx": { - "modules": 1511, + "modules": 1522, "gateways": { - "apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx": 1510, + "apps/sim/app/workspace/[workspaceId]/skills/new/skill-create.tsx": 1521, "apps/sim/triggers/registry.ts": 574, - "apps/sim/blocks/registry.ts": 376, - "apps/sim/blocks/registry-maps.ts": 374, - "apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts": 274, - "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx": 271, + "apps/sim/blocks/registry.ts": 380, + "apps/sim/blocks/registry-maps.ts": 378, + "apps/sim/app/workspace/[workspaceId]/skills/components/skill-fields/index.ts": 277, + "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/rich-markdown-field.tsx": 274, "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/editor-extensions.ts": 123, "apps/sim/app/workspace/[workspaceId]/files/components/file-viewer/rich-markdown-editor/find/index.ts": 68 } }, "app/workspace/[workspaceId]/skills/page.tsx": { - "modules": 1134, + "modules": 1139, "gateways": { - "apps/sim/app/workspace/[workspaceId]/skills/skills.tsx": 1130, - "apps/sim/app/workspace/[workspaceId]/integrations/components/showcase-with-explore/index.ts": 1001, - "apps/sim/blocks/registry.ts": 990, - "apps/sim/blocks/registry-maps.ts": 988, + "apps/sim/app/workspace/[workspaceId]/skills/skills.tsx": 1135, + "apps/sim/app/workspace/[workspaceId]/integrations/components/showcase-with-explore/index.ts": 1004, + "apps/sim/blocks/registry.ts": 993, + "apps/sim/blocks/registry-maps.ts": 991, "apps/sim/triggers/index.ts": 576, "apps/sim/triggers/registry.ts": 574, "apps/sim/hooks/queries/skills.ts": 74, @@ -495,16 +495,16 @@ "gateways": {} }, "app/workspace/[workspaceId]/tables/[tableId]/page.tsx": { - "modules": 2009, + "modules": 2021, "gateways": { - "apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx": 1949, + "apps/sim/app/workspace/[workspaceId]/tables/[tableId]/table.tsx": 1961, "apps/sim/triggers/registry.ts": 574, - "apps/sim/app/workspace/[workspaceId]/w/components/preview/index.ts": 456, - "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 409, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 405, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 348, - "apps/sim/blocks/registry.ts": 340, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts": 339 + "apps/sim/app/workspace/[workspaceId]/w/components/preview/index.ts": 460, + "apps/sim/app/workspace/[workspaceId]/w/components/preview/components/preview-editor/index.ts": 413, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 409, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/sub-block.tsx": 352, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/index.ts": 343, + "apps/sim/blocks/registry.ts": 343 } }, "app/workspace/[workspaceId]/tables/error.tsx": { @@ -516,27 +516,27 @@ "gateways": {} }, "app/workspace/[workspaceId]/tables/page.tsx": { - "modules": 2068, + "modules": 2118, "gateways": { "apps/sim/triggers/registry.ts": 534, - "apps/sim/lib/auth/index.ts": 459, - "apps/sim/blocks/registry.ts": 365, - "apps/sim/app/workspace/[workspaceId]/tables/tables.tsx": 205, - "apps/sim/lib/webhooks/providers/index.ts": 124, - "apps/sim/lib/webhooks/providers/registry.ts": 122, - "apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts": 102, - "apps/sim/app/workspace/[workspaceId]/lib/authorize-resource-prefetch.ts": 54 + "apps/sim/lib/auth/index.ts": 496, + "apps/sim/blocks/registry.ts": 368, + "apps/sim/app/workspace/[workspaceId]/tables/tables.tsx": 206, + "apps/sim/lib/webhooks/providers/index.ts": 130, + "apps/sim/lib/webhooks/providers/registry.ts": 128, + "apps/sim/app/workspace/[workspaceId]/tables/prefetch.ts": 105, + "apps/sim/app/workspace/[workspaceId]/lib/authorize-resource-prefetch.ts": 57 } }, "app/workspace/[workspaceId]/upgrade/page.tsx": { - "modules": 170, + "modules": 172, "gateways": { - "apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx": 163, - "apps/sim/app/workspace/[workspaceId]/upgrade/hooks/index.ts": 109, - "apps/sim/lib/billing/client/upgrade.ts": 101, - "apps/sim/hooks/queries/organization.ts": 95, - "apps/sim/hooks/queries/workspace.ts": 82, - "apps/sim/lib/api/contracts/index.ts": 80 + "apps/sim/app/workspace/[workspaceId]/upgrade/upgrade.tsx": 165, + "apps/sim/app/workspace/[workspaceId]/upgrade/hooks/index.ts": 111, + "apps/sim/lib/billing/client/upgrade.ts": 103, + "apps/sim/hooks/queries/organization.ts": 97, + "apps/sim/hooks/queries/workspace.ts": 84, + "apps/sim/lib/api/contracts/index.ts": 82 } }, "app/workspace/[workspaceId]/w/[workflowId]/layout.tsx": { @@ -544,38 +544,38 @@ "gateways": {} }, "app/workspace/[workspaceId]/w/[workflowId]/page.tsx": { - "modules": 2402, + "modules": 2418, "gateways": { - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 2401, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 665, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 617, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/workflow.tsx": 2417, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 669, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 621, "apps/sim/triggers/registry.ts": 574, - "apps/sim/blocks/registry.ts": 357, + "apps/sim/blocks/registry.ts": 360, "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 290, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/index.ts": 184, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx": 180 + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/index.ts": 185, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx": 181 } }, "app/workspace/[workspaceId]/w/page.tsx": { - "modules": 2383, + "modules": 2399, "gateways": { - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 1111, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 833, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/index.ts": 1121, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/index.ts": 841, "apps/sim/triggers/registry.ts": 574, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 436, - "apps/sim/blocks/registry.ts": 357, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/index.ts": 189, - "apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx": 185, - "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 176 + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/index.ts": 438, + "apps/sim/blocks/registry.ts": 360, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/index.ts": 190, + "apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx": 186, + "apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/index.ts": 177 } }, "app/workspace/layout.tsx": { - "modules": 1199, + "modules": 1205, "gateways": { - "apps/sim/app/workspace/providers/socket-provider.tsx": 1189, + "apps/sim/app/workspace/providers/socket-provider.tsx": 1195, "apps/sim/triggers/registry.ts": 574, - "apps/sim/blocks/registry.ts": 383, - "apps/sim/blocks/registry-maps.ts": 380, + "apps/sim/blocks/registry.ts": 387, + "apps/sim/blocks/registry-maps.ts": 384, "apps/sim/stores/workflows/registry/store.ts": 108, "apps/sim/hooks/queries/deployments.ts": 105, "apps/sim/lib/workflows/comparison/describe.ts": 90, @@ -583,34 +583,34 @@ } }, "app/workspace/page.tsx": { - "modules": 1199, + "modules": 1208, "gateways": { - "apps/sim/lib/auth/stale-session-recovery.ts": 1079, + "apps/sim/lib/auth/stale-session-recovery.ts": 1086, + "apps/sim/stores/reset-all-stores.ts": 1082, "apps/sim/triggers/index.ts": 576, "apps/sim/triggers/registry.ts": 574, - "apps/sim/blocks/registry.ts": 384, - "apps/sim/blocks/registry-maps.ts": 381, + "apps/sim/blocks/registry.ts": 388, + "apps/sim/blocks/registry-maps.ts": 385, "apps/sim/lib/api/contracts/index.ts": 55, - "apps/sim/triggers/plane/index.ts": 46, - "apps/sim/stores/workflows/registry/store.ts": 45 + "apps/sim/triggers/plane/index.ts": 46 } }, "lib/catalog/projection/block-detail.ts": { - "modules": 1036, + "modules": 1041, "gateways": { "apps/sim/triggers/index.ts": 576, "apps/sim/triggers/registry.ts": 574, - "apps/sim/lib/catalog/projection/block-summary.ts": 419, - "apps/sim/blocks/registry-maps.ts": 415, + "apps/sim/lib/catalog/projection/block-summary.ts": 423, + "apps/sim/blocks/registry-maps.ts": 419, "apps/sim/triggers/plane/index.ts": 46, "apps/sim/triggers/clickup/index.ts": 32 } }, "lib/catalog/projection/block-summary.ts": { - "modules": 1031, + "modules": 1036, "gateways": { - "apps/sim/blocks/registry.ts": 1022, - "apps/sim/blocks/registry-maps.ts": 1019, + "apps/sim/blocks/registry.ts": 1026, + "apps/sim/blocks/registry-maps.ts": 1023, "apps/sim/triggers/index.ts": 576, "apps/sim/triggers/registry.ts": 574, "apps/sim/triggers/plane/index.ts": 46, @@ -626,7 +626,7 @@ "gateways": {} }, "lib/catalog/projection/subblock.ts": { - "modules": 6, + "modules": 7, "gateways": {} }, "lib/catalog/projection/tool.ts": { diff --git a/scripts/check-unused-exports.baseline.json b/scripts/check-unused-exports.baseline.json index 0d125cc54df..a26e13bb7e6 100644 --- a/scripts/check-unused-exports.baseline.json +++ b/scripts/check-unused-exports.baseline.json @@ -1161,7 +1161,6 @@ "apps/sim/hooks/queries/providers.ts#providerKeys", "apps/sim/hooks/queries/providers.ts#providerModelsQueryOptions", "apps/sim/hooks/queries/public-shares.ts#FILE_SHARE_STALE_TIME", - "apps/sim/hooks/queries/public-shares.ts#shareKeys", "apps/sim/hooks/queries/resume-execution.ts#PausedExecutionSummary", "apps/sim/hooks/queries/resume-execution.ts#RESUME_EXECUTION_DETAIL_STALE_TIME", "apps/sim/hooks/queries/resume-execution.ts#ResumeLinks", @@ -1228,10 +1227,8 @@ "apps/sim/hooks/queries/workflow-search-replace.ts#workflowSearchReplaceKeys", "apps/sim/hooks/queries/workflows.ts#WORKFLOW_DEPLOYMENT_VERSION_STATE_STALE_TIME", "apps/sim/hooks/queries/workspace-file-table.ts#WORKSPACE_CSV_PREVIEW_STALE_TIME", - "apps/sim/hooks/queries/workspace-file-table.ts#workspaceFileTableKeys", "apps/sim/hooks/queries/workspace-files.ts#CLOUD_STORAGE_CONFIGURED_STALE_TIME", "apps/sim/hooks/queries/workspace-files.ts#DocNotReadyError", - "apps/sim/hooks/queries/workspace-files.ts#WORKSPACE_FILES_LIST_STALE_TIME", "apps/sim/hooks/queries/workspace-files.ts#WORKSPACE_FILE_BINARY_STALE_TIME", "apps/sim/hooks/queries/workspace-files.ts#WORKSPACE_FILE_CONTENT_STALE_TIME", "apps/sim/hooks/queries/workspace-usage.ts#WORKSPACE_CREDIT_AVAILABILITY_STALE_TIME", @@ -1556,7 +1553,6 @@ "apps/sim/lib/api/contracts/file-doc.ts#mergeFileDocBodySchema", "apps/sim/lib/api/contracts/file-doc.ts#mergeFileDocResponseSchema", "apps/sim/lib/api/contracts/file-doc.ts#persistFileDocBodySchema", - "apps/sim/lib/api/contracts/file-doc.ts#persistFileDocResponseSchema", "apps/sim/lib/api/contracts/folders.ts#createFolderBodySchema", "apps/sim/lib/api/contracts/folders.ts#duplicateFolderBodySchema", "apps/sim/lib/api/contracts/folders.ts#folderCascadeCountsSchema", @@ -1901,7 +1897,6 @@ "apps/sim/lib/api/contracts/primitives.ts#retentionOverrideSchema", "apps/sim/lib/api/contracts/providers.ts#openRouterModelInfoSchema", "apps/sim/lib/api/contracts/public-shares.ts#AuthenticatePublicFileBody", - "apps/sim/lib/api/contracts/public-shares.ts#GetFileShareResponse", "apps/sim/lib/api/contracts/public-shares.ts#PublicFileMetadata", "apps/sim/lib/api/contracts/public-shares.ts#PublicFileSSOBody", "apps/sim/lib/api/contracts/public-shares.ts#PublicFileSSOResponse", @@ -3286,15 +3281,11 @@ "apps/sim/lib/api/contracts/v2/file-versions.ts#V2FileVersionRevertResult", "apps/sim/lib/api/contracts/v2/file-versions.ts#V2ListFileVersionsQuery", "apps/sim/lib/api/contracts/v2/file-versions.ts#V2RevertFileVersionBody", - "apps/sim/lib/api/contracts/v2/file-versions.ts#v2DeleteFileVersionResultSchema", "apps/sim/lib/api/contracts/v2/file-versions.ts#v2FileVersionAuthorSchema", "apps/sim/lib/api/contracts/v2/file-versions.ts#v2FileVersionParamsSchema", - "apps/sim/lib/api/contracts/v2/file-versions.ts#v2FileVersionSchema", "apps/sim/lib/api/contracts/v2/file-versions.ts#v2FileVersionSortFields", "apps/sim/lib/api/contracts/v2/file-versions.ts#v2FileVersionSourceSchema", "apps/sim/lib/api/contracts/v2/file-versions.ts#v2FileVersionTextSchema", - "apps/sim/lib/api/contracts/v2/file-versions.ts#v2ListFileVersionsQuerySchema", - "apps/sim/lib/api/contracts/v2/file-versions.ts#v2RevertFileVersionBodySchema", "apps/sim/lib/api/contracts/v2/file-versions.ts#v2RevertFileVersionResultSchema", "apps/sim/lib/api/contracts/v2/files.ts#V2BulkDeleteFilesBody", "apps/sim/lib/api/contracts/v2/files.ts#V2BulkDeleteFilesResult", @@ -3331,9 +3322,6 @@ "apps/sim/lib/api/contracts/v2/files.ts#splitFolderPathList", "apps/sim/lib/api/contracts/v2/files.ts#v2BulkDeleteFilesResultSchema", "apps/sim/lib/api/contracts/v2/files.ts#v2BulkDownloadFilesQuerySchema", - "apps/sim/lib/api/contracts/v2/files.ts#v2CreateFileBodySchema", - "apps/sim/lib/api/contracts/v2/files.ts#v2CreateFileUploadBodySchema", - "apps/sim/lib/api/contracts/v2/files.ts#v2CreateFileUploadDataSchema", "apps/sim/lib/api/contracts/v2/files.ts#v2CreatedFileSchema", "apps/sim/lib/api/contracts/v2/files.ts#v2DeleteFileFolderDataSchema", "apps/sim/lib/api/contracts/v2/files.ts#v2DeleteFileResultSchema", @@ -3342,25 +3330,15 @@ "apps/sim/lib/api/contracts/v2/files.ts#v2FileMetadataSchema", "apps/sim/lib/api/contracts/v2/files.ts#v2FileReferenceParamsSchema", "apps/sim/lib/api/contracts/v2/files.ts#v2FileScopeSchema", - "apps/sim/lib/api/contracts/v2/files.ts#v2FileSearchResultsSchema", - "apps/sim/lib/api/contracts/v2/files.ts#v2FileShareSchema", - "apps/sim/lib/api/contracts/v2/files.ts#v2FileSortFields", "apps/sim/lib/api/contracts/v2/files.ts#v2FileUploadParamsSchema", - "apps/sim/lib/api/contracts/v2/files.ts#v2FileUploadSchema", "apps/sim/lib/api/contracts/v2/files.ts#v2FileUploadWorkspaceQuerySchema", "apps/sim/lib/api/contracts/v2/files.ts#v2GetFileMetadataQuerySchema", "apps/sim/lib/api/contracts/v2/files.ts#v2ListFileFoldersQuerySchema", - "apps/sim/lib/api/contracts/v2/files.ts#v2ListFilesQuerySchema", - "apps/sim/lib/api/contracts/v2/files.ts#v2MoveFileItemsBodySchema", "apps/sim/lib/api/contracts/v2/files.ts#v2MoveFileItemsResultSchema", - "apps/sim/lib/api/contracts/v2/files.ts#v2NullableFileShareSchema", - "apps/sim/lib/api/contracts/v2/files.ts#v2RenameFileBodySchema", "apps/sim/lib/api/contracts/v2/files.ts#v2RestoreFileBodySchema", "apps/sim/lib/api/contracts/v2/files.ts#v2RestoreFileFolderBodySchema", "apps/sim/lib/api/contracts/v2/files.ts#v2RestoreFileFolderDataSchema", "apps/sim/lib/api/contracts/v2/files.ts#v2UnzipFileBodySchema", - "apps/sim/lib/api/contracts/v2/files.ts#v2UnzipFileDataSchema", - "apps/sim/lib/api/contracts/v2/files.ts#v2UpdateFileContentBodySchema", "apps/sim/lib/api/contracts/v2/files.ts#v2WrittenFileSchema", "apps/sim/lib/api/contracts/v2/knowledge.ts#MAX_V2_BULK_KNOWLEDGE_DOCUMENTS", "apps/sim/lib/api/contracts/v2/knowledge.ts#MAX_V2_KNOWLEDGE_DOCUMENT_TAG_FILTERS", @@ -4023,33 +4001,17 @@ "apps/sim/lib/api/contracts/workflows.ts#workflowVariableReadSchema", "apps/sim/lib/api/contracts/workflows.ts#workflowVariableWriteSchema", "apps/sim/lib/api/contracts/workflows.ts#workflowVariablesBodySchema", - "apps/sim/lib/api/contracts/workspace-file-folders.ts#bulkArchiveWorkspaceFileItemsBodySchema", - "apps/sim/lib/api/contracts/workspace-file-folders.ts#createWorkspaceFileFolderBodySchema", - "apps/sim/lib/api/contracts/workspace-file-folders.ts#downloadWorkspaceFileItemsQuerySchema", - "apps/sim/lib/api/contracts/workspace-file-folders.ts#listWorkspaceFileFoldersQuerySchema", - "apps/sim/lib/api/contracts/workspace-file-folders.ts#moveWorkspaceFileItemsBodySchema", - "apps/sim/lib/api/contracts/workspace-file-folders.ts#updateWorkspaceFileFolderBodySchema", "apps/sim/lib/api/contracts/workspace-file-folders.ts#workspaceFileFolderParamsSchema", - "apps/sim/lib/api/contracts/workspace-file-folders.ts#workspaceFileFolderSchema", "apps/sim/lib/api/contracts/workspace-file-folders.ts#workspaceFileFolderScopeSchema", "apps/sim/lib/api/contracts/workspace-file-folders.ts#workspaceFileFoldersParamsSchema", "apps/sim/lib/api/contracts/workspace-file-table.ts#WorkspaceCsvPreviewParams", "apps/sim/lib/api/contracts/workspace-file-table.ts#WorkspaceCsvPreviewQuery", "apps/sim/lib/api/contracts/workspace-file-table.ts#workspaceCsvPreviewParamsSchema", - "apps/sim/lib/api/contracts/workspace-file-table.ts#workspaceCsvPreviewQuerySchema", - "apps/sim/lib/api/contracts/workspace-file-table.ts#workspaceCsvPreviewResponseSchema", "apps/sim/lib/api/contracts/workspace-files.ts#ExportWorkspaceFileSnapshotBody", - "apps/sim/lib/api/contracts/workspace-files.ts#ExtractWorkspaceFileResponse", "apps/sim/lib/api/contracts/workspace-files.ts#UpdateWorkspaceFileDimensionsBody", - "apps/sim/lib/api/contracts/workspace-files.ts#extractWorkspaceFileResponseSchema", "apps/sim/lib/api/contracts/workspace-files.ts#listWorkspaceFilesQuerySchema", - "apps/sim/lib/api/contracts/workspace-files.ts#renameWorkspaceFileBodySchema", "apps/sim/lib/api/contracts/workspace-files.ts#renameWorkspaceFileErrorSchema", - "apps/sim/lib/api/contracts/workspace-files.ts#updateWorkspaceFileContentBodySchema", "apps/sim/lib/api/contracts/workspace-files.ts#updateWorkspaceFileDimensionsBodySchema", - "apps/sim/lib/api/contracts/workspace-files.ts#workspaceFileParamsSchema", - "apps/sim/lib/api/contracts/workspace-files.ts#workspaceFileRecordSchema", - "apps/sim/lib/api/contracts/workspace-files.ts#workspaceFileScopeSchema", "apps/sim/lib/api/contracts/workspace-files.ts#workspaceFilesParamsSchema", "apps/sim/lib/api/contracts/workspace-fork.ts#ForkCopyableFile", "apps/sim/lib/api/contracts/workspace-fork.ts#ForkCopyableResource", @@ -4516,7 +4478,6 @@ "apps/sim/lib/execution/remote-sandbox/workspace-sandboxes.ts#isSandboxNameTaken", "apps/sim/lib/execution/remote-sandbox/workspace-sandboxes.ts#isWorkspaceSandboxNameConflictError", "apps/sim/lib/execution/remote-sandbox/workspace-sandboxes.ts#scheduleSandboxBuild", - "apps/sim/lib/execution/sandbox/run-task.ts#SandboxUserCodeError", "apps/sim/lib/file-parsers/errors.ts#FILE_PARSER_ERROR_CODES", "apps/sim/lib/file-parsers/index.ts#parseFile", "apps/sim/lib/file-parsers/json-parser.ts#stripJsonComments", @@ -5169,7 +5130,6 @@ "apps/sim/lib/tokenization/streaming.ts#processStreamingBlockLog", "apps/sim/lib/tokenization/types.ts#TokenizationInput", "apps/sim/lib/uploads/archive.ts#MAX_ARCHIVE_ENTRIES", - "apps/sim/lib/uploads/archive.ts#MAX_ARCHIVE_TOTAL_BYTES", "apps/sim/lib/uploads/client/upload-session.ts#UploadSessionTransportError", "apps/sim/lib/uploads/contexts/copilot/copilot-file-manager.ts#isSupportedFileType", "apps/sim/lib/uploads/contexts/copilot/index.ts#CopilotStoredFile", @@ -5182,7 +5142,6 @@ "apps/sim/lib/uploads/contexts/workspace/workspace-file-manager.ts#matchesWorkspaceFilePattern", "apps/sim/lib/uploads/contexts/workspace/workspace-file-secret-provenance.ts#WorkspaceFileSecretProvenanceEnvelope", "apps/sim/lib/uploads/index.ts#getStorageConfig", - "apps/sim/lib/uploads/server/heic.ts#MAX_TRANSCODE_INPUT_BYTES", "apps/sim/lib/uploads/shared/types.ts#WORKSPACE_SCOPED_CONTEXTS", "apps/sim/lib/uploads/upload-session/application.ts#UploadSessionCreateResult", "apps/sim/lib/uploads/upload-session/application.ts#UploadSessionTransfer", @@ -5357,7 +5316,6 @@ "apps/sim/lib/workspace-events/constants.ts#SIM_RULE_EVENT_TYPES", "apps/sim/lib/workspace-files/application/csv-preview-workspace-file.ts#CsvPreviewWorkspaceFileInput", "apps/sim/lib/workspace-files/application/delete-workspace-file.ts#DeleteWorkspaceFileInput", - "apps/sim/lib/workspace-files/application/download-workspace-file-items.ts#MAX_ZIP_DOWNLOAD_BYTES", "apps/sim/lib/workspace-files/application/download-workspace-file.ts#DownloadWorkspaceFileInput", "apps/sim/lib/workspace-files/application/extract-workspace-file.ts#ExtractWorkspaceFileInput", "apps/sim/lib/workspace-files/application/extract-workspace-file.ts#ExtractWorkspaceFileResult", diff --git a/scripts/generate-v2-cli-api.ts b/scripts/generate-v2-cli-api.ts index fc6cce672f0..824b434519d 100644 --- a/scripts/generate-v2-cli-api.ts +++ b/scripts/generate-v2-cli-api.ts @@ -717,6 +717,7 @@ export function render(operations: Operation[], docs: Map) const discriminator = renderBodyDiscriminator(op.contract.body, ' ') if (discriminator) out.push(` bodyDiscriminator: ${discriminator},`) else if (isUnionSlot(op.contract.body)) out.push(` opaqueBody: true,`) + else if (!map && op.contract.body instanceof z.ZodObject) out.push(` body: {},`) } } // Contract headers are request input like any other slot: `upload-token`