diff --git a/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx b/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx
index 4e222b68dd3..3e9c2212abd 100644
--- a/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx
+++ b/apps/docs/content/docs/en/platform/self-hosting/environment-variables.mdx
@@ -72,7 +72,7 @@ import { Callout } from 'fumadocs-ui/components/callout'
## File Storage
-By default Sim writes uploads to local disk. For production, point it at AWS S3 or Azure Blob. See [Object Storage](/platform/self-hosting/object-storage) for the full setup, bucket layout, and IAM policy.
+By default Sim writes uploads to local disk. For production, point it at AWS S3, Azure Blob, or Google Cloud Storage. See [Object Storage](/platform/self-hosting/object-storage) for the full setup, bucket layout, and IAM policy.
| Variable | Description |
|----------|-------------|
@@ -82,6 +82,9 @@ By default Sim writes uploads to local disk. For production, point it at AWS S3
| `S3_BUCKET_NAME` | General workspace files bucket — set with `AWS_REGION` to enable S3 |
| `AZURE_STORAGE_CONTAINER_NAME` | General files container — set with Azure credentials to enable Blob (takes precedence over S3) |
| `AZURE_CONNECTION_STRING` | Azure connection string, or use `AZURE_ACCOUNT_NAME` + `AZURE_ACCOUNT_KEY` |
+| `GCS_BUCKET_NAME` | General workspace files bucket — enables GCS when neither Azure Blob nor S3 is configured |
+| `GCS_PROJECT_ID` | GCP project ID. Omit to infer from credentials/ADC |
+| `GCS_CREDENTIALS_JSON` | Inline service-account JSON. Omit to use Application Default Credentials (Workload Identity, `GOOGLE_APPLICATION_CREDENTIALS`) |
## Email Providers
diff --git a/apps/docs/content/docs/en/platform/self-hosting/object-storage.mdx b/apps/docs/content/docs/en/platform/self-hosting/object-storage.mdx
index a19285358d7..5a15538a0a5 100644
--- a/apps/docs/content/docs/en/platform/self-hosting/object-storage.mdx
+++ b/apps/docs/content/docs/en/platform/self-hosting/object-storage.mdx
@@ -1,6 +1,6 @@
---
title: Object Storage
-description: Configure where Sim stores uploaded files — local disk, AWS S3, or Azure Blob
+description: Configure where Sim stores uploaded files — local disk, AWS S3, Azure Blob, or Google Cloud Storage
---
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'
@@ -8,16 +8,17 @@ import { Callout } from 'fumadocs-ui/components/callout'
import { Step, Steps } from 'fumadocs-ui/components/steps'
import { FAQ } from '@/components/ui/faq'
-Sim stores every uploaded file — knowledge base documents, chat attachments, execution outputs, profile pictures, and more — in object storage. Three backends are supported:
+Sim stores every uploaded file — knowledge base documents, chat attachments, execution outputs, profile pictures, and more — in object storage. Four backends are supported:
| Backend | When to use |
|---------|-------------|
| **Local disk** | Single-node Docker, local development, evaluation |
| **[AWS S3](https://aws.amazon.com/s3/)** | Production, especially when running more than one app replica |
| **[Azure Blob](https://learn.microsoft.com/azure/storage/blobs/)** | Production on Azure |
+| **[Google Cloud Storage](https://cloud.google.com/storage)** | Production on GCP |
- Local disk writes to the container's `/uploads` directory. Files are lost when the container is recreated unless that path is on a persistent volume, and they are **not** shared across replicas. For any multi-replica or production deployment, use S3 or Azure Blob.
+ Local disk writes to the container's `/uploads` directory. Files are lost when the container is recreated unless that path is on a persistent volume, and they are **not** shared across replicas. For any multi-replica or production deployment, use S3, Azure Blob, or Google Cloud Storage.
## How the backend is selected
@@ -26,9 +27,10 @@ Sim picks the backend automatically from environment variables — there is no e
1. **Azure Blob** — used if `AZURE_STORAGE_CONTAINER_NAME` is set **and** either (`AZURE_ACCOUNT_NAME` + `AZURE_ACCOUNT_KEY`) or `AZURE_CONNECTION_STRING` is set.
2. **AWS S3** — used if `S3_BUCKET_NAME` **and** `AWS_REGION` are set (and Azure is not configured).
-3. **Local disk** — the fallback when neither is configured.
+3. **Google Cloud Storage** — used if `GCS_BUCKET_NAME` is set (and neither Azure nor S3 is configured).
+4. **Local disk** — the fallback when none is configured.
-If both Azure and S3 are configured, **Azure wins**. Set only the variables for the backend you intend to use.
+If more than one backend is configured, the first match in that order wins. Set only the variables for the backend you intend to use.
## Set up AWS S3
@@ -201,6 +203,143 @@ AZURE_STORAGE_WORKSPACE_LOGOS_CONTAINER_NAME=workspace-logos
A full Helm example lives at `helm/sim/examples/values-azure.yaml`.
+## Set up Google Cloud Storage
+
+
+
+
+
+### Create the buckets
+
+GCS uses one bucket per purpose, mirroring the S3 layout:
+
+```bash
+export PROJECT_ID=your-project-id
+export LOCATION=us-central1
+
+# Create buckets (names must be globally unique — prefix with your org)
+for name in workspace-files knowledge-base execution-files chat-files \
+ copilot-files profile-pictures og-images workspace-logos; do
+ gcloud storage buckets create "gs://myorg-sim-$name" \
+ --project "$PROJECT_ID" \
+ --location "$LOCATION" \
+ --uniform-bucket-level-access
+done
+```
+
+Keep all buckets **private** (no `allUsers` bindings). Sim serves files through short-lived V4 signed URLs, so the buckets never need public read access.
+
+Because uploads are sent **directly from the browser** via signed `PUT` requests, each bucket needs a CORS policy that allows your Sim origin:
+
+```bash
+cat > /tmp/cors.json <<'EOF'
+[
+ {
+ "origin": ["https://your-sim-domain.com"],
+ "method": ["GET", "PUT"],
+ "responseHeader": [
+ "Content-Type",
+ "ETag",
+ "x-goog-meta-originalname",
+ "x-goog-meta-uploadedat",
+ "x-goog-meta-purpose",
+ "x-goog-meta-userid",
+ "x-goog-meta-workspaceid",
+ "x-goog-meta-workflowid",
+ "x-goog-meta-executionid"
+ ],
+ "maxAgeSeconds": 3600
+ }
+]
+EOF
+
+for name in workspace-files knowledge-base execution-files chat-files \
+ copilot-files profile-pictures og-images workspace-logos; do
+ gcloud storage buckets update "gs://myorg-sim-$name" --cors-file=/tmp/cors.json
+done
+```
+
+
+ Header names must be listed individually — GCS CORS matches `responseHeader` entries exactly and does not support wildcards like `x-goog-meta-*`. `ETag` is required because large-file multipart uploads read each part's `ETag` from the browser, and CORS hides the header otherwise.
+
+
+
+
+
+
+### Grant access
+
+Create a service account (or reuse the one your workload runs as) and grant it object access on the buckets:
+
+```bash
+gcloud iam service-accounts create sim-storage --project "$PROJECT_ID"
+
+for name in workspace-files knowledge-base execution-files chat-files \
+ copilot-files profile-pictures og-images workspace-logos; do
+ gcloud storage buckets add-iam-policy-binding "gs://myorg-sim-$name" \
+ --member "serviceAccount:sim-storage@$PROJECT_ID.iam.gserviceaccount.com" \
+ --role roles/storage.objectAdmin
+done
+```
+
+You then have two ways to supply credentials:
+
+- **Application Default Credentials (recommended on GCP)** — run Sim with the service account via GKE Workload Identity (or attach it to the GCE instance) and leave `GCS_CREDENTIALS_JSON` unset. Because there is no private key in this mode, generating signed URLs uses the IAM `signBlob` API — grant the service account `roles/iam.serviceAccountTokenCreator` **on itself**:
+
+ ```bash
+ gcloud iam service-accounts add-iam-policy-binding \
+ "sim-storage@$PROJECT_ID.iam.gserviceaccount.com" \
+ --member "serviceAccount:sim-storage@$PROJECT_ID.iam.gserviceaccount.com" \
+ --role roles/iam.serviceAccountTokenCreator
+ ```
+
+- **Inline key (for Docker Compose or non-GCP hosts)** — create a JSON key for the service account and set `GCS_CREDENTIALS_JSON` to its contents. With a private key present, signed URLs are generated locally and no extra IAM role is needed.
+
+
+
+
+
+### Configure environment variables
+
+```bash
+# Credentials — omit both when using Workload Identity / ADC
+GCS_PROJECT_ID=your-project-id # optional; inferred from credentials when unset
+GCS_CREDENTIALS_JSON='{"type":"service_account","client_email":"...","private_key":"..."}'
+
+# Buckets (per purpose)
+GCS_BUCKET_NAME=myorg-sim-workspace-files
+GCS_KB_BUCKET_NAME=myorg-sim-knowledge-base
+GCS_EXECUTION_FILES_BUCKET_NAME=myorg-sim-execution-files
+GCS_CHAT_BUCKET_NAME=myorg-sim-chat-files
+GCS_COPILOT_BUCKET_NAME=myorg-sim-copilot-files
+GCS_PROFILE_PICTURES_BUCKET_NAME=myorg-sim-profile-pictures
+GCS_OG_IMAGES_BUCKET_NAME=myorg-sim-og-images
+GCS_WORKSPACE_LOGOS_BUCKET_NAME=myorg-sim-workspace-logos
+```
+
+Only `GCS_BUCKET_NAME` is strictly required to switch Sim into GCS mode. Every purpose-specific bucket falls back to the general bucket when unset — add the others so each file type lands in its own bucket.
+
+
+
+
+
+### GCS bucket reference
+
+| Variable | Stores | Required |
+|----------|--------|----------|
+| `GCS_BUCKET_NAME` | General workspace files | **Yes** (enables GCS) |
+| `GCS_PROJECT_ID` | GCP project ID | No (inferred from credentials/ADC) |
+| `GCS_CREDENTIALS_JSON` | Inline service-account JSON | No (uses Application Default Credentials if unset) |
+| `GCS_KB_BUCKET_NAME` | Knowledge base documents | Recommended (falls back to `GCS_BUCKET_NAME`) |
+| `GCS_EXECUTION_FILES_BUCKET_NAME` | Workflow execution files | Recommended (falls back to `GCS_BUCKET_NAME`) |
+| `GCS_CHAT_BUCKET_NAME` | Deployed chat assets | Recommended (falls back to `GCS_BUCKET_NAME`) |
+| `GCS_COPILOT_BUCKET_NAME` | Copilot attachments | Recommended (falls back to `GCS_BUCKET_NAME`) |
+| `GCS_PROFILE_PICTURES_BUCKET_NAME` | User avatars | Recommended (falls back to `GCS_BUCKET_NAME`) |
+| `GCS_OG_IMAGES_BUCKET_NAME` | OpenGraph preview images (falls back to `GCS_BUCKET_NAME`) | Optional |
+| `GCS_WORKSPACE_LOGOS_BUCKET_NAME` | Workspace logos (falls back to `GCS_BUCKET_NAME`) | Optional |
+
+A full Helm example (Workload Identity, GKE) lives at `helm/sim/examples/values-gcp.yaml`.
+
## Set up an S3-compatible provider (R2, MinIO, B2)
Sim works with any S3-compatible store by pointing the S3 client at a custom endpoint. Configure it exactly like AWS S3 (buckets, access key, secret), then add `S3_ENDPOINT` — and `S3_FORCE_PATH_STYLE` where the provider requires path-style addressing. Verified with [Cloudflare R2](https://developers.cloudflare.com/r2/), [MinIO](https://min.io/), [Backblaze B2](https://www.backblaze.com/cloud-storage), and [RustFS](https://rustfs.com/).
@@ -280,10 +419,11 @@ After restarting with the new configuration:
If uploads fail, check the app logs for credential or permission errors (see [Troubleshooting](/platform/self-hosting/troubleshooting)).
diff --git a/apps/sim/.env.example b/apps/sim/.env.example
index 1bc29faf0cd..349d017e792 100644
--- a/apps/sim/.env.example
+++ b/apps/sim/.env.example
@@ -81,7 +81,7 @@ API_ENCRYPTION_KEY=your_api_encryption_key # Use `openssl rand -hex 32` to gener
# CONTEXT_DEV_API_KEY_1= # Context.dev API key #1
# CONTEXT_DEV_API_KEY_2= # Context.dev API key #2
-# File Storage (Optional - defaults to local disk; use S3 or Azure Blob for production)
+# File Storage (Optional - defaults to local disk; use S3, Azure Blob, or Google Cloud Storage for production)
# AWS_REGION=us-east-1 # Required with S3_BUCKET_NAME to enable S3. Use "auto" for Cloudflare R2
# AWS_ACCESS_KEY_ID= # Omit to use the instance/IRSA credential chain
# AWS_SECRET_ACCESS_KEY= # Omit to use the instance/IRSA credential chain
@@ -99,7 +99,7 @@ API_ENCRYPTION_KEY=your_api_encryption_key # Use `openssl rand -hex 32` to gener
# Instagram OAuth (Optional - Instagram App ID/Secret from Meta App Dashboard > Instagram > API setup with Instagram login)
# INSTAGRAM_CLIENT_ID=
# INSTAGRAM_CLIENT_SECRET=
-# Instagram publish file uploads require S3 or Azure Blob above (Meta must fetch a public HTTPS URL).
+# Instagram publish file uploads require cloud storage above — S3, Azure Blob, or GCS (Meta must fetch a public HTTPS URL).
# Gmail attachments do not need this.
# TikTok OAuth (Optional - Client Key/Secret from the TikTok for Developers app)
@@ -119,6 +119,18 @@ API_ENCRYPTION_KEY=your_api_encryption_key # Use `openssl rand -hex 32` to gener
# AZURE_STORAGE_OG_IMAGES_CONTAINER_NAME= # OpenGraph preview images (falls back to AZURE_STORAGE_CONTAINER_NAME)
# AZURE_STORAGE_WORKSPACE_LOGOS_CONTAINER_NAME= # Workspace logos (falls back to AZURE_STORAGE_CONTAINER_NAME)
+# Google Cloud Storage (used when neither Azure Blob nor S3 is configured)
+# GCS_PROJECT_ID= # GCP project ID (optional — inferred from credentials/ADC when unset)
+# GCS_CREDENTIALS_JSON= # Inline service-account JSON. Omit to use Application Default Credentials (Workload Identity, GOOGLE_APPLICATION_CREDENTIALS)
+# GCS_BUCKET_NAME= # General workspace files bucket (enables GCS; all other buckets fall back to it)
+# GCS_KB_BUCKET_NAME= # Knowledge base documents
+# GCS_EXECUTION_FILES_BUCKET_NAME= # Workflow execution files
+# GCS_CHAT_BUCKET_NAME= # Deployed chat assets
+# GCS_COPILOT_BUCKET_NAME= # Copilot attachments
+# GCS_PROFILE_PICTURES_BUCKET_NAME= # User profile pictures
+# GCS_OG_IMAGES_BUCKET_NAME= # OpenGraph preview images
+# GCS_WORKSPACE_LOGOS_BUCKET_NAME= # Workspace logos
+
# Admin API (Optional - for self-hosted GitOps)
# ADMIN_API_KEY= # Use `openssl rand -hex 32` to generate. Enables admin API for workflow export/import.
# Usage: curl -H "x-admin-key: your_key" https://your-instance/api/v1/admin/workspaces
diff --git a/apps/sim/app/api/files/authorization.test.ts b/apps/sim/app/api/files/authorization.test.ts
index 4409108eacd..e894bb387a0 100644
--- a/apps/sim/app/api/files/authorization.test.ts
+++ b/apps/sim/app/api/files/authorization.test.ts
@@ -27,8 +27,7 @@ vi.mock('@/lib/uploads', () => ({
}))
vi.mock('@/lib/uploads/config', () => ({
- BLOB_CHAT_CONFIG: {},
- S3_CHAT_CONFIG: {},
+ getStorageConfig: vi.fn(() => ({})),
}))
vi.mock('@/lib/uploads/server/metadata', () => ({
diff --git a/apps/sim/app/api/files/authorization.ts b/apps/sim/app/api/files/authorization.ts
index 8e0c66b4173..2acd14d46a2 100644
--- a/apps/sim/app/api/files/authorization.ts
+++ b/apps/sim/app/api/files/authorization.ts
@@ -6,7 +6,6 @@ import { and, eq, isNull } from 'drizzle-orm'
import { NextResponse } from 'next/server'
import { getFileMetadata } from '@/lib/uploads'
import type { StorageContext } from '@/lib/uploads/config'
-import { BLOB_CHAT_CONFIG, S3_CHAT_CONFIG } from '@/lib/uploads/config'
import type { StorageConfig } from '@/lib/uploads/core/storage-client'
import { getFileMetadataByKey } from '@/lib/uploads/server/metadata'
import { inferContextFromKey } from '@/lib/uploads/utils/file-utils'
@@ -781,23 +780,6 @@ export async function assertToolFileAccess(
* Get chat storage configuration based on current storage provider
*/
async function getChatStorageConfig(): Promise {
- const { USE_S3_STORAGE, USE_BLOB_STORAGE } = await import('@/lib/uploads/config')
-
- if (USE_BLOB_STORAGE) {
- return {
- containerName: BLOB_CHAT_CONFIG.containerName,
- accountName: BLOB_CHAT_CONFIG.accountName,
- accountKey: BLOB_CHAT_CONFIG.accountKey,
- connectionString: BLOB_CHAT_CONFIG.connectionString,
- }
- }
-
- if (USE_S3_STORAGE) {
- return {
- bucket: S3_CHAT_CONFIG.bucket,
- region: S3_CHAT_CONFIG.region,
- }
- }
-
- return {}
+ const { getStorageConfig } = await import('@/lib/uploads/config')
+ return getStorageConfig('chat')
}
diff --git a/apps/sim/app/api/files/export/[id]/route.ts b/apps/sim/app/api/files/export/[id]/route.ts
index 8a7e4b292e5..414d781c3a0 100644
--- a/apps/sim/app/api/files/export/[id]/route.ts
+++ b/apps/sim/app/api/files/export/[id]/route.ts
@@ -12,7 +12,7 @@ import { extractEmbeddedImageIds } from '@/lib/copilot/tools/server/files/embedd
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { captureServerEvent } from '@/lib/posthog/server'
import type { StorageContext } from '@/lib/uploads/config'
-import { USE_BLOB_STORAGE } from '@/lib/uploads/config'
+import { getServeStoragePrefix } from '@/lib/uploads/config'
import { downloadFile } from '@/lib/uploads/core/storage-service'
import { getFileMetadataById } from '@/lib/uploads/server/metadata'
import { verifyFileAccess } from '@/app/api/files/authorization'
@@ -106,7 +106,7 @@ export const GET = withRouteHandler(
}
if (!isMarkdown(record.originalName, record.contentType)) {
- const storagePrefix = USE_BLOB_STORAGE ? 'blob' : 's3'
+ const storagePrefix = getServeStoragePrefix()
const servePath = `/api/files/serve/${storagePrefix}/${encodeURIComponent(record.key)}`
auditExport('file', 0)
return NextResponse.redirect(new URL(servePath, request.url), { status: 302 })
diff --git a/apps/sim/app/api/files/multipart/route.ts b/apps/sim/app/api/files/multipart/route.ts
index 9ead001c919..07fbac67361 100644
--- a/apps/sim/app/api/files/multipart/route.ts
+++ b/apps/sim/app/api/files/multipart/route.ts
@@ -48,8 +48,8 @@ const ALLOWED_UPLOAD_CONTEXTS = new Set([
/**
* Unified part identity sent by the client when completing a multipart upload.
- * `etag` is required for S3 (CompleteMultipartUpload). For Azure the server
- * derives the block id from `partNumber` via {@link deriveBlobBlockId}.
+ * `etag` is required for S3 and GCS (CompleteMultipartUpload). For Azure the
+ * server derives the block id from `partNumber` via {@link deriveBlobBlockId}.
*/
interface ClientCompletedPart {
partNumber: number
@@ -77,6 +77,9 @@ const buildBlobCustomConfig = (config: StorageConfig) => ({
connectionString: config.connectionString,
})
+const buildGcsCustomConfig = (config: StorageConfig) =>
+ config.bucket ? { bucket: config.bucket } : undefined
+
const verifyTokenForUser = (token: string | undefined, userId: string) => {
if (!token || typeof token !== 'string') {
return null
@@ -124,7 +127,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
if (!isUsingCloudStorage()) {
return NextResponse.json(
- { error: 'Multipart upload is only available with cloud storage (S3 or Azure Blob)' },
+ {
+ error:
+ 'Multipart upload is only available with cloud storage (S3, Azure Blob, or Google Cloud Storage)',
+ },
{ status: 400 }
)
}
@@ -241,6 +247,18 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
})
uploadId = result.uploadId
key = result.key
+ } else if (storageProvider === 'gcs') {
+ const { initiateGcsMultipartUpload } = await import('@/lib/uploads/providers/gcs/client')
+ const result = await initiateGcsMultipartUpload({
+ fileName,
+ contentType,
+ fileSize,
+ customConfig: buildGcsCustomConfig(config),
+ customKey,
+ purpose: context,
+ })
+ uploadId = result.uploadId
+ key = result.key
} else {
return NextResponse.json(
{ error: `Unsupported storage provider: ${storageProvider}` },
@@ -308,6 +326,16 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
)
return NextResponse.json({ presignedUrls })
}
+ if (storageProvider === 'gcs') {
+ const { getGcsMultipartPartUrls } = await import('@/lib/uploads/providers/gcs/client')
+ const presignedUrls = await getGcsMultipartPartUrls(
+ key,
+ uploadId,
+ partNumbers,
+ buildGcsCustomConfig(config)
+ )
+ return NextResponse.json({ presignedUrls })
+ }
return NextResponse.json(
{ error: `Unsupported storage provider: ${storageProvider}` },
@@ -333,6 +361,8 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
storageProvider === 's3' ? await import('@/lib/uploads/providers/s3/client') : null
const blobModule =
storageProvider === 'blob' ? await import('@/lib/uploads/providers/blob/client') : null
+ const gcsModule =
+ storageProvider === 'gcs' ? await import('@/lib/uploads/providers/gcs/client') : null
const completeOne = async (payload: UploadTokenPayload, parts: ClientCompletedPart[]) => {
const { uploadId, key, context } = payload
@@ -360,6 +390,20 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
blockId: deriveBlobBlockId(p.partNumber),
}))
completed = await completeMultipartUpload(key, blobParts, buildBlobCustomConfig(config))
+ } else if (storageProvider === 'gcs' && gcsModule) {
+ const { completeGcsMultipartUpload } = gcsModule
+ const gcsParts = parts.map((p) => {
+ if (!p.etag) {
+ throw new Error(`Missing etag for GCS part ${p.partNumber}`)
+ }
+ return { ETag: p.etag, PartNumber: p.partNumber }
+ })
+ completed = await completeGcsMultipartUpload(
+ key,
+ uploadId,
+ gcsParts,
+ buildGcsCustomConfig(config)
+ )
} else {
throw new Error(`Unsupported storage provider: ${storageProvider}`)
}
@@ -459,6 +503,10 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
const { abortMultipartUpload } = await import('@/lib/uploads/providers/blob/client')
await abortMultipartUpload(key, buildBlobCustomConfig(config))
logger.info(`Aborted Azure multipart upload for key ${key} (context: ${context})`)
+ } else if (storageProvider === 'gcs') {
+ const { abortGcsMultipartUpload } = await import('@/lib/uploads/providers/gcs/client')
+ await abortGcsMultipartUpload(key, uploadId, buildGcsCustomConfig(config))
+ logger.info(`Aborted GCS multipart upload for key ${key} (context: ${context})`)
} else {
return NextResponse.json(
{ error: `Unsupported storage provider: ${storageProvider}` },
diff --git a/apps/sim/app/api/files/parse/route.ts b/apps/sim/app/api/files/parse/route.ts
index 033f180818a..c1bcf0dbea2 100644
--- a/apps/sim/app/api/files/parse/route.ts
+++ b/apps/sim/app/api/files/parse/route.ts
@@ -489,23 +489,25 @@ async function handleExternalUrl(
try {
logger.info('Fetching external URL:', url)
- const {
- S3_EXECUTION_FILES_CONFIG,
- BLOB_EXECUTION_FILES_CONFIG,
- USE_S3_STORAGE,
- USE_BLOB_STORAGE,
- } = await import('@/lib/uploads/config')
+ const { getStorageConfig, USE_S3_STORAGE, USE_BLOB_STORAGE, USE_GCS_STORAGE } = await import(
+ '@/lib/uploads/config'
+ )
+ const executionConfig = getStorageConfig('execution')
let isExecutionFile = false
try {
const parsedUrl = new URL(url)
- if (USE_S3_STORAGE && S3_EXECUTION_FILES_CONFIG.bucket) {
- const bucketInHost = parsedUrl.hostname.startsWith(S3_EXECUTION_FILES_CONFIG.bucket)
- const bucketInPath = parsedUrl.pathname.startsWith(`/${S3_EXECUTION_FILES_CONFIG.bucket}/`)
+ if (USE_S3_STORAGE && executionConfig.bucket) {
+ const bucketInHost = parsedUrl.hostname.startsWith(executionConfig.bucket)
+ const bucketInPath = parsedUrl.pathname.startsWith(`/${executionConfig.bucket}/`)
+ isExecutionFile = bucketInHost || bucketInPath
+ } else if (USE_BLOB_STORAGE && executionConfig.containerName) {
+ isExecutionFile = url.includes(`/${executionConfig.containerName}/`)
+ } else if (USE_GCS_STORAGE && executionConfig.bucket) {
+ const bucketInHost = parsedUrl.hostname.startsWith(`${executionConfig.bucket}.`)
+ const bucketInPath = parsedUrl.pathname.startsWith(`/${executionConfig.bucket}/`)
isExecutionFile = bucketInHost || bucketInPath
- } else if (USE_BLOB_STORAGE && BLOB_EXECUTION_FILES_CONFIG.containerName) {
- isExecutionFile = url.includes(`/${BLOB_EXECUTION_FILES_CONFIG.containerName}/`)
}
} catch (error) {
logger.warn('Failed to parse URL for execution file check:', error)
diff --git a/apps/sim/app/api/files/presigned/batch/route.ts b/apps/sim/app/api/files/presigned/batch/route.ts
index 846c381d051..85dfb85ec08 100644
--- a/apps/sim/app/api/files/presigned/batch/route.ts
+++ b/apps/sim/app/api/files/presigned/batch/route.ts
@@ -8,7 +8,7 @@ import { getValidationErrorMessage, parseRequest } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import type { StorageContext } from '@/lib/uploads/config'
-import { USE_BLOB_STORAGE } from '@/lib/uploads/config'
+import { getServeStoragePrefix } from '@/lib/uploads/config'
import {
generateBatchPresignedUploadUrls,
hasCloudStorage,
@@ -163,7 +163,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
)
}
- const storagePrefix = USE_BLOB_STORAGE ? 'blob' : 's3'
+ const storagePrefix = getServeStoragePrefix()
return NextResponse.json({
files: presignedUrls.map((urlResponse, index) => {
diff --git a/apps/sim/app/api/files/presigned/route.test.ts b/apps/sim/app/api/files/presigned/route.test.ts
index cd8c50caa4a..55f37d718ac 100644
--- a/apps/sim/app/api/files/presigned/route.test.ts
+++ b/apps/sim/app/api/files/presigned/route.test.ts
@@ -75,6 +75,7 @@ vi.mock('@/lib/uploads/config', () => ({
return mockUseS3Storage.value
},
UPLOAD_DIR: '/uploads',
+ getServeStoragePrefix: () => (mockUseBlobStorage.value ? 'blob' : 's3'),
getStorageConfig: mockGetStorageConfig,
isUsingCloudStorage: mockIsUsingCloudStorage,
getStorageProvider: mockGetStorageProvider,
diff --git a/apps/sim/app/api/files/presigned/route.ts b/apps/sim/app/api/files/presigned/route.ts
index 68893aaeae0..5fd15fbfe5c 100644
--- a/apps/sim/app/api/files/presigned/route.ts
+++ b/apps/sim/app/api/files/presigned/route.ts
@@ -7,7 +7,7 @@ import { getSession } from '@/lib/auth'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
import { CopilotFiles } from '@/lib/uploads'
import type { StorageContext } from '@/lib/uploads/config'
-import { USE_BLOB_STORAGE } from '@/lib/uploads/config'
+import { getServeStoragePrefix } from '@/lib/uploads/config'
import { generateExecutionFileKey } from '@/lib/uploads/contexts/execution/utils'
import { generateKnowledgeBaseFileKey } from '@/lib/uploads/contexts/knowledge-base/knowledge-base-file-manager'
import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
@@ -313,7 +313,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
})
}
- const finalPath = `/api/files/serve/${USE_BLOB_STORAGE ? 'blob' : 's3'}/${encodeURIComponent(presignedUrlResponse.key)}?context=${uploadType}`
+ const finalPath = `/api/files/serve/${getServeStoragePrefix()}/${encodeURIComponent(presignedUrlResponse.key)}?context=${uploadType}`
return NextResponse.json({
fileName,
diff --git a/apps/sim/app/api/files/serve/[...path]/route.ts b/apps/sim/app/api/files/serve/[...path]/route.ts
index 42e11b0cd34..aad77fe0393 100644
--- a/apps/sim/app/api/files/serve/[...path]/route.ts
+++ b/apps/sim/app/api/files/serve/[...path]/route.ts
@@ -94,7 +94,8 @@ export const GET = withRouteHandler(
const fullPath = path.join('/')
const isS3Path = path[0] === 's3'
const isBlobPath = path[0] === 'blob'
- const isCloudPath = isS3Path || isBlobPath
+ const isGcsPath = path[0] === 'gcs'
+ const isCloudPath = isS3Path || isBlobPath || isGcsPath
const cloudKey = isCloudPath ? path.slice(1).join('/') : fullPath
const isPublicByKeyPrefix =
diff --git a/apps/sim/app/api/files/storage-status/route.ts b/apps/sim/app/api/files/storage-status/route.ts
index 58dcadbdeb4..aadb2c92c74 100644
--- a/apps/sim/app/api/files/storage-status/route.ts
+++ b/apps/sim/app/api/files/storage-status/route.ts
@@ -9,7 +9,7 @@ export const dynamic = 'force-dynamic'
/**
* GET /api/files/storage-status
- * Whether S3 or Azure Blob is configured (needed for Instagram file-upload publish).
+ * Whether S3, Azure Blob, or Google Cloud Storage is configured (needed for Instagram file-upload publish).
*/
export const GET = withRouteHandler(async (request: NextRequest) => {
const session = await getSession()
diff --git a/apps/sim/app/api/files/utils.ts b/apps/sim/app/api/files/utils.ts
index 2bdf7663825..0eedc8211cc 100644
--- a/apps/sim/app/api/files/utils.ts
+++ b/apps/sim/app/api/files/utils.ts
@@ -99,9 +99,9 @@ export function extractFilename(path: string): string {
.replace(/\/\.\./g, '')
.replace(/\.\.\//g, '')
- if (filename.startsWith('s3/') || filename.startsWith('blob/')) {
+ if (filename.startsWith('s3/') || filename.startsWith('blob/') || filename.startsWith('gcs/')) {
const parts = filename.split('/')
- const prefix = parts[0] // 's3' or 'blob'
+ const prefix = parts[0] // 's3', 'blob', or 'gcs'
const keyParts = parts.slice(1)
const sanitizedKeyParts = keyParts
diff --git a/apps/sim/app/api/files/view/[id]/route.ts b/apps/sim/app/api/files/view/[id]/route.ts
index e9d68b86821..55ddedcef3a 100644
--- a/apps/sim/app/api/files/view/[id]/route.ts
+++ b/apps/sim/app/api/files/view/[id]/route.ts
@@ -5,7 +5,7 @@ import { fileViewContract } from '@/lib/api/contracts/storage-transfer'
import { parseRequest } from '@/lib/api/server'
import { checkSessionOrInternalAuth } from '@/lib/auth/hybrid'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { type StorageContext, USE_BLOB_STORAGE } from '@/lib/uploads/config'
+import { getServeStoragePrefix, type StorageContext } from '@/lib/uploads/config'
import { getFileMetadataById } from '@/lib/uploads/server/metadata'
import { verifyFileAccess } from '@/app/api/files/authorization'
@@ -57,7 +57,7 @@ export const GET = withRouteHandler(
)
}
- const storagePrefix = USE_BLOB_STORAGE ? 'blob' : 's3'
+ const storagePrefix = getServeStoragePrefix()
const servePath = `/api/files/serve/${storagePrefix}/${encodeURIComponent(record.key)}`
logger.info('Redirecting file view to serve path', { id, servePath })
diff --git a/apps/sim/app/api/workspaces/[id]/files/presigned/route.test.ts b/apps/sim/app/api/workspaces/[id]/files/presigned/route.test.ts
index 53a70ec7a0d..d69aa933389 100644
--- a/apps/sim/app/api/workspaces/[id]/files/presigned/route.test.ts
+++ b/apps/sim/app/api/workspaces/[id]/files/presigned/route.test.ts
@@ -35,9 +35,7 @@ vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
}))
vi.mock('@/lib/uploads/config', () => ({
- get USE_BLOB_STORAGE() {
- return mockUseBlobStorage.value
- },
+ getServeStoragePrefix: () => (mockUseBlobStorage.value ? 'blob' : 's3'),
}))
vi.mock('@/lib/workspaces/permissions/utils', () => permissionsMock)
diff --git a/apps/sim/app/api/workspaces/[id]/files/presigned/route.ts b/apps/sim/app/api/workspaces/[id]/files/presigned/route.ts
index 64ba36f0fc5..905ad938d98 100644
--- a/apps/sim/app/api/workspaces/[id]/files/presigned/route.ts
+++ b/apps/sim/app/api/workspaces/[id]/files/presigned/route.ts
@@ -9,7 +9,7 @@ import {
resolveStorageBillingContext,
} from '@/lib/billing/storage'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { USE_BLOB_STORAGE } from '@/lib/uploads/config'
+import { getServeStoragePrefix } from '@/lib/uploads/config'
import { assertWorkspaceFileFolderTarget } from '@/lib/uploads/contexts/workspace'
import { generateWorkspaceFileKey } from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { generatePresignedUploadUrl, hasCloudStorage } from '@/lib/uploads/core/storage-service'
@@ -92,7 +92,7 @@ export const POST = withRouteHandler(
metadata: { workspaceId, ...(targetFolderId ? { folderId: targetFolderId } : {}) },
})
- const finalPath = `/api/files/serve/${USE_BLOB_STORAGE ? 'blob' : 's3'}/${encodeURIComponent(key)}?context=workspace`
+ const finalPath = `/api/files/serve/${getServeStoragePrefix()}/${encodeURIComponent(key)}?context=workspace`
logger.info(`Issued workspace presigned URL for ${fileName} -> ${key}`)
diff --git a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/file-download/file-download.tsx b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/file-download/file-download.tsx
index f898833b891..205ad59a408 100644
--- a/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/file-download/file-download.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/logs/components/log-details/components/file-download/file-download.tsx
@@ -15,7 +15,7 @@ interface FileData {
type: string
key: string
url: string
- storageProvider?: 's3' | 'blob' | 'local'
+ storageProvider?: 's3' | 'blob' | 'gcs' | 'local'
bucketName?: string
}
diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts
index c82870975c5..386ddbb5506 100644
--- a/apps/sim/lib/core/config/env.ts
+++ b/apps/sim/lib/core/config/env.ts
@@ -278,6 +278,18 @@ export const env = createEnv({
AZURE_STORAGE_OG_IMAGES_CONTAINER_NAME: z.string().optional(), // Azure container for OpenGraph images
AZURE_STORAGE_WORKSPACE_LOGOS_CONTAINER_NAME: z.string().optional(), // Azure container for workspace logos
+ // Cloud Storage - Google Cloud Storage
+ GCS_PROJECT_ID: z.string().optional(), // GCP project ID (optional — inferred from credentials/ADC when unset)
+ GCS_CREDENTIALS_JSON: z.string().optional(), // Inline service-account JSON. Omit to use Application Default Credentials (Workload Identity, GOOGLE_APPLICATION_CREDENTIALS)
+ GCS_BUCKET_NAME: z.string().optional(), // GCS bucket for general file storage (enables GCS)
+ GCS_KB_BUCKET_NAME: z.string().optional(), // GCS bucket for knowledge base files
+ GCS_EXECUTION_FILES_BUCKET_NAME: z.string().optional(), // GCS bucket for workflow execution files
+ GCS_CHAT_BUCKET_NAME: z.string().optional(), // GCS bucket for chat logos
+ GCS_COPILOT_BUCKET_NAME: z.string().optional(), // GCS bucket for copilot files
+ GCS_PROFILE_PICTURES_BUCKET_NAME: z.string().optional(), // GCS bucket for profile pictures
+ GCS_OG_IMAGES_BUCKET_NAME: z.string().optional(), // GCS bucket for OpenGraph images
+ GCS_WORKSPACE_LOGOS_BUCKET_NAME: z.string().optional(), // GCS bucket for workspace logos
+
// Admission & Burst Protection
ADMISSION_GATE_MAX_INFLIGHT: z.string().optional().default('500'), // Max concurrent in-flight execution requests per pod
diff --git a/apps/sim/lib/uploads/config.ts b/apps/sim/lib/uploads/config.ts
index d833d19c9f8..10cb9a2eff7 100644
--- a/apps/sim/lib/uploads/config.ts
+++ b/apps/sim/lib/uploads/config.ts
@@ -10,9 +10,11 @@ export const hasBlobConfig = !!(
env.AZURE_STORAGE_CONTAINER_NAME &&
((env.AZURE_ACCOUNT_NAME && env.AZURE_ACCOUNT_KEY) || env.AZURE_CONNECTION_STRING)
)
+const hasGcsConfig = !!env.GCS_BUCKET_NAME
export const USE_BLOB_STORAGE = hasBlobConfig
export const USE_S3_STORAGE = hasS3Config && !USE_BLOB_STORAGE
+export const USE_GCS_STORAGE = hasGcsConfig && !USE_BLOB_STORAGE && !USE_S3_STORAGE
export const S3_CONFIG = {
bucket: env.S3_BUCKET_NAME || '',
@@ -35,6 +37,38 @@ export const BLOB_CONFIG = {
containerName: env.AZURE_STORAGE_CONTAINER_NAME || '',
}
+export const GCS_CONFIG = {
+ bucket: env.GCS_BUCKET_NAME || '',
+}
+
+export const GCS_KB_CONFIG = {
+ bucket: env.GCS_KB_BUCKET_NAME || '',
+}
+
+export const GCS_EXECUTION_FILES_CONFIG = {
+ bucket: env.GCS_EXECUTION_FILES_BUCKET_NAME || '',
+}
+
+export const GCS_CHAT_CONFIG = {
+ bucket: env.GCS_CHAT_BUCKET_NAME || '',
+}
+
+export const GCS_COPILOT_CONFIG = {
+ bucket: env.GCS_COPILOT_BUCKET_NAME || '',
+}
+
+export const GCS_PROFILE_PICTURES_CONFIG = {
+ bucket: env.GCS_PROFILE_PICTURES_BUCKET_NAME || '',
+}
+
+export const GCS_OG_IMAGES_CONFIG = {
+ bucket: env.GCS_OG_IMAGES_BUCKET_NAME || '',
+}
+
+export const GCS_WORKSPACE_LOGOS_CONFIG = {
+ bucket: env.GCS_WORKSPACE_LOGOS_BUCKET_NAME || '',
+}
+
export const S3_KB_CONFIG = {
bucket: env.S3_KB_BUCKET_NAME || '',
region: env.AWS_REGION || '',
@@ -122,17 +156,28 @@ export const BLOB_WORKSPACE_LOGOS_CONFIG = {
/**
* Get the current storage provider as a human-readable string
*/
-export function getStorageProvider(): 'Azure Blob' | 'S3' | 'Local' {
+export function getStorageProvider(): 'Azure Blob' | 'S3' | 'GCS' | 'Local' {
if (USE_BLOB_STORAGE) return 'Azure Blob'
if (USE_S3_STORAGE) return 'S3'
+ if (USE_GCS_STORAGE) return 'GCS'
return 'Local'
}
/**
- * Check if we're using any cloud storage (S3 or Blob)
+ * Check if we're using any cloud storage (S3, Blob, or GCS)
*/
export function isUsingCloudStorage(): boolean {
- return USE_S3_STORAGE || USE_BLOB_STORAGE
+ return USE_S3_STORAGE || USE_BLOB_STORAGE || USE_GCS_STORAGE
+}
+
+/**
+ * Provider segment used in `/api/files/serve//` paths returned to
+ * clients after a direct upload. Defaults to `s3` to preserve historical paths.
+ */
+export function getServeStoragePrefix(): 'blob' | 's3' | 'gcs' {
+ if (USE_BLOB_STORAGE) return 'blob'
+ if (USE_GCS_STORAGE) return 'gcs'
+ return 's3'
}
/**
@@ -147,6 +192,10 @@ export function getStorageConfig(context: StorageContext): StorageConfig {
return getS3Config(context)
}
+ if (USE_GCS_STORAGE) {
+ return getGcsConfig(context)
+ }
+
return {}
}
@@ -277,6 +326,39 @@ function getBlobConfig(context: StorageContext): StorageConfig {
}
}
+/**
+ * Get GCS configuration for a given context.
+ *
+ * Every context falls back to the general bucket when its dedicated bucket is
+ * unset. GCS bucket names are globally unique, so an S3-style literal default
+ * (e.g. `sim-execution-files`) would point at a bucket the operator does not
+ * own; falling back keeps uploads, downloads, and serving consistent when only
+ * `GCS_BUCKET_NAME` is configured.
+ */
+function getGcsConfig(context: StorageContext): StorageConfig {
+ switch (context) {
+ case 'knowledge-base':
+ return { bucket: GCS_KB_CONFIG.bucket || GCS_CONFIG.bucket }
+ case 'chat':
+ return { bucket: GCS_CHAT_CONFIG.bucket || GCS_CONFIG.bucket }
+ case 'copilot':
+ return { bucket: GCS_COPILOT_CONFIG.bucket || GCS_CONFIG.bucket }
+ case 'execution':
+ return { bucket: GCS_EXECUTION_FILES_CONFIG.bucket || GCS_CONFIG.bucket }
+ case 'mothership':
+ case 'workspace':
+ return { bucket: GCS_CONFIG.bucket }
+ case 'profile-pictures':
+ return { bucket: GCS_PROFILE_PICTURES_CONFIG.bucket || GCS_CONFIG.bucket }
+ case 'og-images':
+ return { bucket: GCS_OG_IMAGES_CONFIG.bucket || GCS_CONFIG.bucket }
+ case 'workspace-logos':
+ return { bucket: GCS_WORKSPACE_LOGOS_CONFIG.bucket || GCS_CONFIG.bucket }
+ default:
+ return { bucket: GCS_CONFIG.bucket }
+ }
+}
+
/**
* Check if a specific storage context is configured
* Returns false if the context would fall back to general config but general isn't configured
@@ -295,5 +377,9 @@ export function isStorageContextConfigured(context: StorageContext): boolean {
return !!(config.bucket && config.region)
}
+ if (USE_GCS_STORAGE) {
+ return !!config.bucket
+ }
+
return true
}
diff --git a/apps/sim/lib/uploads/core/setup.server.ts b/apps/sim/lib/uploads/core/setup.server.ts
index 500eae73c1a..b446382e87d 100644
--- a/apps/sim/lib/uploads/core/setup.server.ts
+++ b/apps/sim/lib/uploads/core/setup.server.ts
@@ -7,6 +7,7 @@ import {
getStorageProvider,
S3_CONFIG,
USE_BLOB_STORAGE,
+ USE_GCS_STORAGE,
USE_S3_STORAGE,
} from '@/lib/uploads/config'
@@ -33,6 +34,11 @@ async function ensureUploadsDirectory() {
return true
}
+ if (USE_GCS_STORAGE) {
+ logger.info('Using Google Cloud Storage, skipping local uploads directory creation')
+ return true
+ }
+
try {
if (!existsSync(UPLOAD_DIR_SERVER)) {
await mkdir(UPLOAD_DIR_SERVER, { recursive: true })
@@ -94,6 +100,18 @@ if (typeof process !== 'undefined') {
`Using S3-compatible endpoint: ${env.S3_ENDPOINT} (path-style: ${S3_CONFIG.forcePathStyle})`
)
}
+ } else if (USE_GCS_STORAGE) {
+ // Verify GCS credentials
+ if (env.GCS_CREDENTIALS_JSON) {
+ logger.info('Using inline service-account credentials (GCS_CREDENTIALS_JSON)')
+ } else {
+ logger.info(
+ 'GCS_CREDENTIALS_JSON not set — using Application Default Credentials (Workload Identity or GOOGLE_APPLICATION_CREDENTIALS)'
+ )
+ logger.info(
+ 'Signed URL generation without a private key requires the iam.serviceAccounts.signBlob permission (roles/iam.serviceAccountTokenCreator)'
+ )
+ }
} else {
// Local storage mode
logger.info('Using local file storage')
@@ -121,4 +139,10 @@ if (typeof process !== 'undefined') {
if (USE_S3_STORAGE && env.S3_COPILOT_BUCKET_NAME) {
logger.info(`S3 copilot bucket: ${env.S3_COPILOT_BUCKET_NAME}`)
}
+ if (USE_GCS_STORAGE && env.GCS_KB_BUCKET_NAME) {
+ logger.info(`GCS knowledge base bucket: ${env.GCS_KB_BUCKET_NAME}`)
+ }
+ if (USE_GCS_STORAGE && env.GCS_COPILOT_BUCKET_NAME) {
+ logger.info(`GCS copilot bucket: ${env.GCS_COPILOT_BUCKET_NAME}`)
+ }
}
diff --git a/apps/sim/lib/uploads/core/storage-client.ts b/apps/sim/lib/uploads/core/storage-client.ts
index 2ae29b8a69f..72a0683ebf9 100644
--- a/apps/sim/lib/uploads/core/storage-client.ts
+++ b/apps/sim/lib/uploads/core/storage-client.ts
@@ -1,4 +1,4 @@
-import { USE_BLOB_STORAGE, USE_S3_STORAGE } from '@/lib/uploads/config'
+import { USE_BLOB_STORAGE, USE_GCS_STORAGE, USE_S3_STORAGE } from '@/lib/uploads/config'
import type { StorageConfig } from '@/lib/uploads/shared/types'
export type { StorageConfig } from '@/lib/uploads/shared/types'
@@ -6,9 +6,10 @@ export type { StorageConfig } from '@/lib/uploads/shared/types'
/**
* Get the current storage provider name
*/
-export function getStorageProvider(): 'blob' | 's3' | 'local' {
+export function getStorageProvider(): 'blob' | 's3' | 'gcs' | 'local' {
if (USE_BLOB_STORAGE) return 'blob'
if (USE_S3_STORAGE) return 's3'
+ if (USE_GCS_STORAGE) return 'gcs'
return 'local'
}
@@ -93,5 +94,10 @@ export async function getFileMetadata(
return response.Metadata || {}
}
+ if (USE_GCS_STORAGE) {
+ const { getGcsObjectMetadata } = await import('@/lib/uploads/providers/gcs/client')
+ return getGcsObjectMetadata(key, customConfig?.bucket ? { bucket: customConfig.bucket } : undefined)
+ }
+
return {}
}
diff --git a/apps/sim/lib/uploads/core/storage-service.blob-connection-string.test.ts b/apps/sim/lib/uploads/core/storage-service.blob-connection-string.test.ts
index dffb79607eb..3159d5e7139 100644
--- a/apps/sim/lib/uploads/core/storage-service.blob-connection-string.test.ts
+++ b/apps/sim/lib/uploads/core/storage-service.blob-connection-string.test.ts
@@ -21,6 +21,7 @@ const { mockHeadBlobObject, mockGetBlobServiceClient, mockGenerateBlobSASQueryPa
vi.mock('@/lib/uploads/config', () => ({
USE_S3_STORAGE: false,
USE_BLOB_STORAGE: true,
+ USE_GCS_STORAGE: false,
// Connection-string-only: accountName/accountKey intentionally absent.
getStorageConfig: () => ({
containerName: 'workspace-files',
diff --git a/apps/sim/lib/uploads/core/storage-service.test.ts b/apps/sim/lib/uploads/core/storage-service.test.ts
index 5343853c160..e2f39c44dbb 100644
--- a/apps/sim/lib/uploads/core/storage-service.test.ts
+++ b/apps/sim/lib/uploads/core/storage-service.test.ts
@@ -24,6 +24,7 @@ const {
vi.mock('@/lib/uploads/config', () => ({
USE_S3_STORAGE: true,
USE_BLOB_STORAGE: false,
+ USE_GCS_STORAGE: false,
getStorageConfig: () => ({ bucket: 'b', region: 'r' }),
}))
diff --git a/apps/sim/lib/uploads/core/storage-service.ts b/apps/sim/lib/uploads/core/storage-service.ts
index acfac9f3794..0a49d320e02 100644
--- a/apps/sim/lib/uploads/core/storage-service.ts
+++ b/apps/sim/lib/uploads/core/storage-service.ts
@@ -3,8 +3,14 @@ import { randomBytes } from 'crypto'
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { assertKnownSizeWithinLimit } from '@/lib/core/utils/stream-limits'
-import { getStorageConfig, USE_BLOB_STORAGE, USE_S3_STORAGE } from '@/lib/uploads/config'
+import {
+ getStorageConfig,
+ USE_BLOB_STORAGE,
+ USE_GCS_STORAGE,
+ USE_S3_STORAGE,
+} from '@/lib/uploads/config'
import type { AzureMultipartPart, BlobConfig } from '@/lib/uploads/providers/blob/types'
+import type { GcsConfig, GcsMultipartPart } from '@/lib/uploads/providers/gcs/types'
import type { S3Config, S3MultipartPart } from '@/lib/uploads/providers/s3/types'
import type {
DeleteFileOptions,
@@ -62,6 +68,20 @@ function createS3Config(config: StorageConfig): S3Config {
}
}
+/**
+ * Create a GCS config from StorageConfig
+ * @throws Error if required properties are missing
+ */
+function createGcsConfig(config: StorageConfig): GcsConfig {
+ if (!config.bucket) {
+ throw new Error('GCS configuration missing required property: bucket')
+ }
+
+ return {
+ bucket: config.bucket,
+ }
+}
+
/**
* Insert file metadata into the database
*/
@@ -159,6 +179,32 @@ export async function uploadFile(options: UploadFileOptions): Promise
return uploadResult
}
+ if (USE_GCS_STORAGE) {
+ const { uploadToGcs } = await import('@/lib/uploads/providers/gcs/client')
+ const uploadResult = await uploadToGcs(
+ file,
+ keyToUse,
+ contentType,
+ createGcsConfig(config),
+ file.length,
+ preserveKey,
+ metadata
+ )
+
+ if (metadata && persistMetadata) {
+ await insertFileMetadataHelper(
+ uploadResult.key,
+ metadata,
+ context,
+ fileName,
+ contentType,
+ file.length
+ )
+ }
+
+ return uploadResult
+ }
+
const { writeFile, mkdir } = await import('fs/promises')
const { join, dirname } = await import('path')
const { UPLOAD_DIR_SERVER } = await import('./setup.server')
@@ -262,6 +308,36 @@ async function createBlobBackend(
}
}
+async function createGcsBackend(
+ key: string,
+ config: GcsConfig,
+ contentType: string,
+ purpose: string
+): Promise {
+ const {
+ initiateGcsMultipartUpload,
+ uploadGcsPart,
+ completeGcsMultipartUpload,
+ abortGcsMultipartUpload,
+ } = await import('@/lib/uploads/providers/gcs/client')
+ const { uploadId } = await initiateGcsMultipartUpload({
+ fileName: key,
+ contentType,
+ fileSize: 0,
+ customConfig: config,
+ customKey: key,
+ purpose,
+ })
+ const parts: GcsMultipartPart[] = []
+ return {
+ async uploadPart(partNumber, body) {
+ parts.push(await uploadGcsPart(key, uploadId, partNumber, body, config))
+ },
+ finish: () => completeGcsMultipartUpload(key, uploadId, parts, config).then(() => undefined),
+ abort: () => abortGcsMultipartUpload(key, uploadId, config),
+ }
+}
+
/**
* Open a streaming multipart upload to the configured provider. On the local
* filesystem provider (and for any payload smaller than one part) the bytes are
@@ -292,9 +368,13 @@ export async function createMultipartUpload(options: {
const ensureBackend = async (): Promise => {
if (!backend) {
- backend = USE_BLOB_STORAGE
- ? await createBlobBackend(key, createBlobConfig(config), contentType)
- : await createS3Backend(key, createS3Config(config), contentType, context)
+ if (USE_BLOB_STORAGE) {
+ backend = await createBlobBackend(key, createBlobConfig(config), contentType)
+ } else if (USE_GCS_STORAGE) {
+ backend = await createGcsBackend(key, createGcsConfig(config), contentType, context)
+ } else {
+ backend = await createS3Backend(key, createS3Config(config), contentType, context)
+ }
}
return backend
}
@@ -393,6 +473,14 @@ export async function downloadFile(options: DownloadFileOptions): Promise {
const { deleteFromS3 } = await import('@/lib/uploads/providers/s3/client')
return deleteFromS3(key, createS3Config(config))
}
+
+ if (USE_GCS_STORAGE) {
+ const { deleteFromGcs } = await import('@/lib/uploads/providers/gcs/client')
+ return deleteFromGcs(key, createGcsConfig(config))
+ }
}
const { unlink } = await import('fs/promises')
@@ -531,6 +629,11 @@ export async function headObject(
return headS3Object(key, createS3Config(config))
}
+ if (USE_GCS_STORAGE) {
+ const { headGcsObject } = await import('@/lib/uploads/providers/gcs/client')
+ return headGcsObject(key, createGcsConfig(config))
+ }
+
return null
}
@@ -586,9 +689,40 @@ export async function generatePresignedUploadUrl(
return generateBlobPresignedUrl(key, contentType, allMetadata, config, expirationSeconds)
}
+ if (USE_GCS_STORAGE) {
+ return generateGcsPresignedUrl(key, contentType, allMetadata, config, expirationSeconds)
+ }
+
throw new Error('Cloud storage not configured. Cannot generate presigned URL for local storage.')
}
+/**
+ * Generate presigned URL for GCS
+ */
+async function generateGcsPresignedUrl(
+ key: string,
+ contentType: string,
+ metadata: Record,
+ config: StorageConfig,
+ expirationSeconds: number
+): Promise {
+ const { getGcsPresignedUploadUrl } = await import('@/lib/uploads/providers/gcs/client')
+
+ const { url, signedHeaders } = await getGcsPresignedUploadUrl(
+ key,
+ contentType,
+ metadata,
+ createGcsConfig(config),
+ expirationSeconds
+ )
+
+ return {
+ url,
+ key,
+ uploadHeaders: signedHeaders,
+ }
+}
+
/**
* Generate presigned URL for S3
*/
@@ -752,6 +886,11 @@ export async function generatePresignedDownloadUrl(
return getPresignedUrlWithConfig(key, createBlobConfig(config), expirationSeconds)
}
+ if (USE_GCS_STORAGE) {
+ const { getPresignedUrlWithConfig } = await import('@/lib/uploads/providers/gcs/client')
+ return getPresignedUrlWithConfig(key, createGcsConfig(config), expirationSeconds)
+ }
+
const { getBaseUrl } = await import('@/lib/core/utils/urls')
const baseUrl = getBaseUrl()
return `${baseUrl}/api/files/serve/${encodeURIComponent(key)}`
@@ -761,7 +900,7 @@ export async function generatePresignedDownloadUrl(
* Check if cloud storage is available
*/
export function hasCloudStorage(): boolean {
- return USE_BLOB_STORAGE || USE_S3_STORAGE
+ return USE_BLOB_STORAGE || USE_S3_STORAGE || USE_GCS_STORAGE
}
/**
diff --git a/apps/sim/lib/uploads/providers/gcs/client.test.ts b/apps/sim/lib/uploads/providers/gcs/client.test.ts
new file mode 100644
index 00000000000..58cebc3187a
--- /dev/null
+++ b/apps/sim/lib/uploads/providers/gcs/client.test.ts
@@ -0,0 +1,492 @@
+/**
+ * Tests for GCS client functionality
+ *
+ * @vitest-environment node
+ */
+import { Readable } from 'node:stream'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockFile,
+ mockBucket,
+ mockStorageInstance,
+ mockStorageConstructor,
+ mockGetAccessToken,
+ mockEnv,
+ mockGcsConfig,
+} = vi.hoisted(() => {
+ const mockFile = {
+ save: vi.fn(),
+ createReadStream: vi.fn(),
+ getMetadata: vi.fn(),
+ delete: vi.fn(),
+ getSignedUrl: vi.fn(),
+ }
+ const mockBucket = { file: vi.fn(() => mockFile) }
+ const mockGetAccessToken = vi.fn()
+ const mockStorageInstance = {
+ bucket: vi.fn(() => mockBucket),
+ authClient: { getAccessToken: mockGetAccessToken },
+ }
+ const mockEnv: Record = {
+ GCS_BUCKET_NAME: 'test-bucket',
+ }
+ const mockGcsConfig = { bucket: 'test-bucket' }
+ return {
+ mockFile,
+ mockBucket,
+ mockStorageInstance,
+ mockStorageConstructor: vi.fn().mockImplementation(
+ class {
+ constructor() {
+ // biome-ignore lint/correctness/noConstructorReturn: vitest constructs mocks via Reflect.construct; returning the object overrides the instance so `new Storage()` yields the shared mock the tests assert on
+ return mockStorageInstance
+ }
+ }
+ ),
+ mockGetAccessToken,
+ mockEnv,
+ mockGcsConfig,
+ }
+})
+
+vi.mock('@google-cloud/storage', () => ({
+ Storage: mockStorageConstructor,
+}))
+
+vi.mock('@/lib/core/config/env', () => ({
+ env: mockEnv,
+ getEnv: (key: string) => mockEnv[key],
+ isTruthy: (value: string | boolean | number | undefined) =>
+ typeof value === 'string' ? value.toLowerCase() === 'true' || value === '1' : Boolean(value),
+ isFalsy: (value: string | boolean | number | undefined) =>
+ typeof value === 'string' ? value.toLowerCase() === 'false' || value === '0' : value === false,
+}))
+
+vi.mock('@/lib/uploads/config', () => ({
+ GCS_CONFIG: mockGcsConfig,
+}))
+
+import {
+ abortGcsMultipartUpload,
+ completeGcsMultipartUpload,
+ deleteFromGcs,
+ downloadFromGcs,
+ getGcsClient,
+ getGcsMultipartPartUrls,
+ getGcsPresignedUploadUrl,
+ getPresignedUrlWithConfig,
+ headGcsObject,
+ initiateGcsMultipartUpload,
+ resetGcsClientForTesting,
+ uploadGcsPart,
+ uploadToGcs,
+} from '@/lib/uploads/providers/gcs/client'
+
+const mockFetch = vi.fn()
+
+describe('GCS Client', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ vi.stubGlobal('fetch', mockFetch)
+ mockEnv.GCS_PROJECT_ID = undefined
+ mockEnv.GCS_CREDENTIALS_JSON = undefined
+ mockGetAccessToken.mockResolvedValue('test-access-token')
+ resetGcsClientForTesting()
+ })
+
+ afterEach(() => {
+ vi.unstubAllGlobals()
+ vi.restoreAllMocks()
+ })
+
+ describe('getGcsClient', () => {
+ it('should initialize with Application Default Credentials when no inline credentials are set', async () => {
+ const client = await getGcsClient()
+
+ expect(client).toBeDefined()
+ expect(mockStorageConstructor).toHaveBeenCalledWith({})
+ })
+
+ it('should initialize with inline credentials and project id when provided', async () => {
+ mockEnv.GCS_PROJECT_ID = 'my-project'
+ mockEnv.GCS_CREDENTIALS_JSON = JSON.stringify({
+ client_email: 'svc@my-project.iam.gserviceaccount.com',
+ private_key: '-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----\n',
+ })
+
+ await getGcsClient()
+
+ expect(mockStorageConstructor).toHaveBeenCalledWith({
+ projectId: 'my-project',
+ credentials: {
+ client_email: 'svc@my-project.iam.gserviceaccount.com',
+ private_key: '-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----\n',
+ },
+ })
+ })
+
+ it('should fall back to the project id embedded in the credentials JSON', async () => {
+ mockEnv.GCS_CREDENTIALS_JSON = JSON.stringify({
+ client_email: 'svc@my-project.iam.gserviceaccount.com',
+ private_key: 'key',
+ project_id: 'embedded-project',
+ })
+
+ await getGcsClient()
+
+ expect(mockStorageConstructor).toHaveBeenCalledWith(
+ expect.objectContaining({ projectId: 'embedded-project' })
+ )
+ })
+
+ it('should reject invalid credentials JSON', async () => {
+ mockEnv.GCS_CREDENTIALS_JSON = 'not-json'
+
+ await expect(getGcsClient()).rejects.toThrow('GCS_CREDENTIALS_JSON is not valid JSON')
+ })
+
+ it('should reject credentials JSON missing required fields', async () => {
+ mockEnv.GCS_CREDENTIALS_JSON = JSON.stringify({ client_email: 'svc@example.com' })
+
+ await expect(getGcsClient()).rejects.toThrow(
+ 'GCS_CREDENTIALS_JSON must contain client_email and private_key'
+ )
+ })
+
+ it('should cache the client across calls', async () => {
+ await getGcsClient()
+ await getGcsClient()
+
+ expect(mockStorageConstructor).toHaveBeenCalledTimes(1)
+ })
+ })
+
+ describe('uploadToGcs', () => {
+ it('should upload a file to GCS and return file info', async () => {
+ mockFile.save.mockResolvedValueOnce(undefined)
+
+ const file = Buffer.from('test content')
+ const result = await uploadToGcs(file, 'test-file.txt', 'text/plain')
+
+ expect(mockStorageInstance.bucket).toHaveBeenCalledWith('test-bucket')
+ expect(mockBucket.file).toHaveBeenCalledWith(expect.stringContaining('test-file.txt'))
+ expect(mockFile.save).toHaveBeenCalledWith(file, {
+ contentType: 'text/plain',
+ resumable: false,
+ metadata: {
+ metadata: expect.objectContaining({
+ originalName: 'test-file.txt',
+ uploadedAt: expect.any(String),
+ }),
+ },
+ })
+
+ expect(result).toEqual({
+ path: expect.stringContaining('/api/files/serve/'),
+ key: expect.stringContaining('test-file.txt'),
+ name: 'test-file.txt',
+ size: file.length,
+ type: 'text/plain',
+ })
+ })
+
+ it('should preserve the key when preserveKey is set', async () => {
+ mockFile.save.mockResolvedValueOnce(undefined)
+
+ const file = Buffer.from('content')
+ const result = await uploadToGcs(
+ file,
+ 'workspace/ws-1/file.txt',
+ 'text/plain',
+ { bucket: 'custom-bucket' },
+ file.length,
+ true
+ )
+
+ expect(mockStorageInstance.bucket).toHaveBeenCalledWith('custom-bucket')
+ expect(mockBucket.file).toHaveBeenCalledWith('workspace/ws-1/file.txt')
+ expect(result.key).toBe('workspace/ws-1/file.txt')
+ })
+
+ it('should handle upload errors', async () => {
+ mockFile.save.mockRejectedValueOnce(new Error('Upload failed'))
+
+ await expect(uploadToGcs(Buffer.from('x'), 'f.txt', 'text/plain')).rejects.toThrow(
+ 'Upload failed'
+ )
+ })
+ })
+
+ describe('presigned URLs', () => {
+ it('should generate a v4 read signed URL', async () => {
+ mockFile.getSignedUrl.mockResolvedValueOnce(['https://example.com/signed-read'])
+
+ const url = await getPresignedUrlWithConfig('test-file.txt', { bucket: 'custom' }, 1800)
+
+ expect(mockStorageInstance.bucket).toHaveBeenCalledWith('custom')
+ expect(mockFile.getSignedUrl).toHaveBeenCalledWith({
+ version: 'v4',
+ action: 'read',
+ expires: expect.any(Number),
+ })
+ expect(url).toBe('https://example.com/signed-read')
+ })
+
+ it('should generate a v4 write signed URL with signed metadata headers', async () => {
+ mockFile.getSignedUrl.mockResolvedValueOnce(['https://example.com/signed-write'])
+
+ const result = await getGcsPresignedUploadUrl(
+ 'workspace/file.txt',
+ 'text/plain',
+ { originalName: 'file.txt', workspaceId: 'ws-1' },
+ { bucket: 'test-bucket' },
+ 3600
+ )
+
+ expect(mockFile.getSignedUrl).toHaveBeenCalledWith({
+ version: 'v4',
+ action: 'write',
+ expires: expect.any(Number),
+ contentType: 'text/plain',
+ extensionHeaders: expect.objectContaining({
+ 'x-goog-meta-originalName': 'file.txt',
+ 'x-goog-meta-workspaceId': 'ws-1',
+ }),
+ })
+ expect(result.url).toBe('https://example.com/signed-write')
+ expect(result.signedHeaders).toEqual(
+ expect.objectContaining({
+ 'Content-Type': 'text/plain',
+ 'x-goog-meta-workspaceId': 'ws-1',
+ })
+ )
+ })
+ })
+
+ describe('downloadFromGcs', () => {
+ it('should download a file from GCS', async () => {
+ mockFile.createReadStream.mockReturnValueOnce(
+ Readable.from([Buffer.from('chunk1'), Buffer.from('chunk2')])
+ )
+
+ const result = await downloadFromGcs('test-file.txt')
+
+ expect(mockBucket.file).toHaveBeenCalledWith('test-file.txt')
+ expect(result).toBeInstanceOf(Buffer)
+ expect(result.toString()).toBe('chunk1chunk2')
+ })
+
+ it('should reject before streaming when the known size exceeds the limit', async () => {
+ mockFile.getMetadata.mockResolvedValueOnce([{ size: '1024' }])
+
+ await expect(downloadFromGcs('big.bin', { bucket: 'test-bucket' }, 10)).rejects.toThrow(
+ 'storage download exceeds maximum size'
+ )
+ expect(mockFile.createReadStream).not.toHaveBeenCalled()
+ })
+
+ it('should handle download errors', async () => {
+ const failing = new Readable({
+ read() {
+ this.destroy(new Error('Stream error'))
+ },
+ })
+ mockFile.createReadStream.mockReturnValueOnce(failing)
+
+ await expect(downloadFromGcs('test-file.txt')).rejects.toThrow('Stream error')
+ })
+ })
+
+ describe('headGcsObject', () => {
+ it('should return size and content type when the object exists', async () => {
+ mockFile.getMetadata.mockResolvedValueOnce([{ size: '2048', contentType: 'text/csv' }])
+
+ const result = await headGcsObject('data.csv')
+
+ expect(result).toEqual({ size: 2048, contentType: 'text/csv' })
+ })
+
+ it('should return null when the object is missing', async () => {
+ mockFile.getMetadata.mockRejectedValueOnce(Object.assign(new Error('Not Found'), { code: 404 }))
+
+ const result = await headGcsObject('missing.txt')
+
+ expect(result).toBeNull()
+ })
+
+ it('should rethrow non-404 errors', async () => {
+ mockFile.getMetadata.mockRejectedValueOnce(Object.assign(new Error('Forbidden'), { code: 403 }))
+
+ await expect(headGcsObject('secret.txt')).rejects.toThrow('Forbidden')
+ })
+ })
+
+ describe('deleteFromGcs', () => {
+ it('should delete a file, ignoring missing objects', async () => {
+ mockFile.delete.mockResolvedValueOnce(undefined)
+
+ await deleteFromGcs('test-file.txt')
+
+ expect(mockBucket.file).toHaveBeenCalledWith('test-file.txt')
+ expect(mockFile.delete).toHaveBeenCalledWith({ ignoreNotFound: true })
+ })
+
+ it('should handle delete errors', async () => {
+ mockFile.delete.mockRejectedValueOnce(new Error('Delete failed'))
+
+ await expect(deleteFromGcs('test-file.txt')).rejects.toThrow('Delete failed')
+ })
+ })
+
+ describe('multipart uploads (XML API)', () => {
+ it('should initiate a multipart upload and parse the UploadId', async () => {
+ mockFetch.mockResolvedValueOnce(
+ new Response(
+ 'upload-123',
+ { status: 200 }
+ )
+ )
+
+ const result = await initiateGcsMultipartUpload({
+ fileName: 'large.csv',
+ contentType: 'text/csv',
+ fileSize: 100,
+ customKey: 'workspace/ws-1/large.csv',
+ purpose: 'workspace',
+ })
+
+ expect(result).toEqual({ uploadId: 'upload-123', key: 'workspace/ws-1/large.csv' })
+
+ const [url, init] = mockFetch.mock.calls[0]
+ expect(url).toBe('https://storage.googleapis.com/test-bucket/workspace/ws-1/large.csv?uploads')
+ expect(init.method).toBe('POST')
+ expect(init.headers).toEqual(
+ expect.objectContaining({
+ Authorization: 'Bearer test-access-token',
+ 'Content-Type': 'text/csv',
+ 'x-goog-meta-purpose': 'workspace',
+ })
+ )
+ })
+
+ it('should throw when the initiate response has no UploadId', async () => {
+ mockFetch.mockResolvedValueOnce(new Response('', { status: 200 }))
+
+ await expect(
+ initiateGcsMultipartUpload({ fileName: 'x.csv', contentType: 'text/csv', fileSize: 1 })
+ ).rejects.toThrow('no UploadId in response')
+ })
+
+ it('should surface XML API errors with status details', async () => {
+ mockFetch.mockResolvedValueOnce(
+ new Response('denied', {
+ status: 403,
+ statusText: 'Forbidden',
+ })
+ )
+
+ await expect(
+ initiateGcsMultipartUpload({ fileName: 'x.csv', contentType: 'text/csv', fileSize: 1 })
+ ).rejects.toThrow('403 Forbidden')
+ })
+
+ it('should upload a part and return its ETag', async () => {
+ mockFetch.mockResolvedValueOnce(
+ new Response(null, { status: 200, headers: { ETag: '"etag-1"' } })
+ )
+
+ const part = await uploadGcsPart('key.csv', 'upload-123', 1, Buffer.from('data'))
+
+ expect(part).toEqual({ PartNumber: 1, ETag: '"etag-1"' })
+ const [url, init] = mockFetch.mock.calls[0]
+ expect(url).toBe('https://storage.googleapis.com/test-bucket/key.csv?partNumber=1&uploadId=upload-123')
+ expect(init.method).toBe('PUT')
+ })
+
+ it('should throw when a part upload returns no ETag', async () => {
+ mockFetch.mockResolvedValueOnce(new Response(null, { status: 200 }))
+
+ await expect(uploadGcsPart('key.csv', 'upload-123', 1, Buffer.from('data'))).rejects.toThrow(
+ 'no ETag'
+ )
+ })
+
+ it('should generate signed part URLs covering partNumber and uploadId', async () => {
+ mockFile.getSignedUrl
+ .mockResolvedValueOnce(['https://example.com/part-1'])
+ .mockResolvedValueOnce(['https://example.com/part-2'])
+
+ const urls = await getGcsMultipartPartUrls('key.csv', 'upload-123', [1, 2])
+
+ expect(urls).toEqual([
+ { partNumber: 1, url: 'https://example.com/part-1' },
+ { partNumber: 2, url: 'https://example.com/part-2' },
+ ])
+ expect(mockFile.getSignedUrl).toHaveBeenCalledWith({
+ version: 'v4',
+ action: 'write',
+ expires: expect.any(Number),
+ queryParams: { partNumber: '1', uploadId: 'upload-123' },
+ })
+ })
+
+ it('should complete a multipart upload with sorted parts XML', async () => {
+ mockFetch.mockResolvedValueOnce(new Response('', { status: 200 }))
+
+ const result = await completeGcsMultipartUpload('kb/uuid-file.txt', 'upload-123', [
+ { PartNumber: 2, ETag: '"etag-2"' },
+ { PartNumber: 1, ETag: '"etag-1"' },
+ ])
+
+ const [url, init] = mockFetch.mock.calls[0]
+ expect(url).toBe('https://storage.googleapis.com/test-bucket/kb/uuid-file.txt?uploadId=upload-123')
+ expect(init.method).toBe('POST')
+ expect(init.body).toBe(
+ '1"etag-1"2"etag-2"'
+ )
+ expect(result).toEqual({
+ location: 'https://storage.googleapis.com/test-bucket/kb/uuid-file.txt',
+ path: '/api/files/serve/kb%2Fuuid-file.txt',
+ key: 'kb/uuid-file.txt',
+ })
+ })
+
+ it('should restore quotes on ETags stripped by the browser upload client', async () => {
+ mockFetch.mockResolvedValueOnce(new Response('', { status: 200 }))
+
+ await completeGcsMultipartUpload('key.csv', 'upload-123', [
+ { PartNumber: 1, ETag: 'etag-1' },
+ ])
+
+ const [, init] = mockFetch.mock.calls[0]
+ expect(init.body).toBe(
+ '1"etag-1"'
+ )
+ })
+
+ it('should abort a multipart upload via DELETE', async () => {
+ mockFetch.mockResolvedValueOnce(new Response(null, { status: 204 }))
+
+ await abortGcsMultipartUpload('key.csv', 'upload-123')
+
+ const [url, init] = mockFetch.mock.calls[0]
+ expect(url).toBe('https://storage.googleapis.com/test-bucket/key.csv?uploadId=upload-123')
+ expect(init.method).toBe('DELETE')
+ })
+
+ it('should swallow abort errors', async () => {
+ mockFetch.mockResolvedValueOnce(new Response('boom', { status: 500, statusText: 'ISE' }))
+
+ await expect(abortGcsMultipartUpload('key.csv', 'upload-123')).resolves.toBeUndefined()
+ })
+
+ it('should fail multipart calls when no access token is available', async () => {
+ mockGetAccessToken.mockResolvedValueOnce(null)
+
+ await expect(
+ initiateGcsMultipartUpload({ fileName: 'x.csv', contentType: 'text/csv', fileSize: 1 })
+ ).rejects.toThrow('Failed to obtain a Google Cloud access token')
+ })
+ })
+})
diff --git a/apps/sim/lib/uploads/providers/gcs/client.ts b/apps/sim/lib/uploads/providers/gcs/client.ts
new file mode 100644
index 00000000000..27b248e01fd
--- /dev/null
+++ b/apps/sim/lib/uploads/providers/gcs/client.ts
@@ -0,0 +1,586 @@
+import type { Readable } from 'node:stream'
+import type { Storage } from '@google-cloud/storage'
+import { createLogger } from '@sim/logger'
+import { generateId } from '@sim/utils/id'
+import { env } from '@/lib/core/config/env'
+import {
+ assertKnownSizeWithinLimit,
+ readNodeStreamToBufferWithLimit,
+} from '@/lib/core/utils/stream-limits'
+import { GCS_CONFIG } from '@/lib/uploads/config'
+import type {
+ GcsConfig,
+ GcsMultipartPart,
+ GcsMultipartUploadInit,
+ GcsPartUploadUrl,
+} from '@/lib/uploads/providers/gcs/types'
+import type { FileInfo } from '@/lib/uploads/shared/types'
+import {
+ sanitizeFilenameForMetadata,
+ sanitizeStorageMetadata,
+} from '@/lib/uploads/utils/file-utils'
+import { sanitizeFileName } from '@/executor/constants'
+
+const logger = createLogger('GcsClient')
+
+/** XML API host, also used to build canonical object URLs. */
+const GCS_XML_API_HOST = 'https://storage.googleapis.com'
+
+let _gcsClient: Storage | null = null
+
+/**
+ * Reset the cached GCS client. Only intended for use in tests.
+ */
+export function resetGcsClientForTesting(): void {
+ _gcsClient = null
+}
+
+interface GcsInlineCredentials {
+ client_email: string
+ private_key: string
+ project_id?: string
+}
+
+/**
+ * Parse the inline service-account JSON from `GCS_CREDENTIALS_JSON`.
+ * Returns null when the variable is unset (Application Default Credentials).
+ * @throws Error when the variable is set but not valid service-account JSON
+ */
+export function parseGcsCredentials(): GcsInlineCredentials | null {
+ if (!env.GCS_CREDENTIALS_JSON) return null
+
+ let parsed: unknown
+ try {
+ parsed = JSON.parse(env.GCS_CREDENTIALS_JSON)
+ } catch {
+ throw new Error('GCS_CREDENTIALS_JSON is not valid JSON')
+ }
+
+ const credentials = parsed as Partial
+ if (!credentials.client_email || !credentials.private_key) {
+ throw new Error('GCS_CREDENTIALS_JSON must contain client_email and private_key')
+ }
+
+ return credentials as GcsInlineCredentials
+}
+
+export async function getGcsClient(): Promise {
+ if (_gcsClient) return _gcsClient
+
+ const { Storage } = await import('@google-cloud/storage')
+ const credentials = parseGcsCredentials()
+
+ _gcsClient = new Storage({
+ ...(env.GCS_PROJECT_ID || credentials?.project_id
+ ? { projectId: env.GCS_PROJECT_ID || credentials?.project_id }
+ : {}),
+ ...(credentials
+ ? { credentials: { client_email: credentials.client_email, private_key: credentials.private_key } }
+ : {}),
+ })
+
+ return _gcsClient
+}
+
+/**
+ * Get an OAuth2 bearer token for authenticated XML API requests
+ * (multipart initiate/part/complete/abort have no JSON API equivalent).
+ */
+async function getGcsAccessToken(): Promise {
+ const storage = await getGcsClient()
+ const token = await storage.authClient.getAccessToken()
+ if (!token) {
+ throw new Error(
+ 'Failed to obtain a Google Cloud access token – check GCS_CREDENTIALS_JSON or Application Default Credentials.'
+ )
+ }
+ return token
+}
+
+/** Percent-encode an object key per path segment, preserving `/` separators. */
+function encodeObjectKey(key: string): string {
+ return key
+ .split('/')
+ .map((segment) => encodeURIComponent(segment))
+ .join('/')
+}
+
+/** Canonical public-style URL for an object (not signed — used as a location reference). */
+function buildGcsObjectUrl(bucket: string, key: string): string {
+ return `${GCS_XML_API_HOST}/${bucket}/${encodeObjectKey(key)}`
+}
+
+/**
+ * Upload a file to Google Cloud Storage
+ * @param file Buffer containing file data
+ * @param fileName Original file name
+ * @param contentType MIME type of the file
+ * @param configOrSize Custom GCS configuration OR file size in bytes (optional)
+ * @param size File size in bytes (required if configOrSize is GcsConfig, optional otherwise)
+ * @param preserveKey Preserve the fileName as the storage key without adding timestamp prefix (default: false)
+ * @param metadata Optional metadata to store with the file
+ * @returns Object with file information
+ */
+export async function uploadToGcs(
+ file: Buffer,
+ fileName: string,
+ contentType: string,
+ configOrSize?: GcsConfig | number,
+ size?: number,
+ preserveKey?: boolean,
+ metadata?: Record
+): Promise {
+ let config: GcsConfig
+ let fileSize: number
+ let shouldPreserveKey: boolean
+
+ if (typeof configOrSize === 'object') {
+ config = configOrSize
+ fileSize = size ?? file.length
+ shouldPreserveKey = preserveKey ?? false
+ } else {
+ config = { bucket: GCS_CONFIG.bucket }
+ fileSize = configOrSize ?? file.length
+ shouldPreserveKey = preserveKey ?? false
+ }
+
+ const safeFileName = sanitizeFileName(fileName)
+ const uniqueKey = shouldPreserveKey ? fileName : `${Date.now()}-${safeFileName}`
+
+ const storage = await getGcsClient()
+
+ const gcsMetadata: Record = {
+ originalName: sanitizeFilenameForMetadata(fileName),
+ uploadedAt: new Date().toISOString(),
+ }
+
+ if (metadata) {
+ Object.assign(gcsMetadata, sanitizeStorageMetadata(metadata, 8000))
+ }
+
+ await storage.bucket(config.bucket).file(uniqueKey).save(file, {
+ contentType,
+ resumable: false,
+ metadata: { metadata: gcsMetadata },
+ })
+
+ const servePath = `/api/files/serve/${encodeURIComponent(uniqueKey)}`
+
+ return {
+ path: servePath,
+ key: uniqueKey,
+ name: fileName,
+ size: fileSize,
+ type: contentType,
+ }
+}
+
+/**
+ * Generate a presigned URL for direct file access
+ * @param key GCS object key
+ * @param expiresIn Time in seconds until URL expires
+ * @returns Presigned URL
+ */
+export async function getPresignedUrl(key: string, expiresIn = 3600) {
+ return getPresignedUrlWithConfig(key, { bucket: GCS_CONFIG.bucket }, expiresIn)
+}
+
+/**
+ * Generate a presigned URL for direct file access with custom bucket
+ * @param key GCS object key
+ * @param customConfig Custom GCS configuration
+ * @param expiresIn Time in seconds until URL expires
+ * @returns Presigned URL
+ */
+export async function getPresignedUrlWithConfig(
+ key: string,
+ customConfig: GcsConfig,
+ expiresIn = 3600
+) {
+ const storage = await getGcsClient()
+ const [url] = await storage
+ .bucket(customConfig.bucket)
+ .file(key)
+ .getSignedUrl({
+ version: 'v4',
+ action: 'read',
+ expires: Date.now() + expiresIn * 1000,
+ })
+ return url
+}
+
+/**
+ * Generate a presigned URL for direct file upload. `signedHeaders` must be sent
+ * verbatim by the uploader — they are covered by the V4 signature.
+ */
+export async function getGcsPresignedUploadUrl(
+ key: string,
+ contentType: string,
+ metadata: Record,
+ customConfig: GcsConfig,
+ expirationSeconds: number
+): Promise<{ url: string; signedHeaders: Record }> {
+ const storage = await getGcsClient()
+
+ const sanitizedMetadata = sanitizeStorageMetadata(metadata, 8000)
+ if (sanitizedMetadata.originalName) {
+ sanitizedMetadata.originalName = sanitizeFilenameForMetadata(sanitizedMetadata.originalName)
+ }
+
+ const metadataHeaders = Object.entries(sanitizedMetadata).reduce(
+ (acc, [k, v]) => {
+ acc[`x-goog-meta-${k}`] = encodeURIComponent(v)
+ return acc
+ },
+ {} as Record
+ )
+
+ const [url] = await storage
+ .bucket(customConfig.bucket)
+ .file(key)
+ .getSignedUrl({
+ version: 'v4',
+ action: 'write',
+ expires: Date.now() + expirationSeconds * 1000,
+ contentType,
+ extensionHeaders: metadataHeaders,
+ })
+
+ return {
+ url,
+ signedHeaders: {
+ 'Content-Type': contentType,
+ ...metadataHeaders,
+ },
+ }
+}
+
+/**
+ * Download a file from Google Cloud Storage
+ * @param key GCS object key
+ * @returns File buffer
+ */
+export async function downloadFromGcs(key: string): Promise
+
+/**
+ * Download a file from Google Cloud Storage with custom bucket configuration
+ * @param key GCS object key
+ * @param customConfig Custom GCS configuration
+ * @returns File buffer
+ */
+export async function downloadFromGcs(key: string, customConfig: GcsConfig): Promise
+
+export async function downloadFromGcs(
+ key: string,
+ customConfig: GcsConfig,
+ maxBytes: number
+): Promise
+
+export async function downloadFromGcs(
+ key: string,
+ customConfig?: GcsConfig,
+ maxBytes?: number
+): Promise {
+ const config = customConfig || { bucket: GCS_CONFIG.bucket }
+ const storage = await getGcsClient()
+ const file = storage.bucket(config.bucket).file(key)
+
+ if (maxBytes !== undefined) {
+ const [fileMetadata] = await file.getMetadata()
+ const knownSize = Number(fileMetadata.size)
+ if (Number.isFinite(knownSize)) {
+ assertKnownSizeWithinLimit(knownSize, maxBytes, 'storage download')
+ }
+ }
+
+ return readNodeStreamToBufferWithLimit(file.createReadStream(), {
+ maxBytes: maxBytes ?? Number.MAX_SAFE_INTEGER,
+ label: 'storage download',
+ })
+}
+
+/**
+ * Stream an object out of GCS without buffering it. The caller MUST fully consume or
+ * `destroy()` the returned stream. Used by the large-CSV import worker so a 1M-row file is
+ * never resident in memory.
+ */
+export async function downloadFromGcsStream(
+ key: string,
+ customConfig?: GcsConfig
+): Promise {
+ const config = customConfig || { bucket: GCS_CONFIG.bucket }
+ const storage = await getGcsClient()
+ return storage.bucket(config.bucket).file(key).createReadStream()
+}
+
+/**
+ * Check whether an object exists in GCS (and return its size when it does).
+ * Returns null when the object is missing.
+ */
+export async function headGcsObject(
+ key: string,
+ customConfig?: GcsConfig
+): Promise<{ size: number; contentType?: string } | null> {
+ const config = customConfig || { bucket: GCS_CONFIG.bucket }
+ const storage = await getGcsClient()
+
+ try {
+ const [fileMetadata] = await storage.bucket(config.bucket).file(key).getMetadata()
+ return {
+ size: Number(fileMetadata.size) || 0,
+ contentType: fileMetadata.contentType,
+ }
+ } catch (error) {
+ const code = (error as { code?: number } | null)?.code
+ if (code === 404) {
+ return null
+ }
+ throw error
+ }
+}
+
+/**
+ * Get the custom metadata stored on a GCS object.
+ */
+export async function getGcsObjectMetadata(
+ key: string,
+ customConfig?: GcsConfig
+): Promise> {
+ const config = customConfig || { bucket: GCS_CONFIG.bucket }
+ const storage = await getGcsClient()
+ const [fileMetadata] = await storage.bucket(config.bucket).file(key).getMetadata()
+ return (fileMetadata.metadata as Record | undefined) || {}
+}
+
+/**
+ * Delete a file from Google Cloud Storage
+ * @param key GCS object key
+ */
+export async function deleteFromGcs(key: string): Promise
+
+/**
+ * Delete a file from Google Cloud Storage with custom bucket configuration
+ * @param key GCS object key
+ * @param customConfig Custom GCS configuration
+ */
+export async function deleteFromGcs(key: string, customConfig: GcsConfig): Promise
+
+export async function deleteFromGcs(key: string, customConfig?: GcsConfig): Promise {
+ const config = customConfig || { bucket: GCS_CONFIG.bucket }
+ const storage = await getGcsClient()
+ await storage.bucket(config.bucket).file(key).delete({ ignoreNotFound: true })
+}
+
+/**
+ * Normalize an ETag to the quoted form GCS expects in CompleteMultipartUpload.
+ * The shared browser upload client strips quotes from part ETags (S3 tolerates
+ * either form), so quotes are restored here before building the completion XML.
+ */
+function normalizeEtag(etag: string): string {
+ return etag.startsWith('"') ? etag : `"${etag}"`
+}
+
+/** Escape a value for embedding in an XML text node. */
+function escapeXml(value: string): string {
+ return value
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+}
+
+/**
+ * Perform an authenticated request against the GCS XML API. Multipart uploads
+ * (initiate/part/complete/abort) exist only on the XML API — the JSON API has
+ * no equivalent — so these calls go through fetch with a bearer token instead
+ * of the SDK.
+ */
+async function gcsXmlApiRequest(
+ method: 'POST' | 'PUT' | 'DELETE',
+ bucket: string,
+ key: string,
+ query: string,
+ options?: { headers?: Record; body?: Buffer | string }
+): Promise {
+ const token = await getGcsAccessToken()
+ const url = `${buildGcsObjectUrl(bucket, key)}?${query}`
+
+ const response = await fetch(url, {
+ method,
+ headers: {
+ Authorization: `Bearer ${token}`,
+ ...options?.headers,
+ },
+ // Buffer is a valid BodyInit at runtime; undici's types only admit ArrayBufferView
+ body: options?.body as BodyInit | undefined,
+ })
+
+ if (!response.ok) {
+ const errorBody = await response.text().catch(() => '')
+ throw new Error(
+ `GCS XML API ${method} ${query.split('&')[0]} failed for ${key}: ${response.status} ${response.statusText}${errorBody ? ` – ${errorBody.slice(0, 500)}` : ''}`
+ )
+ }
+
+ return response
+}
+
+/**
+ * Initiate a multipart upload for GCS (XML API, S3-compatible semantics)
+ */
+export async function initiateGcsMultipartUpload(
+ options: GcsMultipartUploadInit
+): Promise<{ uploadId: string; key: string }> {
+ const { fileName, contentType, customConfig, customKey, purpose } = options
+
+ const config = customConfig || { bucket: GCS_CONFIG.bucket }
+
+ const safeFileName = sanitizeFileName(fileName)
+ const uniqueKey = customKey || `kb/${generateId()}-${safeFileName}`
+
+ const response = await gcsXmlApiRequest('POST', config.bucket, uniqueKey, 'uploads', {
+ headers: {
+ 'Content-Type': contentType,
+ 'x-goog-meta-originalname': encodeURIComponent(sanitizeFilenameForMetadata(fileName)),
+ 'x-goog-meta-uploadedat': new Date().toISOString(),
+ 'x-goog-meta-purpose': purpose || 'knowledge-base',
+ },
+ })
+
+ const xml = await response.text()
+ const uploadIdMatch = xml.match(/([^<]+)<\/UploadId>/)
+ if (!uploadIdMatch) {
+ throw new Error('Failed to initiate GCS multipart upload: no UploadId in response')
+ }
+
+ return {
+ uploadId: uploadIdMatch[1],
+ key: uniqueKey,
+ }
+}
+
+/**
+ * Upload a single multipart part from the server (Body in hand), returning its
+ * `{ PartNumber, ETag }`. The presigned variant ({@link getGcsMultipartPartUrls})
+ * is for browser uploads; this is the server-side streaming path.
+ */
+export async function uploadGcsPart(
+ key: string,
+ uploadId: string,
+ partNumber: number,
+ body: Buffer,
+ customConfig?: GcsConfig
+): Promise {
+ const config = customConfig || { bucket: GCS_CONFIG.bucket }
+ const response = await gcsXmlApiRequest(
+ 'PUT',
+ config.bucket,
+ key,
+ `partNumber=${partNumber}&uploadId=${encodeURIComponent(uploadId)}`,
+ { body }
+ )
+
+ const etag = response.headers.get('etag')
+ if (!etag) {
+ throw new Error(`GCS part upload returned no ETag for part ${partNumber} of ${key}`)
+ }
+ return { PartNumber: partNumber, ETag: etag }
+}
+
+/**
+ * Generate presigned URLs for uploading parts to GCS. The URLs sign the
+ * `partNumber`/`uploadId` query parameters (V4), matching the S3 flow —
+ * the browser PUTs each chunk and collects the returned ETags.
+ */
+export async function getGcsMultipartPartUrls(
+ key: string,
+ uploadId: string,
+ partNumbers: number[],
+ customConfig?: GcsConfig
+): Promise {
+ const config = customConfig || { bucket: GCS_CONFIG.bucket }
+ const storage = await getGcsClient()
+ const file = storage.bucket(config.bucket).file(key)
+
+ return Promise.all(
+ partNumbers.map(async (partNumber) => {
+ const [url] = await file.getSignedUrl({
+ version: 'v4',
+ action: 'write',
+ expires: Date.now() + 3600 * 1000,
+ queryParams: {
+ partNumber: String(partNumber),
+ uploadId,
+ },
+ })
+ return { partNumber, url }
+ })
+ )
+}
+
+/**
+ * Complete multipart upload for GCS
+ */
+export async function completeGcsMultipartUpload(
+ key: string,
+ uploadId: string,
+ parts: GcsMultipartPart[],
+ customConfig?: GcsConfig
+): Promise<{ location: string; path: string; key: string }> {
+ const config = customConfig || { bucket: GCS_CONFIG.bucket }
+
+ const sortedParts = [...parts].sort((a, b) => a.PartNumber - b.PartNumber)
+ const partsXml = sortedParts
+ .map(
+ (part) =>
+ `${part.PartNumber}${escapeXml(normalizeEtag(part.ETag))}`
+ )
+ .join('')
+ const body = `${partsXml}`
+
+ const response = await gcsXmlApiRequest(
+ 'POST',
+ config.bucket,
+ key,
+ `uploadId=${encodeURIComponent(uploadId)}`,
+ {
+ headers: { 'Content-Type': 'application/xml' },
+ body,
+ }
+ )
+
+ // The S3 XML dialect permits a 200 response carrying an error document; GCS
+ // does not document that behavior, but checking is cheap insurance against
+ // reporting a failed completion as success.
+ const responseXml = await response.text()
+ if (responseXml.includes(' {
+ const config = customConfig || { bucket: GCS_CONFIG.bucket }
+ try {
+ await gcsXmlApiRequest(
+ 'DELETE',
+ config.bucket,
+ key,
+ `uploadId=${encodeURIComponent(uploadId)}`
+ )
+ } catch (error) {
+ logger.warn('Error cleaning up GCS multipart upload:', error)
+ }
+}
diff --git a/apps/sim/lib/uploads/providers/gcs/types.ts b/apps/sim/lib/uploads/providers/gcs/types.ts
new file mode 100644
index 00000000000..4a54bf3c1d3
--- /dev/null
+++ b/apps/sim/lib/uploads/providers/gcs/types.ts
@@ -0,0 +1,30 @@
+export interface GcsConfig {
+ bucket: string
+}
+
+export interface GcsMultipartUploadInit {
+ fileName: string
+ contentType: string
+ fileSize: number
+ customConfig?: GcsConfig
+ /**
+ * When provided, overrides the default `kb/${id}-${name}` key derivation.
+ * Caller is responsible for uniqueness and prefix conventions.
+ */
+ customKey?: string
+ /**
+ * Storage purpose tag persisted as object metadata. Defaults to `knowledge-base`
+ * for backwards compatibility.
+ */
+ purpose?: string
+}
+
+export interface GcsPartUploadUrl {
+ partNumber: number
+ url: string
+}
+
+export interface GcsMultipartPart {
+ ETag: string
+ PartNumber: number
+}
diff --git a/apps/sim/lib/uploads/utils/embedded-image-ref.test.ts b/apps/sim/lib/uploads/utils/embedded-image-ref.test.ts
index c8773883b70..bec0f9e936b 100644
--- a/apps/sim/lib/uploads/utils/embedded-image-ref.test.ts
+++ b/apps/sim/lib/uploads/utils/embedded-image-ref.test.ts
@@ -14,6 +14,7 @@ describe('extractEmbeddedFileRef', () => {
})
expect(extractEmbeddedFileRef(`/api/files/serve/s3/${ENCODED}`)).toEqual({ key: KEY })
expect(extractEmbeddedFileRef(`/api/files/serve/blob/${ENCODED}`)).toEqual({ key: KEY })
+ expect(extractEmbeddedFileRef(`/api/files/serve/gcs/${ENCODED}`)).toEqual({ key: KEY })
})
it('parses view-url and in-app-path embeds to the file id', () => {
diff --git a/apps/sim/lib/uploads/utils/embedded-image-ref.ts b/apps/sim/lib/uploads/utils/embedded-image-ref.ts
index 0161af11ea7..7e780362ea5 100644
--- a/apps/sim/lib/uploads/utils/embedded-image-ref.ts
+++ b/apps/sim/lib/uploads/utils/embedded-image-ref.ts
@@ -24,7 +24,7 @@ const EMBED_URL_RE =
/**
* 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/` prefixes), `/api/files/view/`,
+ * 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.
*/
@@ -35,7 +35,9 @@ export function extractEmbeddedFileRef(src: string): EmbeddedFileRef {
const segs = parsed.pathname.split('/')
if (segs[1] === 'api' && segs[2] === 'files' && segs[3] === 'serve') {
let keySegs = segs.slice(4)
- if (keySegs[0] === 's3' || keySegs[0] === 'blob') keySegs = keySegs.slice(1)
+ if (keySegs[0] === 's3' || keySegs[0] === 'blob' || keySegs[0] === 'gcs') {
+ keySegs = keySegs.slice(1)
+ }
const raw = keySegs.join('/')
if (!raw) return null
const key = decodeURIComponent(raw)
diff --git a/apps/sim/lib/uploads/utils/file-utils.test.ts b/apps/sim/lib/uploads/utils/file-utils.test.ts
index 0e7581bb454..7c9e62d9504 100644
--- a/apps/sim/lib/uploads/utils/file-utils.test.ts
+++ b/apps/sim/lib/uploads/utils/file-utils.test.ts
@@ -4,6 +4,7 @@
import { createLogger } from '@sim/logger'
import { describe, expect, it } from 'vitest'
import {
+ extractStorageKey,
inferContextFromKey,
isAbortError,
isInternalFileUrl,
@@ -14,6 +15,24 @@ import {
const logger = createLogger('FileUtilsTest')
+describe('extractStorageKey', () => {
+ it('strips every provider serve prefix', () => {
+ expect(extractStorageKey('/api/files/serve/s3/workspace%2Fws-1%2Ffile.txt')).toBe(
+ 'workspace/ws-1/file.txt'
+ )
+ expect(extractStorageKey('/api/files/serve/blob/workspace%2Fws-1%2Ffile.txt')).toBe(
+ 'workspace/ws-1/file.txt'
+ )
+ expect(extractStorageKey('/api/files/serve/gcs/workspace%2Fws-1%2Ffile.txt')).toBe(
+ 'workspace/ws-1/file.txt'
+ )
+ })
+
+ it('returns unprefixed serve keys as-is', () => {
+ expect(extractStorageKey('/api/files/serve/kb/123-doc.pdf')).toBe('kb/123-doc.pdf')
+ })
+})
+
describe('isInternalFileUrl', () => {
it('classifies relative serve paths as internal', () => {
expect(isInternalFileUrl('/api/files/serve/kb/123-file.pdf')).toBe(true)
diff --git a/apps/sim/lib/uploads/utils/file-utils.ts b/apps/sim/lib/uploads/utils/file-utils.ts
index 642e48aafd2..2e0f0d57cb2 100644
--- a/apps/sim/lib/uploads/utils/file-utils.ts
+++ b/apps/sim/lib/uploads/utils/file-utils.ts
@@ -532,6 +532,8 @@ export function extractStorageKey(filePath: string): string {
key = key.substring(3)
} else if (key.startsWith('blob/')) {
key = key.substring(5)
+ } else if (key.startsWith('gcs/')) {
+ key = key.substring(4)
}
return key
}
diff --git a/apps/sim/package.json b/apps/sim/package.json
index 398d3d03e04..a7800a9f904 100644
--- a/apps/sim/package.json
+++ b/apps/sim/package.json
@@ -67,6 +67,7 @@
"@e2b/code-interpreter": "^2.0.0",
"@earendil-works/pi-coding-agent": "0.79.4",
"@floating-ui/dom": "1.7.6",
+ "@google-cloud/storage": "7.21.0",
"@google/genai": "1.34.0",
"@hookform/resolvers": "5.2.2",
"@linear/sdk": "40.0.0",
diff --git a/bun.lock b/bun.lock
index 8e98e377aa4..7e81dd4365f 100644
--- a/bun.lock
+++ b/bun.lock
@@ -1,5 +1,6 @@
{
"lockfileVersion": 1,
+ "configVersion": 0,
"workspaces": {
"": {
"name": "simstudio",
@@ -135,6 +136,7 @@
"@e2b/code-interpreter": "^2.0.0",
"@earendil-works/pi-coding-agent": "0.79.4",
"@floating-ui/dom": "1.7.6",
+ "@google-cloud/storage": "7.21.0",
"@google/genai": "1.34.0",
"@hookform/resolvers": "5.2.2",
"@linear/sdk": "40.0.0",
@@ -1034,8 +1036,16 @@
"@fumari/stf": ["@fumari/stf@1.0.5", "", { "peerDependencies": { "@types/react": "*", "react": "^19.2.0", "react-dom": "^19.2.0" }, "optionalPeers": ["@types/react"] }, "sha512-O9UQIbV15ePV5vUgceCcaMDuBRYfrSuogDEY5E//CmrbIiWJ1Ji5VgGHHH0L0HQuRr5riUJuAEr9lYIvJl9OyQ=="],
+ "@google-cloud/paginator": ["@google-cloud/paginator@5.0.2", "", { "dependencies": { "arrify": "^2.0.0", "extend": "^3.0.2" } }, "sha512-DJS3s0OVH4zFDB1PzjxAsHqJT6sKVbRwwML0ZBP9PbU7Yebtu/7SWMRzvO2J3nUi9pRNITCfu4LJeooM2w4pjg=="],
+
"@google-cloud/precise-date": ["@google-cloud/precise-date@4.0.0", "", {}, "sha512-1TUx3KdaU3cN7nfCdNf+UVqA/PSX29Cjcox3fZZBtINlRrXVTmUkQnCKv2MbBUbCopbK4olAT1IHl76uZyCiVA=="],
+ "@google-cloud/projectify": ["@google-cloud/projectify@4.0.0", "", {}, "sha512-MmaX6HeSvyPbWGwFq7mXdo0uQZLGBYCwziiLIGq5JVX+/bdI3SAq6bP98trV5eTWfLuvsMcIC1YJOF2vfteLFA=="],
+
+ "@google-cloud/promisify": ["@google-cloud/promisify@4.0.0", "", {}, "sha512-Orxzlfb9c67A15cq2JQEyVc7wEsmFBmHjZWZYQMUyJ1qivXyMwdyNOs9odi79hze+2zqdTtu1E19IM/FtqZ10g=="],
+
+ "@google-cloud/storage": ["@google-cloud/storage@7.21.0", "", { "dependencies": { "@google-cloud/paginator": "^5.0.0", "@google-cloud/projectify": "^4.0.0", "@google-cloud/promisify": "<4.1.0", "abort-controller": "^3.0.0", "async-retry": "^1.3.3", "duplexify": "^4.1.3", "fast-xml-parser": "^5.3.4", "gaxios": "^6.0.2", "google-auth-library": "^9.6.3", "html-entities": "^2.5.2", "mime": "^3.0.0", "p-limit": "^3.0.1", "retry-request": "^7.0.0", "teeny-request": "^9.0.0" } }, "sha512-l+IFTkd+6Y5LoAuXyYCKNAKtw/Ci+rAMqgdTB1jv4iZiLhw0rtq+0qjIRbBizXkNzEFmXiXUW0H7sZQQvk1ffA=="],
+
"@google/genai": ["@google/genai@1.34.0", "", { "dependencies": { "google-auth-library": "^10.3.0", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.24.0" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-vu53UMPvjmb7PGzlYu6Tzxso8Dfhn+a7eQFaS2uNemVtDZKwzSpJ5+ikqBbXplF7RGB1STcVDqCkPvquiwb2sw=="],
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
@@ -1804,6 +1814,8 @@
"@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="],
+ "@tootallnate/once": ["@tootallnate/once@2.0.1", "", {}, "sha512-HqmEUIGRJ5fSXchkVgR5F7qn48bDBzv0kWj/Kfu5e6uci4UlEeng4331LnBkWffb++Ei3FOVLxo8JJWMFBDMeQ=="],
+
"@trigger.dev/build": ["@trigger.dev/build@4.4.3", "", { "dependencies": { "@prisma/config": "^6.10.0", "@trigger.dev/core": "4.4.3", "mlly": "^1.7.1", "pkg-types": "^1.1.3", "resolve": "^1.22.8", "tinyglobby": "^0.2.2", "tsconfck": "3.1.3" } }, "sha512-t/hYmQiv2SdrUao9scoczrvfhyzSLkuT8DNyiBt9q29GKct37zytWyAo16hpN2Uf+yXh0EkdnkHbfR9odF0YtQ=="],
"@trigger.dev/core": ["@trigger.dev/core@4.4.3", "", { "dependencies": { "@bugsnag/cuid": "^3.1.1", "@electric-sql/client": "1.0.14", "@google-cloud/precise-date": "^4.0.0", "@jsonhero/path": "^1.0.21", "@opentelemetry/api": "1.9.0", "@opentelemetry/api-logs": "0.203.0", "@opentelemetry/core": "2.0.1", "@opentelemetry/exporter-logs-otlp-http": "0.203.0", "@opentelemetry/exporter-metrics-otlp-http": "0.203.0", "@opentelemetry/exporter-trace-otlp-http": "0.203.0", "@opentelemetry/host-metrics": "^0.37.0", "@opentelemetry/instrumentation": "0.203.0", "@opentelemetry/resources": "2.0.1", "@opentelemetry/sdk-logs": "0.203.0", "@opentelemetry/sdk-metrics": "2.0.1", "@opentelemetry/sdk-trace-base": "2.0.1", "@opentelemetry/sdk-trace-node": "2.0.1", "@opentelemetry/semantic-conventions": "1.36.0", "@s2-dev/streamstore": "0.22.5", "dequal": "^2.0.3", "eventsource": "^3.0.5", "eventsource-parser": "^3.0.0", "execa": "^8.0.1", "humanize-duration": "^3.27.3", "jose": "^5.4.0", "nanoid": "3.3.8", "prom-client": "^15.1.0", "socket.io": "4.7.4", "socket.io-client": "4.7.5", "std-env": "^3.8.1", "tinyexec": "^0.3.2", "uncrypto": "^0.1.3", "zod": "3.25.76", "zod-error": "1.5.0", "zod-validation-error": "^1.5.0" } }, "sha512-4srm2UGoDEcHO29Lqp4Isioq+b6au0EjW9/pjYmzOSxXqGPFDjPquK0BnKYGHyAbKYxuBx8wr2T/ru+zbY0/Jg=="],
@@ -1838,6 +1850,8 @@
"@types/busboy": ["@types/busboy@1.5.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-kG7WrUuAKK0NoyxfQHsVE6j1m01s6kMma64E+OZenQABMQyTJop1DumUWcLwAQ2JzpefU7PDYoRDKl8uZosFjw=="],
+ "@types/caseless": ["@types/caseless@0.12.5", "", {}, "sha512-hWtVTC2q7hc7xZ/RLbxapMvDMgUnDvKvMOpKal4DrMyfGBUfB1oKaZlIRr6mJL+If3bAP6sV/QneGzF6tJjZDg=="],
+
"@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="],
"@types/cookie": ["@types/cookie@0.4.1", "", {}, "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q=="],
@@ -1952,6 +1966,8 @@
"@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="],
+ "@types/request": ["@types/request@2.48.13", "", { "dependencies": { "@types/caseless": "*", "@types/node": "*", "@types/tough-cookie": "*", "form-data": "^2.5.5" } }, "sha512-FGJ6udDNUCjd19pp0Q3iTiDkwhYup7J8hpMW9c4k53NrccQFFWKRho6hvtPPEhnXWKvukfwAlB6DbDz4yhH5Gg=="],
+
"@types/retry": ["@types/retry@0.12.0", "", {}, "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA=="],
"@types/ssh2": ["@types/ssh2@1.15.5", "", { "dependencies": { "@types/node": "^18.11.18" } }, "sha512-N1ASjp/nXH3ovBHddRJpli4ozpk6UdDYIX4RJWFa9L1YKnzdhTlVmiGHm4DZnj/jLbqZpes4aeR30EFGQtvhQQ=="],
@@ -2118,6 +2134,8 @@
"arktype": ["arktype@2.2.0", "", { "dependencies": { "@ark/schema": "0.56.0", "@ark/util": "0.56.0", "arkregex": "0.0.5" } }, "sha512-t54MZ7ti5BhOEvzEkgKnWvqj+UbDfWig+DHr5I34xatymPusKLS0lQpNJd8M6DzmIto2QGszHfNKoFIT8tMCZQ=="],
+ "arrify": ["arrify@2.0.1", "", {}, "sha512-3duEwti880xqi4eAMN8AyR4a0ByT90zoYdLlevfrvU43vb0YZwZVfxOgxWrLXXXpyugL0hNZc9G6BiB5B3nUug=="],
+
"asn1": ["asn1@0.2.6", "", { "dependencies": { "safer-buffer": "~2.1.0" } }, "sha512-ix/FxPn0MDjeyJ7i/yoHGFt/EX6LyNbxSEhPPXODPL+KB0VPk86UYfL0lMdy+KCnv+fmvIzySwaK5COwqVbWTQ=="],
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
@@ -2128,6 +2146,8 @@
"async": ["async@0.2.10", "", {}, "sha512-eAkdoKxU6/LkKDBzLpT+t6Ff5EtfSF4wx1WfJiPEEV7WNLnDaRXk0oVysiEPm262roaachGexwUv94WhSgN5TQ=="],
+ "async-retry": ["async-retry@1.3.3", "", { "dependencies": { "retry": "0.13.1" } }, "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw=="],
+
"asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="],
"atomic-sleep": ["atomic-sleep@1.0.0", "", {}, "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ=="],
@@ -2506,6 +2526,8 @@
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
+ "duplexify": ["duplexify@4.1.3", "", { "dependencies": { "end-of-stream": "^1.4.1", "inherits": "^2.0.3", "readable-stream": "^3.1.1", "stream-shift": "^1.0.2" } }, "sha512-M3BmBhwJRZsSx38lZyhE53Csddgzl5R7xGJNk7CVddZD6CcmwMCH8J+7AprIrQKH7TonKxaCjcv27Qmf+sQ+oA=="],
+
"e2b": ["e2b@2.30.0", "", { "dependencies": { "@bufbuild/protobuf": "^2.6.2", "@connectrpc/connect": "2.0.0-rc.3", "@connectrpc/connect-web": "2.0.0-rc.3", "chalk": "^5.3.0", "compare-versions": "^6.1.0", "dockerfile-ast": "^0.7.1", "glob": "^11.1.0", "openapi-fetch": "^0.14.1", "platform": "^1.3.6", "tar": "^7.5.11", "undici": "^7.25.0" } }, "sha512-4kvfwh3QfPukrYmWEhrVLxL3WnQabzHabvhIRmvk6oU/YTWQtCrlZX+jaA9XBtVI/vQUbu5E5a6GlOhDXmcKzg=="],
"eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
@@ -2700,7 +2722,7 @@
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
- "gaxios": ["gaxios@7.1.5", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg=="],
+ "gaxios": ["gaxios@6.7.1", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "is-stream": "^2.0.0", "node-fetch": "^2.6.9", "uuid": "^9.0.1" } }, "sha512-LDODD4TMYx7XXdpwxAVRAIAuB0bzv0s+ywFonY46k126qzQHT9ygyoa9tncmOiQmmDrik65UYsEkv3lbfqQ3yQ=="],
"gcp-metadata": ["gcp-metadata@8.1.3", "", { "dependencies": { "gaxios": "7.1.3", "google-logging-utils": "1.1.3", "json-bigint": "^1.0.0" } }, "sha512-ziTrzUhhpL9Zk5k0HHzgP/KIpWDJT0VMBC/ynt/QIBvTW+UUcSivQRl6VlwTf/EilDxtSWklHoRsKy1c4k+59w=="],
@@ -2800,6 +2822,8 @@
"html-encoding-sniffer": ["html-encoding-sniffer@4.0.0", "", { "dependencies": { "whatwg-encoding": "^3.1.1" } }, "sha512-Y22oTqIU4uuPgEemfz7NDJz6OeKf12Lsu+QC+s3BVpda64lTiMYCyGwg5ki4vFxkMwQdeZDl2adZoqUgdFuTgQ=="],
+ "html-entities": ["html-entities@2.6.0", "", {}, "sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ=="],
+
"html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="],
"html-to-text": ["html-to-text@9.0.5", "", { "dependencies": { "@selderee/plugin-htmlparser2": "^0.11.0", "deepmerge": "^4.3.1", "dom-serializer": "^2.0.0", "htmlparser2": "^8.0.2", "selderee": "^0.11.0" } }, "sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg=="],
@@ -2902,7 +2926,7 @@
"is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="],
- "is-stream": ["is-stream@3.0.0", "", {}, "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA=="],
+ "is-stream": ["is-stream@2.0.1", "", {}, "sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg=="],
"is-unicode-supported": ["is-unicode-supported@2.1.0", "", {}, "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ=="],
@@ -3208,6 +3232,8 @@
"micromatch": ["micromatch@4.0.8", "", { "dependencies": { "braces": "^3.0.3", "picomatch": "^2.3.1" } }, "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA=="],
+ "mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="],
+
"mime-db": ["mime-db@1.54.0", "", {}, "sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ=="],
"mime-types": ["mime-types@3.0.2", "", { "dependencies": { "mime-db": "^1.54.0" } }, "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A=="],
@@ -3362,6 +3388,8 @@
"orderedmap": ["orderedmap@2.1.1", "", {}, "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g=="],
+ "p-limit": ["p-limit@3.1.0", "", { "dependencies": { "yocto-queue": "^0.1.0" } }, "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ=="],
+
"p-retry": ["p-retry@4.6.2", "", { "dependencies": { "@types/retry": "0.12.0", "retry": "^0.13.1" } }, "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ=="],
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
@@ -3650,6 +3678,8 @@
"retry": ["retry@0.12.0", "", {}, "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow=="],
+ "retry-request": ["retry-request@7.0.2", "", { "dependencies": { "@types/request": "^2.48.8", "extend": "^3.0.2", "teeny-request": "^9.0.0" } }, "sha512-dUOvLMJ0/JJYEn8NrpOaGNE7X3vpI5XlZS/u0ANjqtcZVKnIxP7IgCFwrKTxENw29emmwug53awKtaMm4i9g5w=="],
+
"reusify": ["reusify@1.1.0", "", {}, "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw=="],
"rfdc": ["rfdc@1.4.1", "", {}, "sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA=="],
@@ -3808,6 +3838,10 @@
"stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="],
+ "stream-events": ["stream-events@1.0.5", "", { "dependencies": { "stubs": "^3.0.0" } }, "sha512-E1GUzBSgvct8Jsb3v2X15pjzN1tYebtbLaMg+eBOUOAxgbLoSbT2NS91ckc5lJD1KfLjId+jXJRgo0qnV5Nerg=="],
+
+ "stream-shift": ["stream-shift@1.0.3", "", {}, "sha512-76ORR0DO1o1hlKwTbi/DM3EXWGf3ZJYO8cXX5RJwnul2DEg2oyoZyjLNoQM8WsvZiFKCRfC1O0J7iCvie3RZmQ=="],
+
"streamdown": ["streamdown@2.5.0", "", { "dependencies": { "clsx": "^2.1.1", "hast-util-to-jsx-runtime": "^2.3.6", "html-url-attributes": "^3.0.1", "marked": "^17.0.1", "mermaid": "^11.12.2", "rehype-harden": "^1.1.8", "rehype-raw": "^7.0.0", "rehype-sanitize": "^6.0.0", "remark-gfm": "^4.0.1", "remark-parse": "^11.0.0", "remark-rehype": "^11.1.2", "remend": "1.3.0", "tailwind-merge": "^3.4.0", "unified": "^11.0.5", "unist-util-visit": "^5.0.0", "unist-util-visit-parents": "^6.0.0" }, "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-/tTnURfIOxZK/pqJAxsfCvETG/XCJHoWnk3jq9xLcuz6CSpnjjuxSRBTTL4PKGhxiZQf0lqPxGhImdpwcZ2XwA=="],
"streamsearch": ["streamsearch@1.1.0", "", {}, "sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg=="],
@@ -3844,6 +3878,8 @@
"strtok3": ["strtok3@6.3.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^4.1.0" } }, "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw=="],
+ "stubs": ["stubs@3.0.0", "", {}, "sha512-PdHt7hHUJKxvTCgbKX9C1V/ftOcjJQgz8BZwNfV5c4B6dcGqlpelTbJ999jBGZ2jYiPAwcX5dP6oBwVlBlUbxw=="],
+
"style-to-js": ["style-to-js@1.1.21", "", { "dependencies": { "style-to-object": "1.0.14" } }, "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ=="],
"style-to-object": ["style-to-object@1.0.14", "", { "dependencies": { "inline-style-parser": "0.2.7" } }, "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw=="],
@@ -3882,6 +3918,8 @@
"tdigest": ["tdigest@0.1.2", "", { "dependencies": { "bintrees": "1.0.2" } }, "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA=="],
+ "teeny-request": ["teeny-request@9.0.0", "", { "dependencies": { "http-proxy-agent": "^5.0.0", "https-proxy-agent": "^5.0.0", "node-fetch": "^2.6.9", "stream-events": "^1.0.5", "uuid": "^9.0.0" } }, "sha512-resvxdc6Mgb7YEThw6G6bExlXKkv6+YbuzGg9xuXxSgxJF7Ozs+o8Y9+2R3sArdWdW8nOokoQb1yrpFB0pQK2g=="],
+
"thenify": ["thenify@3.3.1", "", { "dependencies": { "any-promise": "^1.0.0" } }, "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw=="],
"thenify-all": ["thenify-all@1.6.0", "", { "dependencies": { "thenify": ">= 3.1.0 < 4" } }, "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA=="],
@@ -4104,6 +4142,8 @@
"yauzl": ["yauzl@3.4.0", "", { "dependencies": { "pend": "~1.2.0" } }, "sha512-jIH9yLR9wqr0wOS0TpBvo/g/2UgZH5qePVbjgRliiF0BYvOZyaBknKsF+x9Iht0O6sqgnB93rCICdOZFecJuDw=="],
+ "yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
+
"yoctocolors": ["yoctocolors@2.1.2", "", {}, "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug=="],
"yoga-wasm-web": ["yoga-wasm-web@0.3.3", "", {}, "sha512-N+d4UJSJbt/R3wqY7Coqs5pcV0aUj2j9IaQ3rNj9bVCLld8tTGKRa2USARjnvZJWVx1NDmQev8EknoczaOQDOA=="],
@@ -4188,6 +4228,8 @@
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
+ "@google-cloud/storage/google-auth-library": ["google-auth-library@9.15.1", "", { "dependencies": { "base64-js": "^1.3.0", "ecdsa-sig-formatter": "^1.0.11", "gaxios": "^6.1.1", "gcp-metadata": "^6.1.0", "gtoken": "^7.0.0", "jws": "^4.0.0" } }, "sha512-Jb6Z0+nvECVz+2lzSMt9u98UsoakXxA2HGHMCxh+so3n90XgYWkq5dur19JAJV7ONiJY22yBTyJB1TSkvPq9Ng=="],
+
"@grpc/proto-loader/protobufjs": ["protobufjs@7.6.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw=="],
"@modelcontextprotocol/sdk/ajv": ["ajv@8.20.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA=="],
@@ -4492,6 +4534,10 @@
"@types/nodemailer/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="],
+ "@types/request/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="],
+
+ "@types/request/form-data": ["form-data@2.5.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35", "safe-buffer": "^5.2.1" } }, "sha512-Ogz/E85h9tlfJzpI6TuFpGcHZFhLrb9Gw8wq9v40CxSCPnv7ahKr6Xgtkn0KYCDQJ8DNn5VoMO8EXr9V5PadyA=="],
+
"@types/ssh2/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
"@types/through/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="],
@@ -4506,6 +4552,8 @@
"anymatch/picomatch": ["picomatch@2.3.2", "", {}, "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA=="],
+ "async-retry/retry": ["retry@0.13.1", "", {}, "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg=="],
+
"axios/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
"better-auth/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="],
@@ -4568,6 +4616,8 @@
"dom-serializer/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
+ "duplexify/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
+
"e2b/glob": ["glob@11.1.0", "", { "dependencies": { "foreground-child": "^3.3.1", "jackspeak": "^4.1.1", "minimatch": "^10.1.1", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^2.0.0" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw=="],
"echarts/tslib": ["tslib@2.3.0", "", {}, "sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg=="],
@@ -4580,6 +4630,8 @@
"engine.io-client/ws": ["ws@8.20.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w=="],
+ "execa/is-stream": ["is-stream@3.0.0", "", {}, "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA=="],
+
"execa/signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="],
@@ -4618,16 +4670,24 @@
"fumadocs-ui/tailwind-merge": ["tailwind-merge@3.6.0", "", {}, "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w=="],
+ "gaxios/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
+
+ "gaxios/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="],
+
"gcp-metadata/gaxios": ["gaxios@7.1.3", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2", "rimraf": "^5.0.1" } }, "sha512-YGGyuEdVIjqxkxVH1pUTMY/XtmmsApXrCVv5EU25iX6inEPbV+VakJfLealkBtJN69AQmh1eGOdCl9Sm1UP6XQ=="],
"gcp-metadata/google-logging-utils": ["google-logging-utils@1.1.3", "", {}, "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA=="],
+ "google-auth-library/gaxios": ["gaxios@7.1.5", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg=="],
+
"gray-matter/js-yaml": ["js-yaml@3.14.2", "", { "dependencies": { "argparse": "^1.0.7", "esprima": "^4.0.0" }, "bin": { "js-yaml": "bin/js-yaml.js" } }, "sha512-PMSmkqxr106Xa156c2M265Z+FTrPl+oxd/rgOQy2tijQeK5TxQ43psO1ZCwhVOSdnn+RzkzlRz/eY4BgJBYVpg=="],
"groq-sdk/@types/node": ["@types/node@18.19.130", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg=="],
"groq-sdk/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
+ "gtoken/gaxios": ["gaxios@7.1.5", "", { "dependencies": { "extend": "^3.0.2", "https-proxy-agent": "^7.0.1", "node-fetch": "^3.3.2" } }, "sha512-5FZy72Rh8LhtjmvDrKkI+lVhrsQrVKVsItxMoDm5mNQE+xR0WVIIs+jzPSJgBvKVsLi24fZhXJIsNI0bihDzFg=="],
+
"htmlparser2/entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"http-response-object/@types/node": ["@types/node@10.17.60", "", {}, "sha512-F0KIgDJfy2nA3zMLmWGKxcH2ZVEtCZXHHdOQs2gSaQ27+lNeEfGxzkIw90aXswATX7AZ33tahPbzy6KAfUreVw=="],
@@ -4796,6 +4856,14 @@
"tar-stream/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
+ "teeny-request/http-proxy-agent": ["http-proxy-agent@5.0.0", "", { "dependencies": { "@tootallnate/once": "2", "agent-base": "6", "debug": "4" } }, "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w=="],
+
+ "teeny-request/https-proxy-agent": ["https-proxy-agent@5.0.1", "", { "dependencies": { "agent-base": "6", "debug": "4" } }, "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA=="],
+
+ "teeny-request/node-fetch": ["node-fetch@2.7.0", "", { "dependencies": { "whatwg-url": "^5.0.0" }, "peerDependencies": { "encoding": "^0.1.0" }, "optionalPeers": ["encoding"] }, "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A=="],
+
+ "teeny-request/uuid": ["uuid@9.0.1", "", { "bin": { "uuid": "dist/bin/uuid" } }, "sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA=="],
+
"tough-cookie/tldts": ["tldts@6.1.86", "", { "dependencies": { "tldts-core": "^6.1.86" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WMi/OQ2axVTf/ykqCQgXiIct+mSQDFdH2fkwhPwgEwvJ1kSzZRiinb0zF2Xb8u4+OqPChmyI6MEu4EezNJz+FQ=="],
"tsconfck/typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
@@ -4888,6 +4956,10 @@
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
+ "@google-cloud/storage/google-auth-library/gcp-metadata": ["gcp-metadata@6.1.1", "", { "dependencies": { "gaxios": "^6.1.1", "google-logging-utils": "^0.0.2", "json-bigint": "^1.0.0" } }, "sha512-a4tiq7E0/5fTjxPAaH4jpjkSv/uCaU2p5KC6HVGrvl0cDjA8iBZv4vv1gyzlmK0ZUKqwpOyQMKzZQe3lTit77A=="],
+
+ "@google-cloud/storage/google-auth-library/gtoken": ["gtoken@7.1.0", "", { "dependencies": { "gaxios": "^6.0.0", "jws": "^4.0.0" } }, "sha512-pCcEwRi+TKpMlxAQObHDQ56KawURgyAf6jtIY046fJ5tIv3zDe/LEIubckAO8fj6JnAxLdmWkUfNyulQ2iKdEw=="],
+
"@grpc/proto-loader/protobufjs/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="],
"@octokit/plugin-paginate-rest/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="],
@@ -4974,6 +5046,10 @@
"@types/nodemailer/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
+ "@types/request/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
+
+ "@types/request/form-data/mime-types": ["mime-types@2.1.35", "", { "dependencies": { "mime-db": "1.52.0" } }, "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw=="],
+
"@types/ssh2/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
"@types/through/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
@@ -5010,6 +5086,8 @@
"docx/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
+ "duplexify/readable-stream/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
+
"engine.io/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
"express/accepts/negotiator": ["negotiator@1.0.0", "", {}, "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg=="],
@@ -5070,6 +5148,8 @@
"fumadocs-mdx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.1", "", { "os": "win32", "cpu": "x64" }, "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A=="],
+ "gaxios/node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
+
"gray-matter/js-yaml/argparse": ["argparse@1.0.10", "", { "dependencies": { "sprintf-js": "~1.0.2" } }, "sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg=="],
"groq-sdk/@types/node/undici-types": ["undici-types@5.26.5", "", {}, "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA=="],
@@ -5200,6 +5280,12 @@
"tar-stream/readable-stream/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="],
+ "teeny-request/http-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
+
+ "teeny-request/https-proxy-agent/agent-base": ["agent-base@6.0.2", "", { "dependencies": { "debug": "4" } }, "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ=="],
+
+ "teeny-request/node-fetch/whatwg-url": ["whatwg-url@5.0.0", "", { "dependencies": { "tr46": "~0.0.3", "webidl-conversions": "^3.0.0" } }, "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw=="],
+
"tough-cookie/tldts/tldts-core": ["tldts-core@6.1.86", "", {}, "sha512-Je6p7pkk+KMzMv2XXKmAE3McmolOQFdxkKw0R8EYNr7sELW46JqnNeTX8ybPiQgvg1ymCoF8LXs5fzFaZvJPTA=="],
"tsx/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.1", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ=="],
@@ -5270,6 +5356,8 @@
"@earendil-works/pi-ai/@google/genai/protobufjs/@types/node": ["@types/node@25.9.3", "", { "dependencies": { "undici-types": ">=7.24.0 <7.24.7" } }, "sha512-603BddQMv3pUcr4U2dhujk83N2tTDVr/34wII2B6bJy6g+8WD6yUb11jszNs0gdi4PesVWl7ABt8nYMVpnLUcg=="],
+ "@google-cloud/storage/google-auth-library/gcp-metadata/google-logging-utils": ["google-logging-utils@0.0.2", "", {}, "sha512-NEgUnEcBiP5HrPzufUkBzJOD/Sxsco3rLNo1F1TNf7ieU8ryUzBhqba8r756CjLX7rn3fHl6iLEwPYuqpoKgQQ=="],
+
"@grpc/proto-loader/protobufjs/@types/node/undici-types": ["undici-types@7.24.6", "", {}, "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg=="],
"@trigger.dev/core/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/otlp-transformer/protobufjs": ["protobufjs@7.6.4", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-RJJPTTpvFfHcWLkIa2JFWK4XvtSzS0yEWDmunqHXli1h3JlkbcQZXDZdcWxv+JK3Xsl5/UFDPZ0iGm7DAengYw=="],
@@ -5290,8 +5378,14 @@
"@trigger.dev/core/socket.io/engine.io/ws": ["ws@8.17.1", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ=="],
+ "@types/request/form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
+
"cli-truncate/string-width/strip-ansi/ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
+ "gaxios/node-fetch/whatwg-url/tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
+
+ "gaxios/node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
+
"groq-sdk/node-fetch/whatwg-url/tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
"groq-sdk/node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
@@ -5348,6 +5442,10 @@
"sim/tailwindcss/chokidar/readdirp": ["readdirp@3.6.0", "", { "dependencies": { "picomatch": "^2.2.1" } }, "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA=="],
+ "teeny-request/node-fetch/whatwg-url/tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
+
+ "teeny-request/node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
+
"@browserbasehq/stagehand/@anthropic-ai/sdk/node-fetch/whatwg-url/tr46": ["tr46@0.0.3", "", {}, "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw=="],
"@browserbasehq/stagehand/@anthropic-ai/sdk/node-fetch/whatwg-url/webidl-conversions": ["webidl-conversions@3.0.1", "", {}, "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ=="],
diff --git a/helm/sim/examples/values-gcp.yaml b/helm/sim/examples/values-gcp.yaml
index 3356ac99894..4a3a27f9762 100644
--- a/helm/sim/examples/values-gcp.yaml
+++ b/helm/sim/examples/values-gcp.yaml
@@ -90,6 +90,22 @@ app:
GOOGLE_CLOUD_PROJECT: "your-project-id"
GOOGLE_CLOUD_REGION: "us-central1"
+ # Google Cloud Storage Configuration (RECOMMENDED for production)
+ # Create GCS buckets in your project and grant the Workload Identity
+ # service account roles/storage.objectAdmin on them. Signed-URL generation
+ # without a key file additionally requires roles/iam.serviceAccountTokenCreator
+ # on the service account itself (or set GCS_CREDENTIALS_JSON to inline
+ # service-account JSON with a private key).
+ GCS_PROJECT_ID: "your-project-id"
+ GCS_BUCKET_NAME: "myorg-sim-workspace-files" # Workspace files (enables GCS)
+ GCS_KB_BUCKET_NAME: "myorg-sim-knowledge-base" # Knowledge base documents
+ GCS_EXECUTION_FILES_BUCKET_NAME: "myorg-sim-execution-files" # Workflow execution outputs
+ GCS_CHAT_BUCKET_NAME: "myorg-sim-chat-files" # Deployed chat assets
+ GCS_COPILOT_BUCKET_NAME: "myorg-sim-copilot-files" # Copilot attachments
+ GCS_PROFILE_PICTURES_BUCKET_NAME: "myorg-sim-profile-pictures" # User avatars
+ GCS_OG_IMAGES_BUCKET_NAME: "myorg-sim-og-images" # OpenGraph preview images
+ GCS_WORKSPACE_LOGOS_BUCKET_NAME: "myorg-sim-workspace-logos" # Workspace logos
+
# Realtime service
realtime:
enabled: true
diff --git a/helm/sim/values.yaml b/helm/sim/values.yaml
index a3d4c94085e..019c8a4e9d4 100644
--- a/helm/sim/values.yaml
+++ b/helm/sim/values.yaml
@@ -288,6 +288,20 @@ app:
AZURE_STORAGE_OG_IMAGES_CONTAINER_NAME: "" # Azure container for OpenGraph preview images
AZURE_STORAGE_WORKSPACE_LOGOS_CONTAINER_NAME: "" # Azure container for workspace logos
+ # Google Cloud Storage Configuration (optional - for file storage)
+ # If configured, files will be stored in GCS instead of local storage
+ # Note: used when neither Azure Blob nor S3 is configured
+ GCS_PROJECT_ID: "" # GCP project ID (optional — inferred from credentials/ADC when unset)
+ GCS_CREDENTIALS_JSON: "" # Inline service-account JSON. Leave empty to use Workload Identity / Application Default Credentials
+ GCS_BUCKET_NAME: "" # GCS bucket for workspace files (all other GCS buckets fall back to it)
+ GCS_KB_BUCKET_NAME: "" # GCS bucket for knowledge base files
+ GCS_EXECUTION_FILES_BUCKET_NAME: "" # GCS bucket for workflow execution files
+ GCS_CHAT_BUCKET_NAME: "" # GCS bucket for deployed chat files
+ GCS_COPILOT_BUCKET_NAME: "" # GCS bucket for copilot files
+ GCS_PROFILE_PICTURES_BUCKET_NAME: "" # GCS bucket for user profile pictures
+ GCS_OG_IMAGES_BUCKET_NAME: "" # GCS bucket for OpenGraph preview images
+ GCS_WORKSPACE_LOGOS_BUCKET_NAME: "" # GCS bucket for workspace logos
+
# Operational tunables shipped with the chart. These are rendered as inline `env:` on the
# app container — NOT written into the chart-managed Secret and NOT required to be mapped
# when externalSecrets.enabled=true. Override any key by setting `app.envDefaults.KEY` in