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