From 6f188c8f35e1140e5f8311b94293da5164423b74 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 2 Jul 2026 08:52:38 -0700 Subject: [PATCH 1/5] fix(supabase): remove non-functional SQL introspection path, harden storage URL encoding, add missing storage endpoints - introspect.ts no longer attempts raw SQL via a nonexistent PostgREST RPC endpoint (always failed); now uses the OpenAPI-spec path directly with honest heuristic/best-effort documentation for PK/FK/index fields - storage tools now trim + URL-encode bucket/path segments before building request URLs (download, list, get_public_url, create_signed_url, delete_bucket, delete, and the upload API route) - storage_create_signed_url guards against a missing signedURL field in the response instead of silently building a broken URL - add supabase_storage_create_signed_upload_url, supabase_storage_update_bucket, and supabase_storage_empty_bucket tools + block wiring - change insert/upsert `data` param type from 'array' to 'json' to match actual accepted shapes (array or single object) - bump tool versions to semver (1.0 -> 1.0.0) - rewrite a BlockMeta template that implied unsupported Supabase Auth Admin user-provisioning (cherry picked from commit 52b655acf59bace806c75c06b4b2cfaebe4b6781) --- .../tools/supabase/storage-upload/route.ts | 7 +- apps/sim/blocks/blocks/supabase.ts | 57 ++- apps/sim/tools/registry.ts | 6 + apps/sim/tools/supabase/count.ts | 2 +- apps/sim/tools/supabase/delete.ts | 2 +- apps/sim/tools/supabase/get_row.ts | 2 +- apps/sim/tools/supabase/index.ts | 6 + apps/sim/tools/supabase/insert.ts | 4 +- apps/sim/tools/supabase/introspect.ts | 456 ++---------------- apps/sim/tools/supabase/invoke_function.ts | 2 +- apps/sim/tools/supabase/query.ts | 2 +- apps/sim/tools/supabase/rpc.ts | 2 +- apps/sim/tools/supabase/storage_copy.ts | 2 +- .../tools/supabase/storage_create_bucket.ts | 2 +- .../storage_create_signed_upload_url.ts | 109 +++++ .../supabase/storage_create_signed_url.ts | 11 +- apps/sim/tools/supabase/storage_delete.ts | 6 +- .../tools/supabase/storage_delete_bucket.ts | 10 +- apps/sim/tools/supabase/storage_download.ts | 8 +- .../tools/supabase/storage_empty_bucket.ts | 80 +++ .../tools/supabase/storage_get_public_url.ts | 15 +- apps/sim/tools/supabase/storage_list.ts | 6 +- .../tools/supabase/storage_list_buckets.ts | 2 +- apps/sim/tools/supabase/storage_move.ts | 2 +- .../tools/supabase/storage_update_bucket.ts | 113 +++++ apps/sim/tools/supabase/storage_upload.ts | 2 +- apps/sim/tools/supabase/text_search.ts | 2 +- apps/sim/tools/supabase/types.ts | 60 ++- apps/sim/tools/supabase/update.ts | 2 +- apps/sim/tools/supabase/upsert.ts | 4 +- apps/sim/tools/supabase/utils.ts | 21 + apps/sim/tools/supabase/vector_search.ts | 2 +- 32 files changed, 530 insertions(+), 477 deletions(-) create mode 100644 apps/sim/tools/supabase/storage_create_signed_upload_url.ts create mode 100644 apps/sim/tools/supabase/storage_empty_bucket.ts create mode 100644 apps/sim/tools/supabase/storage_update_bucket.ts diff --git a/apps/sim/app/api/tools/supabase/storage-upload/route.ts b/apps/sim/app/api/tools/supabase/storage-upload/route.ts index 3e2dcd4cdd6..7f02f7791ea 100644 --- a/apps/sim/app/api/tools/supabase/storage-upload/route.ts +++ b/apps/sim/app/api/tools/supabase/storage-upload/route.ts @@ -11,6 +11,7 @@ import { processSingleFileToUserFile } from '@/lib/uploads/utils/file-utils' import { downloadServableFileFromStorage } from '@/lib/uploads/utils/file-utils.server' import { docNotReadyResponse } from '@/lib/uploads/utils/servable-file-response' import { assertToolFileAccess } from '@/app/api/files/authorization' +import { encodeStoragePath, encodeStorageSegment } from '@/tools/supabase/utils' export const dynamic = 'force-dynamic' @@ -185,7 +186,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => { return NextResponse.json({ success: false, error: projectValidation.error }, { status: 400 }) } - const supabaseUrl = `https://${projectValidation.sanitized}.supabase.co/storage/v1/object/${validatedData.bucket}/${fullPath}` + const encodedBucket = encodeStorageSegment(validatedData.bucket) + const encodedPath = encodeStoragePath(fullPath) + const supabaseUrl = `https://${projectValidation.sanitized}.supabase.co/storage/v1/object/${encodedBucket}/${encodedPath}` const headers: Record = { apikey: validatedData.apiKey, @@ -248,7 +251,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => { path: fullPath, }) - const publicUrl = `https://${projectValidation.sanitized}.supabase.co/storage/v1/object/public/${validatedData.bucket}/${fullPath}` + const publicUrl = `https://${projectValidation.sanitized}.supabase.co/storage/v1/object/public/${encodedBucket}/${encodedPath}` return NextResponse.json({ success: true, diff --git a/apps/sim/blocks/blocks/supabase.ts b/apps/sim/blocks/blocks/supabase.ts index 9f0d5eac72e..87711781c9a 100644 --- a/apps/sim/blocks/blocks/supabase.ts +++ b/apps/sim/blocks/blocks/supabase.ts @@ -45,7 +45,10 @@ export const SupabaseBlock: BlockConfig = { { label: 'Storage: Copy File', id: 'storage_copy' }, { label: 'Storage: Get Public URL', id: 'storage_get_public_url' }, { label: 'Storage: Create Signed URL', id: 'storage_create_signed_url' }, + { label: 'Storage: Create Signed Upload URL', id: 'storage_create_signed_upload_url' }, { label: 'Storage: Create Bucket', id: 'storage_create_bucket' }, + { label: 'Storage: Update Bucket', id: 'storage_update_bucket' }, + { label: 'Storage: Empty Bucket', id: 'storage_empty_bucket' }, { label: 'Storage: List Buckets', id: 'storage_list_buckets' }, { label: 'Storage: Delete Bucket', id: 'storage_delete_bucket' }, ], @@ -686,9 +689,12 @@ Return ONLY the PostgREST filter expression - no explanations, no markdown, no e 'storage_move', 'storage_copy', 'storage_create_bucket', + 'storage_update_bucket', + 'storage_empty_bucket', 'storage_delete_bucket', 'storage_get_public_url', 'storage_create_signed_url', + 'storage_create_signed_upload_url', ], }, required: true, @@ -917,21 +923,43 @@ Return ONLY the PostgREST filter expression - no explanations, no markdown, no e { label: 'True (Public)', id: 'true' }, ], value: () => 'false', - condition: { field: 'operation', value: 'storage_create_bucket' }, + condition: { field: 'operation', value: ['storage_create_bucket', 'storage_update_bucket'] }, }, { id: 'fileSizeLimit', title: 'File Size Limit (bytes)', type: 'short-input', placeholder: '52428800', - condition: { field: 'operation', value: 'storage_create_bucket' }, + condition: { field: 'operation', value: ['storage_create_bucket', 'storage_update_bucket'] }, + mode: 'advanced', }, { id: 'allowedMimeTypes', title: 'Allowed MIME Types (JSON array)', type: 'code', placeholder: '["image/png", "image/jpeg"]', - condition: { field: 'operation', value: 'storage_create_bucket' }, + condition: { field: 'operation', value: ['storage_create_bucket', 'storage_update_bucket'] }, + mode: 'advanced', + }, + { + id: 'path', + title: 'Destination Path', + type: 'short-input', + placeholder: 'folder/file.jpg', + condition: { field: 'operation', value: 'storage_create_signed_upload_url' }, + required: true, + }, + { + id: 'upsert', + title: 'Allow Overwrite', + type: 'dropdown', + options: [ + { label: 'False', id: 'false' }, + { label: 'True', id: 'true' }, + ], + value: () => 'false', + condition: { field: 'operation', value: 'storage_create_signed_upload_url' }, + mode: 'advanced', }, ], tools: { @@ -955,10 +983,13 @@ Return ONLY the PostgREST filter expression - no explanations, no markdown, no e 'supabase_storage_move', 'supabase_storage_copy', 'supabase_storage_create_bucket', + 'supabase_storage_update_bucket', + 'supabase_storage_empty_bucket', 'supabase_storage_list_buckets', 'supabase_storage_delete_bucket', 'supabase_storage_get_public_url', 'supabase_storage_create_signed_url', + 'supabase_storage_create_signed_upload_url', ], config: { tool: (params) => { @@ -1001,6 +1032,10 @@ Return ONLY the PostgREST filter expression - no explanations, no markdown, no e return 'supabase_storage_copy' case 'storage_create_bucket': return 'supabase_storage_create_bucket' + case 'storage_update_bucket': + return 'supabase_storage_update_bucket' + case 'storage_empty_bucket': + return 'supabase_storage_empty_bucket' case 'storage_list_buckets': return 'supabase_storage_list_buckets' case 'storage_delete_bucket': @@ -1009,6 +1044,8 @@ Return ONLY the PostgREST filter expression - no explanations, no markdown, no e return 'supabase_storage_get_public_url' case 'storage_create_signed_url': return 'supabase_storage_create_signed_url' + case 'storage_create_signed_upload_url': + return 'supabase_storage_create_signed_upload_url' default: throw new Error(`Invalid Supabase operation: ${params.operation}`) } @@ -1281,7 +1318,15 @@ Return ONLY the PostgREST filter expression - no explanations, no markdown, no e }, signedUrl: { type: 'string', - description: 'Temporary signed URL for storage file', + description: 'Temporary signed URL for storage file (download or upload)', + }, + token: { + type: 'string', + description: 'Upload token embedded in the signed upload URL', + }, + path: { + type: 'string', + description: 'Destination object path for the signed upload URL', }, tables: { type: 'json', @@ -1300,9 +1345,9 @@ export const SupabaseBlockMeta = { templates: [ { icon: SupabaseIcon, - title: 'Supabase user provisioning', + title: 'Supabase customer record sync', prompt: - 'Build a workflow that listens for Stripe new-customer events, provisions a Supabase user with the correct role and metadata, and emails the welcome login link.', + 'Build a workflow that listens for Stripe new-customer events and upserts a row into a Supabase customers table with the correct plan and metadata, then emails a welcome message.', modules: ['agent', 'workflows'], category: 'operations', tags: ['enterprise', 'automation'], diff --git a/apps/sim/tools/registry.ts b/apps/sim/tools/registry.ts index d7e5eda0107..a4a5b3a4abe 100644 --- a/apps/sim/tools/registry.ts +++ b/apps/sim/tools/registry.ts @@ -3698,14 +3698,17 @@ import { supabaseRpcTool, supabaseStorageCopyTool, supabaseStorageCreateBucketTool, + supabaseStorageCreateSignedUploadUrlTool, supabaseStorageCreateSignedUrlTool, supabaseStorageDeleteBucketTool, supabaseStorageDeleteTool, supabaseStorageDownloadTool, + supabaseStorageEmptyBucketTool, supabaseStorageGetPublicUrlTool, supabaseStorageListBucketsTool, supabaseStorageListTool, supabaseStorageMoveTool, + supabaseStorageUpdateBucketTool, supabaseStorageUploadTool, supabaseTextSearchTool, supabaseUpdateTool, @@ -5207,10 +5210,13 @@ export const tools: Record = { supabase_storage_move: supabaseStorageMoveTool, supabase_storage_copy: supabaseStorageCopyTool, supabase_storage_create_bucket: supabaseStorageCreateBucketTool, + supabase_storage_update_bucket: supabaseStorageUpdateBucketTool, + supabase_storage_empty_bucket: supabaseStorageEmptyBucketTool, supabase_storage_list_buckets: supabaseStorageListBucketsTool, supabase_storage_delete_bucket: supabaseStorageDeleteBucketTool, supabase_storage_get_public_url: supabaseStorageGetPublicUrlTool, supabase_storage_create_signed_url: supabaseStorageCreateSignedUrlTool, + supabase_storage_create_signed_upload_url: supabaseStorageCreateSignedUploadUrlTool, tailscale_list_devices: tailscaleListDevicesTool, tailscale_get_device: tailscaleGetDeviceTool, tailscale_delete_device: tailscaleDeleteDeviceTool, diff --git a/apps/sim/tools/supabase/count.ts b/apps/sim/tools/supabase/count.ts index 7e5d2c0f3ad..144b41f00bd 100644 --- a/apps/sim/tools/supabase/count.ts +++ b/apps/sim/tools/supabase/count.ts @@ -7,7 +7,7 @@ export const countTool: ToolConfig = id: 'supabase_count', name: 'Supabase Count', description: 'Count rows in a Supabase table', - version: '1.0', + version: '1.0.0', params: { projectId: { diff --git a/apps/sim/tools/supabase/delete.ts b/apps/sim/tools/supabase/delete.ts index 967229868e1..d7460d6a6bd 100644 --- a/apps/sim/tools/supabase/delete.ts +++ b/apps/sim/tools/supabase/delete.ts @@ -7,7 +7,7 @@ export const deleteTool: ToolConfig 63) { - throw new Error(`Invalid value: ${value}`) - } - return value.replace(/'/g, "''") -} - /** - * SQL query filtered by specific schema - */ -const getSchemaFilteredSQL = (schema: string) => { - const safeSchema = escapeSqlString(schema) - return ` -WITH table_info AS ( - SELECT - t.table_schema, - t.table_name - FROM information_schema.tables t - WHERE t.table_type = 'BASE TABLE' - AND t.table_schema = '${safeSchema}' -), -columns_info AS ( - SELECT - c.table_schema, - c.table_name, - c.column_name, - c.data_type, - c.is_nullable, - c.column_default, - c.ordinal_position - FROM information_schema.columns c - INNER JOIN table_info t ON c.table_schema = t.table_schema AND c.table_name = t.table_name -), -pk_info AS ( - SELECT - tc.table_schema, - tc.table_name, - kcu.column_name - FROM information_schema.table_constraints tc - JOIN information_schema.key_column_usage kcu - ON tc.constraint_name = kcu.constraint_name - AND tc.table_schema = kcu.table_schema - WHERE tc.constraint_type = 'PRIMARY KEY' - AND tc.table_schema = '${safeSchema}' -), -fk_info AS ( - SELECT - tc.table_schema, - tc.table_name, - kcu.column_name, - ccu.table_name AS foreign_table_name, - ccu.column_name AS foreign_column_name - FROM information_schema.table_constraints tc - JOIN information_schema.key_column_usage kcu - ON tc.constraint_name = kcu.constraint_name - AND tc.table_schema = kcu.table_schema - JOIN information_schema.constraint_column_usage ccu - ON ccu.constraint_name = tc.constraint_name - WHERE tc.constraint_type = 'FOREIGN KEY' - AND tc.table_schema = '${safeSchema}' -), -index_info AS ( - SELECT - schemaname AS table_schema, - tablename AS table_name, - indexname AS index_name, - CASE WHEN indexdef LIKE '%UNIQUE%' THEN true ELSE false END AS is_unique, - indexdef - FROM pg_indexes - WHERE schemaname = '${safeSchema}' -) -SELECT json_build_object( - 'tables', ( - SELECT json_agg( - json_build_object( - 'schema', t.table_schema, - 'name', t.table_name, - 'columns', ( - SELECT json_agg( - json_build_object( - 'name', c.column_name, - 'type', c.data_type, - 'nullable', c.is_nullable = 'YES', - 'default', c.column_default, - 'isPrimaryKey', EXISTS ( - SELECT 1 FROM pk_info pk - WHERE pk.table_schema = c.table_schema - AND pk.table_name = c.table_name - AND pk.column_name = c.column_name - ), - 'isForeignKey', EXISTS ( - SELECT 1 FROM fk_info fk - WHERE fk.table_schema = c.table_schema - AND fk.table_name = c.table_name - AND fk.column_name = c.column_name - ), - 'references', ( - SELECT json_build_object('table', fk.foreign_table_name, 'column', fk.foreign_column_name) - FROM fk_info fk - WHERE fk.table_schema = c.table_schema - AND fk.table_name = c.table_name - AND fk.column_name = c.column_name - LIMIT 1 - ) - ) - ORDER BY c.ordinal_position - ) - FROM columns_info c - WHERE c.table_schema = t.table_schema AND c.table_name = t.table_name - ), - 'primaryKey', ( - SELECT COALESCE(json_agg(pk.column_name), '[]'::json) - FROM pk_info pk - WHERE pk.table_schema = t.table_schema AND pk.table_name = t.table_name - ), - 'foreignKeys', ( - SELECT COALESCE(json_agg( - json_build_object( - 'column', fk.column_name, - 'referencesTable', fk.foreign_table_name, - 'referencesColumn', fk.foreign_column_name - ) - ), '[]'::json) - FROM fk_info fk - WHERE fk.table_schema = t.table_schema AND fk.table_name = t.table_name - ), - 'indexes', ( - SELECT COALESCE(json_agg( - json_build_object( - 'name', idx.index_name, - 'unique', idx.is_unique, - 'definition', idx.indexdef - ) - ), '[]'::json) - FROM index_info idx - WHERE idx.table_schema = t.table_schema AND idx.table_name = t.table_name - ) - ) - ) - FROM table_info t - ), - 'schemas', ( - SELECT COALESCE(json_agg(DISTINCT table_schema), '[]'::json) - FROM table_info - ) -) AS result; -` -} - -/** - * Tool for introspecting Supabase database schema - * Uses raw SQL execution via PostgREST to retrieve table structures + * Tool for introspecting Supabase database schema. + * + * PostgREST (which powers `/rest/v1`) has no generic "run arbitrary SQL" + * endpoint, so schema introspection is derived from the project's + * auto-generated OpenAPI spec (`GET /rest/v1/` with an OpenAPI `Accept` + * header) rather than a live `information_schema` query. Primary-key + * detection is a best-effort naming heuristic (`id` column), and + * foreign-key detection only succeeds if the table owner has added a + * matching `references table.column` SQL comment — the OpenAPI spec does + * not expose constraint metadata directly. Index information is not + * available via this API at all. */ export const introspectTool: ToolConfig = { id: 'supabase_introspect', name: 'Supabase Introspect', description: - 'Introspect Supabase database schema to get table structures, columns, and relationships', - version: '1.0', + 'Introspect Supabase database schema from its OpenAPI spec to get table and column structures (best-effort primary/foreign key detection)', + version: '1.0.0', params: { projectId: { @@ -336,127 +51,30 @@ export const introspectTool: ToolConfig { - return `${supabaseBaseUrl(params.projectId)}/rest/v1/rpc/` - }, - method: 'POST', + url: (params) => `${supabaseBaseUrl(params.projectId)}/rest/v1/?select=*`, + method: 'GET', headers: (params) => ({ apikey: params.apiKey, Authorization: `Bearer ${params.apiKey}`, - 'Content-Type': 'application/json', + Accept: 'application/openapi+json', }), - body: () => ({}), }, - directExecution: async ( - params: SupabaseIntrospectParams - ): Promise => { - const { apiKey, projectId, schema } = params - - try { - const sqlQuery = schema ? getSchemaFilteredSQL(schema) : INTROSPECTION_SQL - const baseUrl = supabaseBaseUrl(projectId) - - const response = await fetch(`${baseUrl}/rest/v1/rpc/`, { - method: 'POST', - headers: { - apikey: apiKey, - Authorization: `Bearer ${apiKey}`, - 'Content-Type': 'application/json', - Prefer: 'return=representation', - }, - body: JSON.stringify({ - query: sqlQuery, - }), - }) - - if (!response.ok) { - const errorText = await response.text() - logger.warn('Direct RPC call failed, attempting alternative approach', { - status: response.status, - }) - - const pgResponse = await fetch(`${baseUrl}/rest/v1/?select=*`, { - method: 'GET', - headers: { - apikey: apiKey, - Authorization: `Bearer ${apiKey}`, - Accept: 'application/openapi+json', - }, - }) - - if (!pgResponse.ok) { - throw new Error(`Failed to introspect database: ${errorText}`) - } - - const openApiSpec = await pgResponse.json() - const tables = parseOpenApiSpec(openApiSpec, schema) - - return { - success: true, - output: { - message: `Successfully introspected ${tables.length} table(s) from database schema`, - tables, - schemas: [...new Set(tables.map((t) => t.schema))], - }, - } - } - - const data = await response.json() - const result = Array.isArray(data) && data.length > 0 ? data[0].result : data.result || data - - const tables: SupabaseTableSchema[] = (result.tables || []).map((table: any) => ({ - name: table.name, - schema: table.schema, - columns: (table.columns || []).map((col: any) => ({ - name: col.name, - type: col.type, - nullable: col.nullable, - default: col.default, - isPrimaryKey: col.isPrimaryKey, - isForeignKey: col.isForeignKey, - references: col.references, - })), - primaryKey: table.primaryKey || [], - foreignKeys: table.foreignKeys || [], - indexes: (table.indexes || []).map((idx: any) => ({ - name: idx.name, - columns: parseIndexColumns(idx.definition || ''), - unique: idx.unique, - })), - })) - - return { - success: true, - output: { - message: `Successfully introspected ${tables.length} table(s) from database`, - tables, - schemas: result.schemas || [], - }, - } - } catch (error) { - logger.error('Supabase introspection failed', { error }) - const errorMessage = getErrorMessage(error, 'Unknown error occurred') - return { - success: false, - output: { - message: 'Failed to introspect database schema', - tables: [], - schemas: [], - }, - error: errorMessage, - } + transformResponse: async (response: Response, params?: SupabaseIntrospectParams) => { + if (!response.ok) { + const errorText = await response.text() + throw new Error(`Failed to introspect database: ${errorText}`) } - }, - transformResponse: async (response: Response) => { - const data = await response.json() + const openApiSpec = await response.json() + const tables = parseOpenApiSpec(openApiSpec, params?.schema) + return { success: true, output: { - message: 'Schema introspection completed', - tables: data.tables || [], - schemas: data.schemas || [], + message: `Successfully introspected ${tables.length} table(s) from database schema`, + tables, + schemas: [...new Set(tables.map((t) => t.schema))], }, } }, @@ -476,19 +94,13 @@ export const introspectTool: ToolConfig col.trim().replace(/"/g, '')) - } - return [] -} - -/** - * Parse OpenAPI spec to extract table schema information - * This is a fallback method when direct SQL execution is not available + * Parse a PostgREST-generated OpenAPI spec into table schemas. + * + * `isPrimaryKey` is a naming heuristic (`id` column) — PostgREST does not + * expose real constraint metadata in the spec. `isForeignKey`/`references` + * only populate when the table owner has added a `references table.column` + * SQL comment on the column. `indexes` is always empty: index definitions + * are not part of the OpenAPI spec. */ function parseOpenApiSpec(spec: any, filterSchema?: string): SupabaseTableSchema[] { const tables: SupabaseTableSchema[] = [] @@ -511,7 +123,7 @@ function parseOpenApiSpec(spec: any, filterSchema?: string): SupabaseTableSchema for (const [colName, colDef] of Object.entries(properties)) { const col = colDef as any - const isPK = col.description?.includes('primary key') || colName === 'id' + const isPK = colName === 'id' const fkMatch = col.description?.match(/references\s+(\w+)\.(\w+)/) const column: SupabaseColumnSchema = { diff --git a/apps/sim/tools/supabase/invoke_function.ts b/apps/sim/tools/supabase/invoke_function.ts index 4b02f65bd85..016a508f741 100644 --- a/apps/sim/tools/supabase/invoke_function.ts +++ b/apps/sim/tools/supabase/invoke_function.ts @@ -34,7 +34,7 @@ export const invokeFunctionTool: ToolConfig< id: 'supabase_invoke_function', name: 'Supabase Invoke Edge Function', description: 'Invoke a Supabase Edge Function over HTTP', - version: '1.0', + version: '1.0.0', params: { projectId: { diff --git a/apps/sim/tools/supabase/query.ts b/apps/sim/tools/supabase/query.ts index 0fcf1b75dda..f80bc866b31 100644 --- a/apps/sim/tools/supabase/query.ts +++ b/apps/sim/tools/supabase/query.ts @@ -7,7 +7,7 @@ export const queryTool: ToolConfig = id: 'supabase_query', name: 'Supabase Query', description: 'Query data from a Supabase table', - version: '1.0', + version: '1.0.0', params: { projectId: { diff --git a/apps/sim/tools/supabase/rpc.ts b/apps/sim/tools/supabase/rpc.ts index c9295c0854a..19c394646a9 100644 --- a/apps/sim/tools/supabase/rpc.ts +++ b/apps/sim/tools/supabase/rpc.ts @@ -7,7 +7,7 @@ export const rpcTool: ToolConfig = { id: 'supabase_rpc', name: 'Supabase RPC', description: 'Call a PostgreSQL function in Supabase', - version: '1.0', + version: '1.0.0', params: { projectId: { diff --git a/apps/sim/tools/supabase/storage_copy.ts b/apps/sim/tools/supabase/storage_copy.ts index 027a03b823f..ac396f0f687 100644 --- a/apps/sim/tools/supabase/storage_copy.ts +++ b/apps/sim/tools/supabase/storage_copy.ts @@ -10,7 +10,7 @@ export const storageCopyTool: ToolConfig = { + id: 'supabase_storage_create_signed_upload_url', + name: 'Supabase Storage Create Signed Upload URL', + description: + 'Create a temporary signed URL a client can use to upload directly to a Supabase storage bucket', + version: '1.0.0', + + params: { + projectId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Your Supabase project ID (e.g., jdrkgepadsdopsntdlom)', + }, + bucket: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The name of the storage bucket', + }, + path: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The destination path for the uploaded file (e.g., "folder/file.jpg")', + }, + upsert: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'If true, allows overwriting an existing file at this path (default: false)', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Your Supabase service role secret key', + }, + }, + + request: { + url: (params) => { + const bucket = encodeStorageSegment(params.bucket) + const path = encodeStoragePath(params.path) + return `${supabaseBaseUrl(params.projectId)}/storage/v1/object/upload/sign/${bucket}/${path}` + }, + method: 'POST', + headers: (params) => ({ + apikey: params.apiKey, + Authorization: `Bearer ${params.apiKey}`, + 'Content-Type': 'application/json', + ...(params.upsert ? { 'x-upsert': 'true' } : {}), + }), + body: () => ({}), + }, + + transformResponse: async ( + response: Response, + params?: SupabaseStorageCreateSignedUploadUrlParams + ) => { + let data + try { + data = await response.json() + } catch (parseError) { + throw new Error( + `Failed to parse Supabase storage create signed upload URL response: ${parseError}` + ) + } + + const relativeUrl = data.url + if (!relativeUrl) { + throw new Error('Supabase did not return a signed upload URL path in its response') + } + if (!params?.projectId) { + throw new Error('projectId is required to construct the signed upload URL') + } + + return { + success: true, + output: { + message: 'Successfully created signed upload URL', + signedUrl: `${supabaseBaseUrl(params.projectId)}/storage/v1${relativeUrl}`, + path: data.path, + token: data.token, + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + signedUrl: { + type: 'string', + description: 'The temporary signed URL a client can PUT the file to', + }, + path: { type: 'string', description: 'The destination object path' }, + token: { type: 'string', description: 'The upload token embedded in the signed URL' }, + }, +} diff --git a/apps/sim/tools/supabase/storage_create_signed_url.ts b/apps/sim/tools/supabase/storage_create_signed_url.ts index 93153af43db..9fd917e5bdf 100644 --- a/apps/sim/tools/supabase/storage_create_signed_url.ts +++ b/apps/sim/tools/supabase/storage_create_signed_url.ts @@ -2,7 +2,7 @@ import type { SupabaseStorageCreateSignedUrlParams, SupabaseStorageCreateSignedUrlResponse, } from '@/tools/supabase/types' -import { supabaseBaseUrl } from '@/tools/supabase/utils' +import { encodeStoragePath, encodeStorageSegment, supabaseBaseUrl } from '@/tools/supabase/utils' import type { ToolConfig } from '@/tools/types' export const storageCreateSignedUrlTool: ToolConfig< @@ -12,7 +12,7 @@ export const storageCreateSignedUrlTool: ToolConfig< id: 'supabase_storage_create_signed_url', name: 'Supabase Storage Create Signed URL', description: 'Create a temporary signed URL for a file in a Supabase storage bucket', - version: '1.0', + version: '1.0.0', params: { projectId: { @@ -55,7 +55,9 @@ export const storageCreateSignedUrlTool: ToolConfig< request: { url: (params) => { - return `${supabaseBaseUrl(params.projectId)}/storage/v1/object/sign/${params.bucket}/${params.path}` + const bucket = encodeStorageSegment(params.bucket) + const path = encodeStoragePath(params.path) + return `${supabaseBaseUrl(params.projectId)}/storage/v1/object/sign/${bucket}/${path}` }, method: 'POST', headers: (params) => ({ @@ -85,6 +87,9 @@ export const storageCreateSignedUrlTool: ToolConfig< } const relativePath = data.signedURL || data.signedUrl + if (!relativePath) { + throw new Error('Supabase did not return a signed URL path in its response') + } if (!params?.projectId) { throw new Error('projectId is required to construct the signed URL') } diff --git a/apps/sim/tools/supabase/storage_delete.ts b/apps/sim/tools/supabase/storage_delete.ts index e0228bb1810..9cf876a41c9 100644 --- a/apps/sim/tools/supabase/storage_delete.ts +++ b/apps/sim/tools/supabase/storage_delete.ts @@ -3,7 +3,7 @@ import { type SupabaseStorageDeleteParams, type SupabaseStorageDeleteResponse, } from '@/tools/supabase/types' -import { supabaseBaseUrl } from '@/tools/supabase/utils' +import { encodeStorageSegment, supabaseBaseUrl } from '@/tools/supabase/utils' import type { ToolConfig } from '@/tools/types' export const storageDeleteTool: ToolConfig< @@ -13,7 +13,7 @@ export const storageDeleteTool: ToolConfig< id: 'supabase_storage_delete', name: 'Supabase Storage Delete', description: 'Delete files from a Supabase storage bucket', - version: '1.0', + version: '1.0.0', params: { projectId: { @@ -44,7 +44,7 @@ export const storageDeleteTool: ToolConfig< request: { url: (params) => { - return `${supabaseBaseUrl(params.projectId)}/storage/v1/object/${params.bucket}` + return `${supabaseBaseUrl(params.projectId)}/storage/v1/object/${encodeStorageSegment(params.bucket)}` }, method: 'DELETE', headers: (params) => ({ diff --git a/apps/sim/tools/supabase/storage_delete_bucket.ts b/apps/sim/tools/supabase/storage_delete_bucket.ts index 621ba2341ec..25b0dae0829 100644 --- a/apps/sim/tools/supabase/storage_delete_bucket.ts +++ b/apps/sim/tools/supabase/storage_delete_bucket.ts @@ -1,9 +1,9 @@ import { - STORAGE_DELETE_BUCKET_OUTPUT_PROPERTIES, + STORAGE_MESSAGE_OUTPUT_PROPERTIES, type SupabaseStorageDeleteBucketParams, type SupabaseStorageDeleteBucketResponse, } from '@/tools/supabase/types' -import { supabaseBaseUrl } from '@/tools/supabase/utils' +import { encodeStorageSegment, supabaseBaseUrl } from '@/tools/supabase/utils' import type { ToolConfig } from '@/tools/types' export const storageDeleteBucketTool: ToolConfig< @@ -13,7 +13,7 @@ export const storageDeleteBucketTool: ToolConfig< id: 'supabase_storage_delete_bucket', name: 'Supabase Storage Delete Bucket', description: 'Delete a storage bucket in Supabase', - version: '1.0', + version: '1.0.0', params: { projectId: { @@ -38,7 +38,7 @@ export const storageDeleteBucketTool: ToolConfig< request: { url: (params) => { - return `${supabaseBaseUrl(params.projectId)}/storage/v1/bucket/${params.bucket}` + return `${supabaseBaseUrl(params.projectId)}/storage/v1/bucket/${encodeStorageSegment(params.bucket)}` }, method: 'DELETE', headers: (params) => ({ @@ -70,7 +70,7 @@ export const storageDeleteBucketTool: ToolConfig< results: { type: 'object', description: 'Delete operation result', - properties: STORAGE_DELETE_BUCKET_OUTPUT_PROPERTIES, + properties: STORAGE_MESSAGE_OUTPUT_PROPERTIES, }, }, } diff --git a/apps/sim/tools/supabase/storage_download.ts b/apps/sim/tools/supabase/storage_download.ts index d47977f61d8..4079f2e4330 100644 --- a/apps/sim/tools/supabase/storage_download.ts +++ b/apps/sim/tools/supabase/storage_download.ts @@ -4,7 +4,7 @@ import { type SupabaseStorageDownloadParams, type SupabaseStorageDownloadResponse, } from '@/tools/supabase/types' -import { supabaseBaseUrl } from '@/tools/supabase/utils' +import { encodeStoragePath, encodeStorageSegment, supabaseBaseUrl } from '@/tools/supabase/utils' import type { ToolConfig } from '@/tools/types' const logger = createLogger('SupabaseStorageDownloadTool') @@ -16,7 +16,7 @@ export const storageDownloadTool: ToolConfig< id: 'supabase_storage_download', name: 'Supabase Storage Download', description: 'Download a file from a Supabase storage bucket', - version: '1.0', + version: '1.0.0', params: { projectId: { @@ -53,7 +53,9 @@ export const storageDownloadTool: ToolConfig< request: { url: (params) => { - return `${supabaseBaseUrl(params.projectId)}/storage/v1/object/${params.bucket}/${params.path}` + const bucket = encodeStorageSegment(params.bucket) + const path = encodeStoragePath(params.path) + return `${supabaseBaseUrl(params.projectId)}/storage/v1/object/${bucket}/${path}` }, method: 'GET', headers: (params) => ({ diff --git a/apps/sim/tools/supabase/storage_empty_bucket.ts b/apps/sim/tools/supabase/storage_empty_bucket.ts new file mode 100644 index 00000000000..4e9386aab52 --- /dev/null +++ b/apps/sim/tools/supabase/storage_empty_bucket.ts @@ -0,0 +1,80 @@ +import { + STORAGE_MESSAGE_OUTPUT_PROPERTIES, + type SupabaseStorageEmptyBucketParams, + type SupabaseStorageEmptyBucketResponse, +} from '@/tools/supabase/types' +import { encodeStorageSegment, supabaseBaseUrl } from '@/tools/supabase/utils' +import type { ToolConfig } from '@/tools/types' + +export const storageEmptyBucketTool: ToolConfig< + SupabaseStorageEmptyBucketParams, + SupabaseStorageEmptyBucketResponse +> = { + id: 'supabase_storage_empty_bucket', + name: 'Supabase Storage Empty Bucket', + description: + 'Delete all objects inside a Supabase storage bucket without deleting the bucket itself', + version: '1.0.0', + + params: { + projectId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Your Supabase project ID (e.g., jdrkgepadsdopsntdlom)', + }, + bucket: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The name of the bucket to empty', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Your Supabase service role secret key', + }, + }, + + request: { + url: (params) => { + const bucket = encodeStorageSegment(params.bucket) + return `${supabaseBaseUrl(params.projectId)}/storage/v1/bucket/${bucket}/empty` + }, + method: 'POST', + headers: (params) => ({ + apikey: params.apiKey, + Authorization: `Bearer ${params.apiKey}`, + 'Content-Type': 'application/json', + }), + body: () => ({}), + }, + + transformResponse: async (response: Response) => { + let data + try { + data = await response.json() + } catch (parseError) { + throw new Error(`Failed to parse Supabase storage empty bucket response: ${parseError}`) + } + + return { + success: true, + output: { + message: data.message || 'Successfully emptied storage bucket', + results: data, + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + results: { + type: 'object', + description: 'Empty bucket operation result', + properties: STORAGE_MESSAGE_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/supabase/storage_get_public_url.ts b/apps/sim/tools/supabase/storage_get_public_url.ts index 64bee51541a..52f1c51c136 100644 --- a/apps/sim/tools/supabase/storage_get_public_url.ts +++ b/apps/sim/tools/supabase/storage_get_public_url.ts @@ -2,7 +2,7 @@ import type { SupabaseStorageGetPublicUrlParams, SupabaseStorageGetPublicUrlResponse, } from '@/tools/supabase/types' -import { supabaseBaseUrl } from '@/tools/supabase/utils' +import { encodeStoragePath, encodeStorageSegment, supabaseBaseUrl } from '@/tools/supabase/utils' import type { ToolConfig } from '@/tools/types' export const storageGetPublicUrlTool: ToolConfig< @@ -12,7 +12,7 @@ export const storageGetPublicUrlTool: ToolConfig< id: 'supabase_storage_get_public_url', name: 'Supabase Storage Get Public URL', description: 'Get the public URL for a file in a Supabase storage bucket', - version: '1.0', + version: '1.0.0', params: { projectId: { @@ -48,7 +48,9 @@ export const storageGetPublicUrlTool: ToolConfig< * its response. */ directExecution: async (params: SupabaseStorageGetPublicUrlParams) => { - let publicUrl = `${supabaseBaseUrl(params.projectId)}/storage/v1/object/public/${params.bucket}/${params.path}` + const bucket = encodeStorageSegment(params.bucket) + const path = encodeStoragePath(params.path) + let publicUrl = `${supabaseBaseUrl(params.projectId)}/storage/v1/object/public/${bucket}/${path}` if (params.download) { publicUrl += '?download=true' @@ -65,8 +67,11 @@ export const storageGetPublicUrlTool: ToolConfig< }, request: { - url: (params) => - `${supabaseBaseUrl(params.projectId)}/storage/v1/object/public/${params.bucket}/${params.path}`, + url: (params) => { + const bucket = encodeStorageSegment(params.bucket) + const path = encodeStoragePath(params.path) + return `${supabaseBaseUrl(params.projectId)}/storage/v1/object/public/${bucket}/${path}` + }, method: 'GET', headers: () => ({}), }, diff --git a/apps/sim/tools/supabase/storage_list.ts b/apps/sim/tools/supabase/storage_list.ts index fd13ca475cb..2d20468f3d4 100644 --- a/apps/sim/tools/supabase/storage_list.ts +++ b/apps/sim/tools/supabase/storage_list.ts @@ -3,14 +3,14 @@ import { type SupabaseStorageListParams, type SupabaseStorageListResponse, } from '@/tools/supabase/types' -import { supabaseBaseUrl } from '@/tools/supabase/utils' +import { encodeStorageSegment, supabaseBaseUrl } from '@/tools/supabase/utils' import type { ToolConfig } from '@/tools/types' export const storageListTool: ToolConfig = { id: 'supabase_storage_list', name: 'Supabase Storage List', description: 'List files in a Supabase storage bucket', - version: '1.0', + version: '1.0.0', params: { projectId: { @@ -72,7 +72,7 @@ export const storageListTool: ToolConfig { - return `${supabaseBaseUrl(params.projectId)}/storage/v1/object/list/${params.bucket}` + return `${supabaseBaseUrl(params.projectId)}/storage/v1/object/list/${encodeStorageSegment(params.bucket)}` }, method: 'POST', headers: (params) => ({ diff --git a/apps/sim/tools/supabase/storage_list_buckets.ts b/apps/sim/tools/supabase/storage_list_buckets.ts index a7ede783ca4..e03ddff3016 100644 --- a/apps/sim/tools/supabase/storage_list_buckets.ts +++ b/apps/sim/tools/supabase/storage_list_buckets.ts @@ -13,7 +13,7 @@ export const storageListBucketsTool: ToolConfig< id: 'supabase_storage_list_buckets', name: 'Supabase Storage List Buckets', description: 'List all storage buckets in Supabase', - version: '1.0', + version: '1.0.0', params: { projectId: { diff --git a/apps/sim/tools/supabase/storage_move.ts b/apps/sim/tools/supabase/storage_move.ts index 5f3c2edf30a..23e149e21ee 100644 --- a/apps/sim/tools/supabase/storage_move.ts +++ b/apps/sim/tools/supabase/storage_move.ts @@ -10,7 +10,7 @@ export const storageMoveTool: ToolConfig = { + id: 'supabase_storage_update_bucket', + name: 'Supabase Storage Update Bucket', + description: 'Update the configuration of an existing Supabase storage bucket', + version: '1.0.0', + + params: { + projectId: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Your Supabase project ID (e.g., jdrkgepadsdopsntdlom)', + }, + bucket: { + type: 'string', + required: true, + visibility: 'user-or-llm', + description: 'The name of the bucket to update', + }, + isPublic: { + type: 'boolean', + required: false, + visibility: 'user-or-llm', + description: 'Whether the bucket should be publicly accessible (default: false)', + }, + fileSizeLimit: { + type: 'number', + required: false, + visibility: 'user-or-llm', + description: 'Maximum file size in bytes (optional)', + }, + allowedMimeTypes: { + type: 'array', + required: false, + visibility: 'user-or-llm', + description: 'Array of allowed MIME types (e.g., ["image/png", "image/jpeg"])', + }, + apiKey: { + type: 'string', + required: true, + visibility: 'user-only', + description: 'Your Supabase service role secret key', + }, + }, + + request: { + url: (params) => { + const bucket = encodeStorageSegment(params.bucket) + return `${supabaseBaseUrl(params.projectId)}/storage/v1/bucket/${bucket}` + }, + method: 'PUT', + headers: (params) => ({ + apikey: params.apiKey, + Authorization: `Bearer ${params.apiKey}`, + 'Content-Type': 'application/json', + }), + body: (params) => { + const payload: any = { + id: params.bucket, + name: params.bucket, + public: params.isPublic || false, + } + + if (params.fileSizeLimit) { + payload.file_size_limit = Number(params.fileSizeLimit) + } + + if (params.allowedMimeTypes && params.allowedMimeTypes.length > 0) { + payload.allowed_mime_types = params.allowedMimeTypes + } + + return payload + }, + }, + + transformResponse: async (response: Response) => { + let data + try { + data = await response.json() + } catch (parseError) { + throw new Error(`Failed to parse Supabase storage update bucket response: ${parseError}`) + } + + return { + success: true, + output: { + message: 'Successfully updated storage bucket', + results: data, + }, + error: undefined, + } + }, + + outputs: { + message: { type: 'string', description: 'Operation status message' }, + results: { + type: 'object', + description: 'Update operation result', + properties: STORAGE_MESSAGE_OUTPUT_PROPERTIES, + }, + }, +} diff --git a/apps/sim/tools/supabase/storage_upload.ts b/apps/sim/tools/supabase/storage_upload.ts index 8f99c182797..b4298fd6bc8 100644 --- a/apps/sim/tools/supabase/storage_upload.ts +++ b/apps/sim/tools/supabase/storage_upload.ts @@ -12,7 +12,7 @@ export const storageUploadTool: ToolConfig< id: 'supabase_storage_upload', name: 'Supabase Storage Upload', description: 'Upload a file to a Supabase storage bucket', - version: '1.0', + version: '1.0.0', params: { projectId: { diff --git a/apps/sim/tools/supabase/text_search.ts b/apps/sim/tools/supabase/text_search.ts index 5aaef93bdc1..271518afaba 100644 --- a/apps/sim/tools/supabase/text_search.ts +++ b/apps/sim/tools/supabase/text_search.ts @@ -7,7 +7,7 @@ export const textSearchTool: ToolConfig /** - * Output definition for storage delete bucket response - * Returns a confirmation message + * Output definition for storage bucket operations that only return a + * confirmation message (delete bucket, update bucket, empty bucket) + * @see https://github.com/supabase/storage-js/blob/main/src/packages/StorageBucketApi.ts */ -export const STORAGE_DELETE_BUCKET_OUTPUT_PROPERTIES = { +export const STORAGE_MESSAGE_OUTPUT_PROPERTIES = { message: { type: 'string', description: 'Operation status message' }, } as const satisfies Record @@ -233,11 +234,18 @@ export const INTROSPECT_COLUMN_OUTPUT_PROPERTIES = { type: { type: 'string', description: 'Column data type' }, nullable: { type: 'boolean', description: 'Whether the column allows null values' }, default: { type: 'string', description: 'Default value for the column', optional: true }, - isPrimaryKey: { type: 'boolean', description: 'Whether the column is a primary key' }, - isForeignKey: { type: 'boolean', description: 'Whether the column is a foreign key' }, + isPrimaryKey: { + type: 'boolean', + description: 'Best-effort guess based on the column being named "id" (not authoritative)', + }, + isForeignKey: { + type: 'boolean', + description: + 'True only if the column has a "references table.column" SQL comment; most databases will show false even for real foreign keys', + }, references: { type: 'object', - description: 'Foreign key reference details', + description: 'Foreign key reference details, when detected via SQL comment', optional: true, properties: INTROSPECT_REFERENCE_OUTPUT_PROPERTIES, }, @@ -294,7 +302,8 @@ export const INTROSPECT_TABLE_OUTPUT_PROPERTIES = { }, indexes: { type: 'array', - description: 'Array of index definitions', + description: + 'Always empty — index definitions are not exposed by the OpenAPI spec this tool reads', items: { type: 'object', properties: INTROSPECT_INDEX_OUTPUT_PROPERTIES, @@ -589,6 +598,43 @@ export interface SupabaseStorageCreateSignedUrlResponse extends ToolResponse { error?: string } +export interface SupabaseStorageCreateSignedUploadUrlParams { + apiKey: string + projectId: string + bucket: string + path: string + upsert?: boolean +} + +export interface SupabaseStorageCreateSignedUploadUrlResponse extends ToolResponse { + output: { + message: string + signedUrl: string + path: string + token: string + } + error?: string +} + +export interface SupabaseStorageUpdateBucketParams { + apiKey: string + projectId: string + bucket: string + isPublic?: boolean + fileSizeLimit?: number + allowedMimeTypes?: string[] +} + +export interface SupabaseStorageUpdateBucketResponse extends SupabaseBaseResponse {} + +export interface SupabaseStorageEmptyBucketParams { + apiKey: string + projectId: string + bucket: string +} + +export interface SupabaseStorageEmptyBucketResponse extends SupabaseBaseResponse {} + /** * Parameters for introspecting a Supabase database schema */ diff --git a/apps/sim/tools/supabase/update.ts b/apps/sim/tools/supabase/update.ts index 28c26e53d89..b8e429cefac 100644 --- a/apps/sim/tools/supabase/update.ts +++ b/apps/sim/tools/supabase/update.ts @@ -7,7 +7,7 @@ export const updateTool: ToolConfig encodeURIComponent(segment.trim())) + .join('/') +} diff --git a/apps/sim/tools/supabase/vector_search.ts b/apps/sim/tools/supabase/vector_search.ts index e8c8cc5d166..6ddfbe0fd6a 100644 --- a/apps/sim/tools/supabase/vector_search.ts +++ b/apps/sim/tools/supabase/vector_search.ts @@ -13,7 +13,7 @@ export const vectorSearchTool: ToolConfig< id: 'supabase_vector_search', name: 'Supabase Vector Search', description: 'Perform similarity search using pgvector in a Supabase table', - version: '1.0', + version: '1.0.0', params: { projectId: { From 60695c76b5ff4ca6880d35b540f2c24610fab51f Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 2 Jul 2026 09:02:28 -0700 Subject: [PATCH 2/5] fix(supabase): address review feedback on update-bucket, signed upload url, and introspect - storage_update_bucket now fetches the bucket's current config first and only overrides isPublic/fileSizeLimit/allowedMimeTypes when the caller explicitly provides them, instead of silently forcing public: false on every update (the Storage API's PUT is a full replace, not a patch) - storage_create_signed_upload_url and storage_empty_bucket now check response.ok before surfacing an error, so a non-2xx response reports Supabase's actual error instead of a misleading parse-side message - introspect.ts drops the spurious ?select=* query param from the OpenAPI spec request (meaningless on the root spec endpoint) (cherry picked from commit 557e4074594464262b2f631ca66272bf60f027f8) --- apps/sim/tools/supabase/introspect.ts | 2 +- .../storage_create_signed_upload_url.ts | 6 + .../tools/supabase/storage_empty_bucket.ts | 6 + .../tools/supabase/storage_update_bucket.ts | 116 +++++++++++++----- 4 files changed, 97 insertions(+), 33 deletions(-) diff --git a/apps/sim/tools/supabase/introspect.ts b/apps/sim/tools/supabase/introspect.ts index cea984f30fb..aa717294c53 100644 --- a/apps/sim/tools/supabase/introspect.ts +++ b/apps/sim/tools/supabase/introspect.ts @@ -51,7 +51,7 @@ export const introspectTool: ToolConfig `${supabaseBaseUrl(params.projectId)}/rest/v1/?select=*`, + url: (params) => `${supabaseBaseUrl(params.projectId)}/rest/v1/`, method: 'GET', headers: (params) => ({ apikey: params.apiKey, diff --git a/apps/sim/tools/supabase/storage_create_signed_upload_url.ts b/apps/sim/tools/supabase/storage_create_signed_upload_url.ts index 1a1e0c4aba5..0a7ce6113a1 100644 --- a/apps/sim/tools/supabase/storage_create_signed_upload_url.ts +++ b/apps/sim/tools/supabase/storage_create_signed_upload_url.ts @@ -77,6 +77,12 @@ export const storageCreateSignedUploadUrlTool: ToolConfig< ) } + if (!response.ok) { + throw new Error( + `Failed to create signed upload URL: ${data.message || data.error || response.statusText}` + ) + } + const relativeUrl = data.url if (!relativeUrl) { throw new Error('Supabase did not return a signed upload URL path in its response') diff --git a/apps/sim/tools/supabase/storage_empty_bucket.ts b/apps/sim/tools/supabase/storage_empty_bucket.ts index 4e9386aab52..ba86d845bea 100644 --- a/apps/sim/tools/supabase/storage_empty_bucket.ts +++ b/apps/sim/tools/supabase/storage_empty_bucket.ts @@ -59,6 +59,12 @@ export const storageEmptyBucketTool: ToolConfig< throw new Error(`Failed to parse Supabase storage empty bucket response: ${parseError}`) } + if (!response.ok) { + throw new Error( + `Failed to empty storage bucket: ${data.message || data.error || response.statusText}` + ) + } + return { success: true, output: { diff --git a/apps/sim/tools/supabase/storage_update_bucket.ts b/apps/sim/tools/supabase/storage_update_bucket.ts index 9329ba21b21..7e4339805e3 100644 --- a/apps/sim/tools/supabase/storage_update_bucket.ts +++ b/apps/sim/tools/supabase/storage_update_bucket.ts @@ -1,3 +1,4 @@ +import { getErrorMessage } from '@sim/utils/errors' import { STORAGE_MESSAGE_OUTPUT_PROPERTIES, type SupabaseStorageUpdateBucketParams, @@ -32,19 +33,21 @@ export const storageUpdateBucketTool: ToolConfig< type: 'boolean', required: false, visibility: 'user-or-llm', - description: 'Whether the bucket should be publicly accessible (default: false)', + description: + 'Whether the bucket should be publicly accessible (leave unset to keep the current value)', }, fileSizeLimit: { type: 'number', required: false, visibility: 'user-or-llm', - description: 'Maximum file size in bytes (optional)', + description: 'Maximum file size in bytes (leave unset to keep the current value)', }, allowedMimeTypes: { type: 'array', required: false, visibility: 'user-or-llm', - description: 'Array of allowed MIME types (e.g., ["image/png", "image/jpeg"])', + description: + 'Array of allowed MIME types (e.g., ["image/png", "image/jpeg"]) — leave unset to keep the current value', }, apiKey: { type: 'string', @@ -54,51 +57,100 @@ export const storageUpdateBucketTool: ToolConfig< }, }, + /** + * Unreachable: `directExecution` below always handles this tool because + * the update must first read the bucket's current configuration (the + * Storage API's update-bucket endpoint is a full-replace PUT, not a + * partial patch). Declared only to satisfy `ToolConfig`'s required + * `request` field. + */ request: { - url: (params) => { - const bucket = encodeStorageSegment(params.bucket) - return `${supabaseBaseUrl(params.projectId)}/storage/v1/bucket/${bucket}` - }, + url: (params) => + `${supabaseBaseUrl(params.projectId)}/storage/v1/bucket/${encodeStorageSegment(params.bucket)}`, method: 'PUT', headers: (params) => ({ apikey: params.apiKey, Authorization: `Bearer ${params.apiKey}`, 'Content-Type': 'application/json', }), - body: (params) => { + }, + + /** + * The Storage API's update-bucket endpoint is a full-replace PUT + * (`{id, name, public, file_size_limit?, allowed_mime_types?}`), not a + * partial patch. Fetching the bucket's current configuration first lets + * unset params fall back to their existing value instead of silently + * resetting to a default (e.g. flipping a public bucket private just + * because `isPublic` wasn't provided). + */ + directExecution: async ( + params: SupabaseStorageUpdateBucketParams + ): Promise => { + const baseUrl = supabaseBaseUrl(params.projectId) + const bucket = encodeStorageSegment(params.bucket) + const headers = { + apikey: params.apiKey, + Authorization: `Bearer ${params.apiKey}`, + 'Content-Type': 'application/json', + } + + try { + const currentResponse = await fetch(`${baseUrl}/storage/v1/bucket/${bucket}`, { + method: 'GET', + headers, + }) + + if (!currentResponse.ok) { + const errorText = await currentResponse.text() + throw new Error(`Failed to read current bucket configuration: ${errorText}`) + } + + const current = await currentResponse.json() + const payload: any = { id: params.bucket, name: params.bucket, - public: params.isPublic || false, + public: params.isPublic !== undefined ? params.isPublic : Boolean(current.public), + file_size_limit: + params.fileSizeLimit !== undefined + ? Number(params.fileSizeLimit) + : (current.file_size_limit ?? null), + allowed_mime_types: + params.allowedMimeTypes !== undefined + ? params.allowedMimeTypes + : (current.allowed_mime_types ?? null), } - if (params.fileSizeLimit) { - payload.file_size_limit = Number(params.fileSizeLimit) - } + const updateResponse = await fetch(`${baseUrl}/storage/v1/bucket/${bucket}`, { + method: 'PUT', + headers, + body: JSON.stringify(payload), + }) - if (params.allowedMimeTypes && params.allowedMimeTypes.length > 0) { - payload.allowed_mime_types = params.allowedMimeTypes + if (!updateResponse.ok) { + const errorText = await updateResponse.text() + throw new Error(`Failed to update bucket: ${errorText}`) } - return payload - }, - }, - - transformResponse: async (response: Response) => { - let data - try { - data = await response.json() - } catch (parseError) { - throw new Error(`Failed to parse Supabase storage update bucket response: ${parseError}`) - } + const data = await updateResponse.json() - return { - success: true, - output: { - message: 'Successfully updated storage bucket', - results: data, - }, - error: undefined, + return { + success: true, + output: { + message: 'Successfully updated storage bucket', + results: data, + }, + error: undefined, + } + } catch (error) { + return { + success: false, + output: { + message: 'Failed to update storage bucket', + results: {}, + }, + error: getErrorMessage(error, 'Unknown error occurred'), + } } }, From 7d4e08606b752db49e0d68027a55a7013e87337d Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 2 Jul 2026 09:11:54 -0700 Subject: [PATCH 3/5] fix(supabase): tri-state bucket visibility on update, empty-string param coercion, introspect schema header - introspect.ts now sends Accept-Profile when a schema param is given, matching the convention used by the other DB tools, so schema-scoped introspection actually reads from that schema's spec - storage_update_bucket.ts treats an empty-string param the same as "not provided" (e.g. an untouched file size limit input no longer coerces to 0) - the shared "Public Bucket" dropdown always sent an explicit true/false for storage_update_bucket (its default masked "not touched"), which could still flip a public bucket private; added a dedicated "Keep Current / True / False" control for the update operation so omitting a choice genuinely omits the isPublic override (cherry picked from commit c80567b980e9a547b892a222cebee2bd03d89420) --- apps/sim/blocks/blocks/supabase.ts | 30 ++++++++++++++++++- apps/sim/tools/supabase/introspect.ts | 1 + .../tools/supabase/storage_update_bucket.ts | 23 ++++++++------ 3 files changed, 44 insertions(+), 10 deletions(-) diff --git a/apps/sim/blocks/blocks/supabase.ts b/apps/sim/blocks/blocks/supabase.ts index 87711781c9a..adadb2b1523 100644 --- a/apps/sim/blocks/blocks/supabase.ts +++ b/apps/sim/blocks/blocks/supabase.ts @@ -923,7 +923,19 @@ Return ONLY the PostgREST filter expression - no explanations, no markdown, no e { label: 'True (Public)', id: 'true' }, ], value: () => 'false', - condition: { field: 'operation', value: ['storage_create_bucket', 'storage_update_bucket'] }, + condition: { field: 'operation', value: 'storage_create_bucket' }, + }, + { + id: 'updateIsPublic', + title: 'Public Bucket', + type: 'dropdown', + options: [ + { label: 'Keep Current', id: '' }, + { label: 'False (Private)', id: 'false' }, + { label: 'True (Public)', id: 'true' }, + ], + value: () => '', + condition: { field: 'operation', value: 'storage_update_bucket' }, }, { id: 'fileSizeLimit', @@ -1065,6 +1077,7 @@ Return ONLY the PostgREST filter expression - no explanations, no markdown, no e functionBody, functionHeaders, method, + updateIsPublic, ...rest } = params @@ -1237,6 +1250,16 @@ Return ONLY the PostgREST filter expression - no explanations, no markdown, no e result.isPublic = parsedIsPublic } + // "Keep Current" (empty string) means the caller didn't choose to + // override visibility — omit `isPublic` entirely so the update + // tool preserves the bucket's existing public/private setting + // instead of defaulting to false. + if (operation === 'storage_update_bucket' && updateIsPublic !== undefined) { + if (updateIsPublic === 'true' || updateIsPublic === 'false') { + result.isPublic = updateIsPublic === 'true' + } + } + if (normalizedFileData !== undefined) { result.fileData = normalizedFileData } @@ -1291,6 +1314,11 @@ Return ONLY the PostgREST filter expression - no explanations, no markdown, no e search: { type: 'string', description: 'Search term for filtering' }, expiresIn: { type: 'number', description: 'Expiration time in seconds for signed URL' }, isPublic: { type: 'boolean', description: 'Whether bucket should be public' }, + updateIsPublic: { + type: 'string', + description: + 'Visibility override for bucket update: "" keeps the current value, "true"/"false" overrides it', + }, fileSizeLimit: { type: 'number', description: 'Maximum file size in bytes' }, allowedMimeTypes: { type: 'array', description: 'Array of allowed MIME types' }, }, diff --git a/apps/sim/tools/supabase/introspect.ts b/apps/sim/tools/supabase/introspect.ts index aa717294c53..55ed9d09605 100644 --- a/apps/sim/tools/supabase/introspect.ts +++ b/apps/sim/tools/supabase/introspect.ts @@ -57,6 +57,7 @@ export const introspectTool: ToolConfig + value !== undefined && value !== null && value !== '' + const payload: any = { id: params.bucket, name: params.bucket, - public: params.isPublic !== undefined ? params.isPublic : Boolean(current.public), - file_size_limit: - params.fileSizeLimit !== undefined - ? Number(params.fileSizeLimit) - : (current.file_size_limit ?? null), - allowed_mime_types: - params.allowedMimeTypes !== undefined - ? params.allowedMimeTypes - : (current.allowed_mime_types ?? null), + public: hasValue(params.isPublic) ? params.isPublic : Boolean(current.public), + file_size_limit: hasValue(params.fileSizeLimit) + ? Number(params.fileSizeLimit) + : (current.file_size_limit ?? null), + allowed_mime_types: hasValue(params.allowedMimeTypes) + ? params.allowedMimeTypes + : (current.allowed_mime_types ?? null), } const updateResponse = await fetch(`${baseUrl}/storage/v1/bucket/${bucket}`, { From 46352f3a5b79aad14b5d97c9fcab3109537de469 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 2 Jul 2026 09:18:54 -0700 Subject: [PATCH 4/5] fix(supabase): check response.ok before parsing storage_create_signed_url MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Matches the pattern already applied to the new signed-upload-url tool this session — a non-2xx response now surfaces Supabase's actual error message instead of the generic "did not return a signed URL path" one. (cherry picked from commit 9f40427dd80ec21b4af1e85c9c8218fd961d6931) --- apps/sim/tools/supabase/storage_create_signed_url.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/apps/sim/tools/supabase/storage_create_signed_url.ts b/apps/sim/tools/supabase/storage_create_signed_url.ts index 9fd917e5bdf..ddf3a938fff 100644 --- a/apps/sim/tools/supabase/storage_create_signed_url.ts +++ b/apps/sim/tools/supabase/storage_create_signed_url.ts @@ -86,6 +86,12 @@ export const storageCreateSignedUrlTool: ToolConfig< throw new Error(`Failed to parse Supabase storage create signed URL response: ${parseError}`) } + if (!response.ok) { + throw new Error( + `Failed to create signed URL: ${data.message || data.error || response.statusText}` + ) + } + const relativePath = data.signedURL || data.signedUrl if (!relativePath) { throw new Error('Supabase did not return a signed URL path in its response') From 19fe7f9fc5e830f1afb43c35867e58e7f14b5966 Mon Sep 17 00:00:00 2001 From: waleed Date: Thu, 2 Jul 2026 10:36:07 -0700 Subject: [PATCH 5/5] fix(supabase): correct download param semantics, echoed path field, and schema hint mismatch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by a second, per-tool parallel validation pass against live Supabase/PostgREST docs and source: - storage_get_public_url.ts and storage_create_signed_url.ts sent download=true literally, which Supabase's Storage API treats as a filename override (renaming the downloaded file to "true") rather than a boolean flag; forcing a download while keeping the original filename requires an empty download= value - storage_create_signed_url.ts also sent download in the POST body, which the sign endpoint ignores entirely — forcing download only works as a query param on the returned URL - storage_create_signed_upload_url.ts read a `path` field from the API response that doesn't exist there; it now echoes the caller-supplied path, matching the official storage-js client - introspect.ts's nullable heuristic didn't disclose that a NOT NULL column with a default is misreported as nullable (PostgREST omits it from the OpenAPI required list in that case); documented alongside the other already-disclosed heuristics, and removed a tautological schema-filter check left over from an earlier revision - insert.ts/upsert.ts's `data` param reverted from 'json' back to 'array': the 'json' type mapped to an LLM-facing JSON-schema type of 'object', which contradicted the param's own description ("array of objects or a single object") --- apps/sim/tools/supabase/insert.ts | 2 +- apps/sim/tools/supabase/introspect.ts | 28 ++++++++++--------- .../storage_create_signed_upload_url.ts | 5 +++- .../supabase/storage_create_signed_url.ts | 24 ++++++++-------- .../tools/supabase/storage_get_public_url.ts | 6 +++- apps/sim/tools/supabase/types.ts | 6 +++- apps/sim/tools/supabase/upsert.ts | 2 +- 7 files changed, 43 insertions(+), 30 deletions(-) diff --git a/apps/sim/tools/supabase/insert.ts b/apps/sim/tools/supabase/insert.ts index e0fca9a9b0c..2bff0bcec1b 100644 --- a/apps/sim/tools/supabase/insert.ts +++ b/apps/sim/tools/supabase/insert.ts @@ -30,7 +30,7 @@ export const insertTool: ToolConfig = { id: 'supabase_introspect', @@ -152,18 +154,18 @@ function parseOpenApiSpec(spec: any, filterSchema?: string): SupabaseTableSchema columns.push(column) } - const schemaName = filterSchema || 'public' - - if (!filterSchema || schemaName === filterSchema) { - tables.push({ - name: tableName, - schema: schemaName, - columns, - primaryKey, - foreignKeys, - indexes: [], - }) - } + tables.push({ + name: tableName, + // The OpenAPI spec doesn't map tables to schemas, so this can only + // reflect the schema that was actually requested (or "public" when + // introspecting the default schema) — not necessarily the table's + // true schema in a multi-schema database. + schema: filterSchema || 'public', + columns, + primaryKey, + foreignKeys, + indexes: [], + }) } return tables diff --git a/apps/sim/tools/supabase/storage_create_signed_upload_url.ts b/apps/sim/tools/supabase/storage_create_signed_upload_url.ts index 0a7ce6113a1..d9b78bc3bff 100644 --- a/apps/sim/tools/supabase/storage_create_signed_upload_url.ts +++ b/apps/sim/tools/supabase/storage_create_signed_upload_url.ts @@ -96,7 +96,10 @@ export const storageCreateSignedUploadUrlTool: ToolConfig< output: { message: 'Successfully created signed upload URL', signedUrl: `${supabaseBaseUrl(params.projectId)}/storage/v1${relativeUrl}`, - path: data.path, + // The API response has no `path` field — it's the caller-supplied + // destination path, echoed back the same way the official + // storage-js client does. + path: params.path, token: data.token, }, error: undefined, diff --git a/apps/sim/tools/supabase/storage_create_signed_url.ts b/apps/sim/tools/supabase/storage_create_signed_url.ts index ddf3a938fff..eb10d497667 100644 --- a/apps/sim/tools/supabase/storage_create_signed_url.ts +++ b/apps/sim/tools/supabase/storage_create_signed_url.ts @@ -65,17 +65,9 @@ export const storageCreateSignedUrlTool: ToolConfig< Authorization: `Bearer ${params.apiKey}`, 'Content-Type': 'application/json', }), - body: (params) => { - const payload: any = { - expiresIn: Number(params.expiresIn), - } - - if (params.download !== undefined) { - payload.download = params.download - } - - return payload - }, + body: (params) => ({ + expiresIn: Number(params.expiresIn), + }), }, transformResponse: async (response: Response, params?: SupabaseStorageCreateSignedUrlParams) => { @@ -99,7 +91,15 @@ export const storageCreateSignedUrlTool: ToolConfig< if (!params?.projectId) { throw new Error('projectId is required to construct the signed URL') } - const fullUrl = `${supabaseBaseUrl(params.projectId)}/storage/v1${relativePath}` + let fullUrl = `${supabaseBaseUrl(params.projectId)}/storage/v1${relativePath}` + + // The Storage API ignores a `download` field in the sign request body — + // forcing download is a client-side query param on the resulting URL. + // An empty value preserves the original filename; a non-empty value + // would override it, so a boolean "true" must never be sent literally. + if (params.download) { + fullUrl += fullUrl.includes('?') ? '&download=' : '?download=' + } return { success: true, diff --git a/apps/sim/tools/supabase/storage_get_public_url.ts b/apps/sim/tools/supabase/storage_get_public_url.ts index 52f1c51c136..b189f9bd543 100644 --- a/apps/sim/tools/supabase/storage_get_public_url.ts +++ b/apps/sim/tools/supabase/storage_get_public_url.ts @@ -53,7 +53,11 @@ export const storageGetPublicUrlTool: ToolConfig< let publicUrl = `${supabaseBaseUrl(params.projectId)}/storage/v1/object/public/${bucket}/${path}` if (params.download) { - publicUrl += '?download=true' + // Supabase's `download` query param is a filename override, not a + // boolean flag — an empty value forces a download while preserving + // the original filename. Sending the literal string "true" would + // instead rename the downloaded file to "true". + publicUrl += '?download=' } return { diff --git a/apps/sim/tools/supabase/types.ts b/apps/sim/tools/supabase/types.ts index 99d6492b47e..40a9c4313c3 100644 --- a/apps/sim/tools/supabase/types.ts +++ b/apps/sim/tools/supabase/types.ts @@ -232,7 +232,11 @@ export const INTROSPECT_REFERENCE_OUTPUT_PROPERTIES = { export const INTROSPECT_COLUMN_OUTPUT_PROPERTIES = { name: { type: 'string', description: 'Column name' }, type: { type: 'string', description: 'Column data type' }, - nullable: { type: 'boolean', description: 'Whether the column allows null values' }, + nullable: { + type: 'boolean', + description: + 'Whether the column allows null values — a NOT NULL column that has a default value is misreported as nullable, since the OpenAPI spec this is derived from omits it from the required list in that case', + }, default: { type: 'string', description: 'Default value for the column', optional: true }, isPrimaryKey: { type: 'boolean', diff --git a/apps/sim/tools/supabase/upsert.ts b/apps/sim/tools/supabase/upsert.ts index 3795b2a6458..129a89e0b37 100644 --- a/apps/sim/tools/supabase/upsert.ts +++ b/apps/sim/tools/supabase/upsert.ts @@ -30,7 +30,7 @@ export const upsertTool: ToolConfig