mirror of
https://github.com/simstudioai/sim.git
synced 2026-09-24 15:45:35 +08:00
fix(supabase): remove non-functional SQL introspection, harden storage encoding, add missing endpoints (#5371)
* 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)
* 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)
* 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)
* fix(supabase): check response.ok before parsing storage_create_signed_url
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)
* fix(supabase): correct download param semantics, echoed path field, and schema hint mismatch
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")
This commit is contained in:
@@ -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<string, string> = {
|
||||
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,
|
||||
|
||||
@@ -45,7 +45,10 @@ export const SupabaseBlock: BlockConfig<SupabaseResponse> = {
|
||||
{ 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,
|
||||
@@ -919,19 +925,53 @@ Return ONLY the PostgREST filter expression - no explanations, no markdown, no e
|
||||
value: () => 'false',
|
||||
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',
|
||||
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 +995,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 +1044,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 +1056,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}`)
|
||||
}
|
||||
@@ -1028,6 +1077,7 @@ Return ONLY the PostgREST filter expression - no explanations, no markdown, no e
|
||||
functionBody,
|
||||
functionHeaders,
|
||||
method,
|
||||
updateIsPublic,
|
||||
...rest
|
||||
} = params
|
||||
|
||||
@@ -1200,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
|
||||
}
|
||||
@@ -1254,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' },
|
||||
},
|
||||
@@ -1281,7 +1346,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 +1373,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'],
|
||||
|
||||
@@ -3707,14 +3707,17 @@ import {
|
||||
supabaseRpcTool,
|
||||
supabaseStorageCopyTool,
|
||||
supabaseStorageCreateBucketTool,
|
||||
supabaseStorageCreateSignedUploadUrlTool,
|
||||
supabaseStorageCreateSignedUrlTool,
|
||||
supabaseStorageDeleteBucketTool,
|
||||
supabaseStorageDeleteTool,
|
||||
supabaseStorageDownloadTool,
|
||||
supabaseStorageEmptyBucketTool,
|
||||
supabaseStorageGetPublicUrlTool,
|
||||
supabaseStorageListBucketsTool,
|
||||
supabaseStorageListTool,
|
||||
supabaseStorageMoveTool,
|
||||
supabaseStorageUpdateBucketTool,
|
||||
supabaseStorageUploadTool,
|
||||
supabaseTextSearchTool,
|
||||
supabaseUpdateTool,
|
||||
@@ -5218,10 +5221,13 @@ export const tools: Record<string, ToolConfig> = {
|
||||
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,
|
||||
|
||||
@@ -7,7 +7,7 @@ export const countTool: ToolConfig<SupabaseCountParams, SupabaseCountResponse> =
|
||||
id: 'supabase_count',
|
||||
name: 'Supabase Count',
|
||||
description: 'Count rows in a Supabase table',
|
||||
version: '1.0',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
projectId: {
|
||||
|
||||
@@ -7,7 +7,7 @@ export const deleteTool: ToolConfig<SupabaseDeleteParams, SupabaseDeleteResponse
|
||||
id: 'supabase_delete',
|
||||
name: 'Supabase Delete Row',
|
||||
description: 'Delete rows from a Supabase table based on filter criteria',
|
||||
version: '1.0',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
projectId: {
|
||||
|
||||
@@ -7,7 +7,7 @@ export const getRowTool: ToolConfig<SupabaseGetRowParams, SupabaseGetRowResponse
|
||||
id: 'supabase_get_row',
|
||||
name: 'Supabase Get Row',
|
||||
description: 'Get a single row from a Supabase table based on filter criteria',
|
||||
version: '1.0',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
projectId: {
|
||||
|
||||
@@ -8,14 +8,17 @@ import { queryTool } from '@/tools/supabase/query'
|
||||
import { rpcTool } from '@/tools/supabase/rpc'
|
||||
import { storageCopyTool } from '@/tools/supabase/storage_copy'
|
||||
import { storageCreateBucketTool } from '@/tools/supabase/storage_create_bucket'
|
||||
import { storageCreateSignedUploadUrlTool } from '@/tools/supabase/storage_create_signed_upload_url'
|
||||
import { storageCreateSignedUrlTool } from '@/tools/supabase/storage_create_signed_url'
|
||||
import { storageDeleteTool } from '@/tools/supabase/storage_delete'
|
||||
import { storageDeleteBucketTool } from '@/tools/supabase/storage_delete_bucket'
|
||||
import { storageDownloadTool } from '@/tools/supabase/storage_download'
|
||||
import { storageEmptyBucketTool } from '@/tools/supabase/storage_empty_bucket'
|
||||
import { storageGetPublicUrlTool } from '@/tools/supabase/storage_get_public_url'
|
||||
import { storageListTool } from '@/tools/supabase/storage_list'
|
||||
import { storageListBucketsTool } from '@/tools/supabase/storage_list_buckets'
|
||||
import { storageMoveTool } from '@/tools/supabase/storage_move'
|
||||
import { storageUpdateBucketTool } from '@/tools/supabase/storage_update_bucket'
|
||||
import { storageUploadTool } from '@/tools/supabase/storage_upload'
|
||||
import { textSearchTool } from '@/tools/supabase/text_search'
|
||||
import { updateTool } from '@/tools/supabase/update'
|
||||
@@ -45,3 +48,6 @@ export const supabaseStorageListBucketsTool = storageListBucketsTool
|
||||
export const supabaseStorageDeleteBucketTool = storageDeleteBucketTool
|
||||
export const supabaseStorageGetPublicUrlTool = storageGetPublicUrlTool
|
||||
export const supabaseStorageCreateSignedUrlTool = storageCreateSignedUrlTool
|
||||
export const supabaseStorageCreateSignedUploadUrlTool = storageCreateSignedUploadUrlTool
|
||||
export const supabaseStorageUpdateBucketTool = storageUpdateBucketTool
|
||||
export const supabaseStorageEmptyBucketTool = storageEmptyBucketTool
|
||||
|
||||
@@ -7,7 +7,7 @@ export const insertTool: ToolConfig<SupabaseInsertParams, SupabaseInsertResponse
|
||||
id: 'supabase_insert',
|
||||
name: 'Supabase Insert',
|
||||
description: 'Insert data into a Supabase table',
|
||||
version: '1.0',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
projectId: {
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import {
|
||||
INTROSPECT_TABLE_OUTPUT_PROPERTIES,
|
||||
type SupabaseColumnSchema,
|
||||
@@ -10,308 +8,27 @@ import {
|
||||
import { supabaseBaseUrl } from '@/tools/supabase/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
const logger = createLogger('SupabaseIntrospect')
|
||||
|
||||
/**
|
||||
* SQL query to introspect database schema
|
||||
* This query retrieves all tables, columns, primary keys, foreign keys, and indexes
|
||||
*/
|
||||
const INTROSPECTION_SQL = `
|
||||
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 NOT IN ('pg_catalog', 'information_schema', 'auth', 'storage', 'realtime', 'supabase_functions', 'supabase_migrations', 'extensions', 'graphql', 'graphql_public', 'pgsodium', 'pgsodium_masks', 'vault', 'pgbouncer', '_timescaledb_internal', '_timescaledb_config', '_timescaledb_catalog', '_timescaledb_cache')
|
||||
),
|
||||
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'
|
||||
),
|
||||
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'
|
||||
),
|
||||
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 NOT IN ('pg_catalog', 'information_schema', 'auth', 'storage', 'realtime', 'supabase_functions', 'supabase_migrations', 'extensions', 'graphql', 'graphql_public', 'pgsodium', 'pgsodium_masks', 'vault', 'pgbouncer', '_timescaledb_internal', '_timescaledb_config', '_timescaledb_catalog', '_timescaledb_cache')
|
||||
)
|
||||
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;
|
||||
`
|
||||
|
||||
/**
|
||||
* Escapes a value for single-quoted SQL strings by doubling single quotes
|
||||
*/
|
||||
function escapeSqlString(value: string): string {
|
||||
if (!value || value.length > 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. `nullable` is also best-effort: PostgREST
|
||||
* only lists a column under `required` when it's NOT NULL *and* has no
|
||||
* default, so a NOT NULL column with a default is reported as nullable.
|
||||
*/
|
||||
export const introspectTool: ToolConfig<SupabaseIntrospectParams, SupabaseIntrospectResponse> = {
|
||||
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 +53,31 @@ export const introspectTool: ToolConfig<SupabaseIntrospectParams, SupabaseIntros
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
return `${supabaseBaseUrl(params.projectId)}/rest/v1/rpc/`
|
||||
},
|
||||
method: 'POST',
|
||||
url: (params) => `${supabaseBaseUrl(params.projectId)}/rest/v1/`,
|
||||
method: 'GET',
|
||||
headers: (params) => ({
|
||||
apikey: params.apiKey,
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/openapi+json',
|
||||
...(params.schema ? { 'Accept-Profile': params.schema } : {}),
|
||||
}),
|
||||
body: () => ({}),
|
||||
},
|
||||
|
||||
directExecution: async (
|
||||
params: SupabaseIntrospectParams
|
||||
): Promise<SupabaseIntrospectResponse> => {
|
||||
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 +97,13 @@ export const introspectTool: ToolConfig<SupabaseIntrospectParams, SupabaseIntros
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse index definition to extract column names
|
||||
*/
|
||||
function parseIndexColumns(indexDef: string): string[] {
|
||||
const match = indexDef.match(/\(([^)]+)\)/)
|
||||
if (match) {
|
||||
return match[1].split(',').map((col) => 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 +126,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 = {
|
||||
@@ -539,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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -7,7 +7,7 @@ export const queryTool: ToolConfig<SupabaseQueryParams, SupabaseQueryResponse> =
|
||||
id: 'supabase_query',
|
||||
name: 'Supabase Query',
|
||||
description: 'Query data from a Supabase table',
|
||||
version: '1.0',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
projectId: {
|
||||
|
||||
@@ -7,7 +7,7 @@ export const rpcTool: ToolConfig<SupabaseRpcParams, SupabaseRpcResponse> = {
|
||||
id: 'supabase_rpc',
|
||||
name: 'Supabase RPC',
|
||||
description: 'Call a PostgreSQL function in Supabase',
|
||||
version: '1.0',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
projectId: {
|
||||
|
||||
@@ -10,7 +10,7 @@ export const storageCopyTool: ToolConfig<SupabaseStorageCopyParams, SupabaseStor
|
||||
id: 'supabase_storage_copy',
|
||||
name: 'Supabase Storage Copy',
|
||||
description: 'Copy a file within a Supabase storage bucket',
|
||||
version: '1.0',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
projectId: {
|
||||
|
||||
@@ -13,7 +13,7 @@ export const storageCreateBucketTool: ToolConfig<
|
||||
id: 'supabase_storage_create_bucket',
|
||||
name: 'Supabase Storage Create Bucket',
|
||||
description: 'Create a new storage bucket in Supabase',
|
||||
version: '1.0',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
projectId: {
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
import type {
|
||||
SupabaseStorageCreateSignedUploadUrlParams,
|
||||
SupabaseStorageCreateSignedUploadUrlResponse,
|
||||
} from '@/tools/supabase/types'
|
||||
import { encodeStoragePath, encodeStorageSegment, supabaseBaseUrl } from '@/tools/supabase/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const storageCreateSignedUploadUrlTool: ToolConfig<
|
||||
SupabaseStorageCreateSignedUploadUrlParams,
|
||||
SupabaseStorageCreateSignedUploadUrlResponse
|
||||
> = {
|
||||
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}`
|
||||
)
|
||||
}
|
||||
|
||||
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')
|
||||
}
|
||||
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}`,
|
||||
// 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,
|
||||
}
|
||||
},
|
||||
|
||||
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' },
|
||||
},
|
||||
}
|
||||
@@ -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) => ({
|
||||
@@ -63,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) => {
|
||||
@@ -84,11 +78,28 @@ 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')
|
||||
}
|
||||
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,
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -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) => ({
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
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}`)
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(
|
||||
`Failed to empty storage bucket: ${data.message || data.error || response.statusText}`
|
||||
)
|
||||
}
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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,10 +48,16 @@ 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'
|
||||
// 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 {
|
||||
@@ -65,8 +71,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: () => ({}),
|
||||
},
|
||||
|
||||
@@ -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<SupabaseStorageListParams, SupabaseStorageListResponse> = {
|
||||
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<SupabaseStorageListParams, SupabaseStor
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
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) => ({
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -10,7 +10,7 @@ export const storageMoveTool: ToolConfig<SupabaseStorageMoveParams, SupabaseStor
|
||||
id: 'supabase_storage_move',
|
||||
name: 'Supabase Storage Move',
|
||||
description: 'Move a file within a Supabase storage bucket',
|
||||
version: '1.0',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
projectId: {
|
||||
|
||||
@@ -0,0 +1,170 @@
|
||||
import { getErrorMessage } from '@sim/utils/errors'
|
||||
import {
|
||||
STORAGE_MESSAGE_OUTPUT_PROPERTIES,
|
||||
type SupabaseStorageUpdateBucketParams,
|
||||
type SupabaseStorageUpdateBucketResponse,
|
||||
} from '@/tools/supabase/types'
|
||||
import { encodeStorageSegment, supabaseBaseUrl } from '@/tools/supabase/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const storageUpdateBucketTool: ToolConfig<
|
||||
SupabaseStorageUpdateBucketParams,
|
||||
SupabaseStorageUpdateBucketResponse
|
||||
> = {
|
||||
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 (leave unset to keep the current value)',
|
||||
},
|
||||
fileSizeLimit: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
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"]) — leave unset to keep the current value',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Your Supabase service role secret key',
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* 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) =>
|
||||
`${supabaseBaseUrl(params.projectId)}/storage/v1/bucket/${encodeStorageSegment(params.bucket)}`,
|
||||
method: 'PUT',
|
||||
headers: (params) => ({
|
||||
apikey: params.apiKey,
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
},
|
||||
|
||||
/**
|
||||
* 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<SupabaseStorageUpdateBucketResponse> => {
|
||||
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()
|
||||
|
||||
// Block subBlocks for a shared field can forward an empty string
|
||||
// (e.g. an untouched short-input) rather than omitting the key
|
||||
// entirely — treat that the same as "not provided" so it falls
|
||||
// back to the bucket's current value instead of coercing to 0/false.
|
||||
const hasValue = (value: unknown): boolean =>
|
||||
value !== undefined && value !== null && value !== ''
|
||||
|
||||
const payload: any = {
|
||||
id: params.bucket,
|
||||
name: params.bucket,
|
||||
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}`, {
|
||||
method: 'PUT',
|
||||
headers,
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
if (!updateResponse.ok) {
|
||||
const errorText = await updateResponse.text()
|
||||
throw new Error(`Failed to update bucket: ${errorText}`)
|
||||
}
|
||||
|
||||
const data = await updateResponse.json()
|
||||
|
||||
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'),
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
message: { type: 'string', description: 'Operation status message' },
|
||||
results: {
|
||||
type: 'object',
|
||||
description: 'Update operation result',
|
||||
properties: STORAGE_MESSAGE_OUTPUT_PROPERTIES,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -7,7 +7,7 @@ export const textSearchTool: ToolConfig<SupabaseTextSearchParams, SupabaseTextSe
|
||||
id: 'supabase_text_search',
|
||||
name: 'Supabase Text Search',
|
||||
description: 'Perform full-text search on a Supabase table',
|
||||
version: '1.0',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
projectId: {
|
||||
|
||||
@@ -119,10 +119,11 @@ export const STORAGE_COPY_OUTPUT_PROPERTIES = {
|
||||
} as const satisfies Record<string, OutputProperty>
|
||||
|
||||
/**
|
||||
* 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<string, OutputProperty>
|
||||
|
||||
@@ -231,13 +232,24 @@ 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', 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 +306,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 +602,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
|
||||
*/
|
||||
|
||||
@@ -7,7 +7,7 @@ export const updateTool: ToolConfig<SupabaseUpdateParams, SupabaseUpdateResponse
|
||||
id: 'supabase_update',
|
||||
name: 'Supabase Update Row',
|
||||
description: 'Update rows in a Supabase table based on filter criteria',
|
||||
version: '1.0',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
projectId: {
|
||||
|
||||
@@ -7,7 +7,7 @@ export const upsertTool: ToolConfig<SupabaseUpsertParams, SupabaseUpsertResponse
|
||||
id: 'supabase_upsert',
|
||||
name: 'Supabase Upsert',
|
||||
description: 'Insert or update data in a Supabase table (upsert operation)',
|
||||
version: '1.0',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
projectId: {
|
||||
|
||||
@@ -12,3 +12,24 @@ export function supabaseBaseUrl(projectId: string): string {
|
||||
}
|
||||
return `https://${result.sanitized}.supabase.co`
|
||||
}
|
||||
|
||||
/**
|
||||
* URL-encodes a single storage path segment (bucket name), trimming
|
||||
* copy-paste whitespace first so the value is safe to interpolate into a URL.
|
||||
*/
|
||||
export function encodeStorageSegment(segment: string): string {
|
||||
return encodeURIComponent(segment.trim())
|
||||
}
|
||||
|
||||
/**
|
||||
* URL-encodes a storage object path for use inside a URL, preserving `/`
|
||||
* as a path separator while encoding each segment (and trimming
|
||||
* copy-paste whitespace) so spaces, `#`, `?`, and other reserved
|
||||
* characters in file names don't corrupt the request.
|
||||
*/
|
||||
export function encodeStoragePath(path: string): string {
|
||||
return path
|
||||
.split('/')
|
||||
.map((segment) => encodeURIComponent(segment.trim()))
|
||||
.join('/')
|
||||
}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user