chore: import upstream snapshot with attribution
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
Publish CLI Package / publish-npm (push) Waiting to run
Publish Python SDK / publish-pypi (push) Waiting to run
Publish TypeScript SDK / publish-npm (push) Waiting to run
CI / Migrate Dev DB (push) Has been skipped
CI / Detect Version (push) Has been cancelled
CI / Migrate DB (push) Has been cancelled
CI / Build Dev ECR (./docker/app.Dockerfile, ECR_APP) (push) Has been cancelled
CI / Build Dev ECR (./docker/db.Dockerfile, ECR_MIGRATIONS) (push) Has been cancelled
CI / Build Dev ECR (./docker/pii.Dockerfile, ECR_PII) (push) Has been cancelled
CI / Build Dev ECR (./docker/realtime.Dockerfile, ECR_REALTIME) (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
CI / Build AMD64 (./docker/app.Dockerfile, ECR_APP, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build AMD64 (./docker/db.Dockerfile, ECR_MIGRATIONS, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (./docker/pii.Dockerfile, ECR_PII, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (./docker/realtime.Dockerfile, ECR_REALTIME, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/app.Dockerfile, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/db.Dockerfile, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/pii.Dockerfile, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (./docker/realtime.Dockerfile, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Test and Build (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,110 @@
|
||||
import { validateDatabaseIdentifier } from '@/lib/core/security/input-validation'
|
||||
import type { SupabaseCountParams, SupabaseCountResponse } from '@/tools/supabase/types'
|
||||
import { supabaseBaseUrl } from '@/tools/supabase/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const countTool: ToolConfig<SupabaseCountParams, SupabaseCountResponse> = {
|
||||
id: 'supabase_count',
|
||||
name: 'Supabase Count',
|
||||
description: 'Count rows in a Supabase table',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
projectId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Your Supabase project ID (e.g., jdrkgepadsdopsntdlom)',
|
||||
},
|
||||
table: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The name of the Supabase table to count rows from',
|
||||
},
|
||||
schema: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Database schema to count from (default: public). Use this to access tables in other schemas.',
|
||||
},
|
||||
filter: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'PostgREST filter (e.g., "status=eq.active")',
|
||||
},
|
||||
countType: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Count type: exact, planned, or estimated (default: exact)',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Your Supabase service role secret key',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const tableValidation = validateDatabaseIdentifier(params.table, 'table')
|
||||
if (!tableValidation.isValid) throw new Error(tableValidation.error)
|
||||
let url = `${supabaseBaseUrl(params.projectId)}/rest/v1/${encodeURIComponent(params.table)}?select=*`
|
||||
|
||||
if (params.filter?.trim()) {
|
||||
url += `&${params.filter.trim()}`
|
||||
}
|
||||
|
||||
return url
|
||||
},
|
||||
method: 'HEAD',
|
||||
headers: (params) => {
|
||||
const countType = params.countType || 'exact'
|
||||
const headers: Record<string, string> = {
|
||||
apikey: params.apiKey,
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
Prefer: `count=${countType}`,
|
||||
}
|
||||
if (params.schema) {
|
||||
headers['Accept-Profile'] = params.schema
|
||||
}
|
||||
return headers
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
// Extract count from Content-Range header
|
||||
const contentRange = response.headers.get('content-range')
|
||||
|
||||
if (!contentRange) {
|
||||
throw new Error('No content-range header found in response')
|
||||
}
|
||||
|
||||
// Parse the content-range header (format: "0-9/100" or "*/100")
|
||||
const countMatch = contentRange.match(/\/(\d+)$/)
|
||||
|
||||
if (!countMatch) {
|
||||
throw new Error(`Invalid content-range header format: ${contentRange}`)
|
||||
}
|
||||
|
||||
const count = Number.parseInt(countMatch[1], 10)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
message: `Successfully counted ${count} row${count === 1 ? '' : 's'}`,
|
||||
count: count,
|
||||
},
|
||||
error: undefined,
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
message: { type: 'string', description: 'Operation status message' },
|
||||
count: { type: 'number', description: 'Number of rows matching the filter' },
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
import { validateDatabaseIdentifier } from '@/lib/core/security/input-validation'
|
||||
import type { SupabaseDeleteParams, SupabaseDeleteResponse } from '@/tools/supabase/types'
|
||||
import { supabaseBaseUrl } from '@/tools/supabase/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
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.0',
|
||||
|
||||
params: {
|
||||
projectId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Your Supabase project ID (e.g., jdrkgepadsdopsntdlom)',
|
||||
},
|
||||
table: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The name of the Supabase table to delete from',
|
||||
},
|
||||
schema: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Database schema to delete from (default: public). Use this to access tables in other schemas.',
|
||||
},
|
||||
filter: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'PostgREST filter to identify rows to delete (e.g., "id=eq.123")',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Your Supabase service role secret key',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const tableValidation = validateDatabaseIdentifier(params.table, 'table')
|
||||
if (!tableValidation.isValid) throw new Error(tableValidation.error)
|
||||
|
||||
let url = `${supabaseBaseUrl(params.projectId)}/rest/v1/${encodeURIComponent(params.table)}?select=*`
|
||||
|
||||
if (params.filter?.trim()) {
|
||||
url += `&${params.filter.trim()}`
|
||||
} else {
|
||||
throw new Error(
|
||||
'Filter is required for delete operations to prevent accidental deletion of all rows'
|
||||
)
|
||||
}
|
||||
|
||||
return url
|
||||
},
|
||||
method: 'DELETE',
|
||||
headers: (params) => {
|
||||
const headers: Record<string, string> = {
|
||||
apikey: params.apiKey,
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
Prefer: 'return=representation',
|
||||
}
|
||||
if (params.schema) {
|
||||
headers['Content-Profile'] = params.schema
|
||||
}
|
||||
return headers
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const text = await response.text()
|
||||
let data
|
||||
|
||||
if (text?.trim()) {
|
||||
try {
|
||||
data = JSON.parse(text)
|
||||
} catch (parseError) {
|
||||
throw new Error(`Failed to parse Supabase response: ${parseError}`)
|
||||
}
|
||||
} else {
|
||||
data = []
|
||||
}
|
||||
|
||||
const deletedCount = Array.isArray(data) ? data.length : 0
|
||||
|
||||
if (deletedCount === 0) {
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
message: 'No rows were deleted (no matching records found)',
|
||||
results: data,
|
||||
},
|
||||
error: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
message: `Successfully deleted ${deletedCount} row${deletedCount === 1 ? '' : 's'}`,
|
||||
results: data,
|
||||
},
|
||||
error: undefined,
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
message: { type: 'string', description: 'Operation status message' },
|
||||
results: { type: 'array', description: 'Array of deleted records' },
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { validateDatabaseIdentifier } from '@/lib/core/security/input-validation'
|
||||
import type { SupabaseGetRowParams, SupabaseGetRowResponse } from '@/tools/supabase/types'
|
||||
import { supabaseBaseUrl } from '@/tools/supabase/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
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.0',
|
||||
|
||||
params: {
|
||||
projectId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Your Supabase project ID (e.g., jdrkgepadsdopsntdlom)',
|
||||
},
|
||||
table: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The name of the Supabase table to query',
|
||||
},
|
||||
schema: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Database schema to query from (default: public). Use this to access tables in other schemas.',
|
||||
},
|
||||
select: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Columns to return (comma-separated). Defaults to * (all columns)',
|
||||
},
|
||||
filter: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'PostgREST filter to find the specific row (e.g., "id=eq.123")',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Your Supabase service role secret key',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const tableValidation = validateDatabaseIdentifier(params.table, 'table')
|
||||
if (!tableValidation.isValid) throw new Error(tableValidation.error)
|
||||
|
||||
const selectColumns = params.select?.trim() || '*'
|
||||
let url = `${supabaseBaseUrl(params.projectId)}/rest/v1/${encodeURIComponent(params.table)}?select=${encodeURIComponent(selectColumns)}`
|
||||
|
||||
if (params.filter?.trim()) {
|
||||
url += `&${params.filter.trim()}`
|
||||
}
|
||||
|
||||
url += `&limit=1`
|
||||
|
||||
return url
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params) => {
|
||||
const headers: Record<string, string> = {
|
||||
apikey: params.apiKey,
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
}
|
||||
if (params.schema) {
|
||||
headers['Accept-Profile'] = params.schema
|
||||
}
|
||||
return headers
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
let data
|
||||
try {
|
||||
data = await response.json()
|
||||
} catch (parseError) {
|
||||
throw new Error(`Failed to parse Supabase response: ${parseError}`)
|
||||
}
|
||||
|
||||
const rowFound = data.length > 0
|
||||
const results = rowFound ? [data[0]] : []
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
message: rowFound ? 'Successfully found 1 row' : 'No row found matching the criteria',
|
||||
results: results,
|
||||
},
|
||||
error: undefined,
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
message: { type: 'string', description: 'Operation status message' },
|
||||
results: {
|
||||
type: 'array',
|
||||
description: 'Array containing the row data if found, empty array if not found',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import { countTool } from '@/tools/supabase/count'
|
||||
import { deleteTool } from '@/tools/supabase/delete'
|
||||
import { getRowTool } from '@/tools/supabase/get_row'
|
||||
import { insertTool } from '@/tools/supabase/insert'
|
||||
import { introspectTool } from '@/tools/supabase/introspect'
|
||||
import { invokeFunctionTool } from '@/tools/supabase/invoke_function'
|
||||
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'
|
||||
import { upsertTool } from '@/tools/supabase/upsert'
|
||||
import { vectorSearchTool } from '@/tools/supabase/vector_search'
|
||||
|
||||
export const supabaseQueryTool = queryTool
|
||||
export const supabaseInsertTool = insertTool
|
||||
export const supabaseGetRowTool = getRowTool
|
||||
export const supabaseUpdateTool = updateTool
|
||||
export const supabaseDeleteTool = deleteTool
|
||||
export const supabaseUpsertTool = upsertTool
|
||||
export const supabaseVectorSearchTool = vectorSearchTool
|
||||
export const supabaseRpcTool = rpcTool
|
||||
export const supabaseIntrospectTool = introspectTool
|
||||
export const supabaseInvokeFunctionTool = invokeFunctionTool
|
||||
export const supabaseTextSearchTool = textSearchTool
|
||||
export const supabaseCountTool = countTool
|
||||
export const supabaseStorageUploadTool = storageUploadTool
|
||||
export const supabaseStorageDownloadTool = storageDownloadTool
|
||||
export const supabaseStorageListTool = storageListTool
|
||||
export const supabaseStorageDeleteTool = storageDeleteTool
|
||||
export const supabaseStorageMoveTool = storageMoveTool
|
||||
export const supabaseStorageCopyTool = storageCopyTool
|
||||
export const supabaseStorageCreateBucketTool = storageCreateBucketTool
|
||||
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
|
||||
@@ -0,0 +1,126 @@
|
||||
import { validateDatabaseIdentifier } from '@/lib/core/security/input-validation'
|
||||
import type { SupabaseInsertParams, SupabaseInsertResponse } from '@/tools/supabase/types'
|
||||
import { supabaseBaseUrl } from '@/tools/supabase/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const insertTool: ToolConfig<SupabaseInsertParams, SupabaseInsertResponse> = {
|
||||
id: 'supabase_insert',
|
||||
name: 'Supabase Insert',
|
||||
description: 'Insert data into a Supabase table',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
projectId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Your Supabase project ID (e.g., jdrkgepadsdopsntdlom)',
|
||||
},
|
||||
table: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The name of the Supabase table to insert data into',
|
||||
},
|
||||
schema: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Database schema to insert into (default: public). Use this to access tables in other schemas.',
|
||||
},
|
||||
data: {
|
||||
type: 'array',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The data to insert (array of objects or a single object)',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Your Supabase service role secret key',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const tableValidation = validateDatabaseIdentifier(params.table, 'table')
|
||||
if (!tableValidation.isValid) throw new Error(tableValidation.error)
|
||||
return `${supabaseBaseUrl(params.projectId)}/rest/v1/${encodeURIComponent(params.table)}?select=*`
|
||||
},
|
||||
method: 'POST',
|
||||
headers: (params) => {
|
||||
const headers: Record<string, string> = {
|
||||
apikey: params.apiKey,
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=representation',
|
||||
}
|
||||
if (params.schema) {
|
||||
headers['Content-Profile'] = params.schema
|
||||
}
|
||||
return headers
|
||||
},
|
||||
body: (params) => {
|
||||
// Prepare the data - if it's an object but not an array, wrap it in an array
|
||||
const dataToSend =
|
||||
typeof params.data === 'object' && !Array.isArray(params.data) ? [params.data] : params.data
|
||||
|
||||
return dataToSend
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const text = await response.text()
|
||||
|
||||
if (!text || text.trim() === '') {
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
message: 'Successfully inserted data into Supabase (no data returned)',
|
||||
results: [],
|
||||
},
|
||||
error: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
let data
|
||||
try {
|
||||
data = JSON.parse(text)
|
||||
} catch (parseError) {
|
||||
throw new Error(`Failed to parse Supabase response: ${parseError}`)
|
||||
}
|
||||
|
||||
// Check if results array is empty and provide better feedback
|
||||
const resultsArray = Array.isArray(data) ? data : [data]
|
||||
const isEmpty = resultsArray.length === 0 || (resultsArray.length === 1 && !resultsArray[0])
|
||||
|
||||
if (isEmpty) {
|
||||
return {
|
||||
success: false,
|
||||
output: {
|
||||
message: 'No data was inserted into Supabase',
|
||||
results: data,
|
||||
},
|
||||
error:
|
||||
'No data was inserted into Supabase. This usually indicates invalid data format or schema mismatch. Please check that your JSON is valid and matches your table schema.',
|
||||
}
|
||||
}
|
||||
|
||||
const insertedCount = resultsArray.length
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
message: `Successfully inserted ${insertedCount} row${insertedCount === 1 ? '' : 's'} into Supabase`,
|
||||
results: data,
|
||||
},
|
||||
error: undefined,
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
message: { type: 'string', description: 'Operation status message' },
|
||||
results: { type: 'array', description: 'Array of inserted records' },
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import {
|
||||
INTROSPECT_TABLE_OUTPUT_PROPERTIES,
|
||||
type SupabaseColumnSchema,
|
||||
type SupabaseIntrospectParams,
|
||||
type SupabaseIntrospectResponse,
|
||||
type SupabaseTableSchema,
|
||||
} from '@/tools/supabase/types'
|
||||
import { supabaseBaseUrl } from '@/tools/supabase/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
/**
|
||||
* 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 from its OpenAPI spec to get table and column structures (best-effort primary/foreign key detection)',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
projectId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Your Supabase project ID (e.g., jdrkgepadsdopsntdlom)',
|
||||
},
|
||||
schema: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Database schema to introspect (defaults to all user schemas, commonly "public")',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Your Supabase service role secret key',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => `${supabaseBaseUrl(params.projectId)}/rest/v1/`,
|
||||
method: 'GET',
|
||||
headers: (params) => ({
|
||||
apikey: params.apiKey,
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
Accept: 'application/openapi+json',
|
||||
...(params.schema ? { 'Accept-Profile': params.schema } : {}),
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response, params?: SupabaseIntrospectParams) => {
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
throw new Error(`Failed to introspect database: ${errorText}`)
|
||||
}
|
||||
|
||||
const openApiSpec = await response.json()
|
||||
const tables = parseOpenApiSpec(openApiSpec, params?.schema)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
message: `Successfully introspected ${tables.length} table(s) from database schema`,
|
||||
tables,
|
||||
schemas: [...new Set(tables.map((t) => t.schema))],
|
||||
},
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
message: { type: 'string', description: 'Operation status message' },
|
||||
tables: {
|
||||
type: 'array',
|
||||
description: 'Array of table schemas with columns, keys, and indexes',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: INTROSPECT_TABLE_OUTPUT_PROPERTIES,
|
||||
},
|
||||
},
|
||||
schemas: { type: 'array', description: 'List of schemas found in the database' },
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* 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[] = []
|
||||
const definitions = spec.definitions || spec.components?.schemas || {}
|
||||
|
||||
for (const [tableName, tableDef] of Object.entries(definitions)) {
|
||||
if (tableName.startsWith('_') || tableName === 'Error') continue
|
||||
|
||||
const definition = tableDef as any
|
||||
const properties = definition.properties || {}
|
||||
const required = definition.required || []
|
||||
|
||||
const columns: SupabaseColumnSchema[] = []
|
||||
const primaryKey: string[] = []
|
||||
const foreignKeys: Array<{
|
||||
column: string
|
||||
referencesTable: string
|
||||
referencesColumn: string
|
||||
}> = []
|
||||
|
||||
for (const [colName, colDef] of Object.entries(properties)) {
|
||||
const col = colDef as any
|
||||
const isPK = colName === 'id'
|
||||
const fkMatch = col.description?.match(/references\s+(\w+)\.(\w+)/)
|
||||
|
||||
const column: SupabaseColumnSchema = {
|
||||
name: colName,
|
||||
type: col.format || col.type || 'unknown',
|
||||
nullable: !required.includes(colName),
|
||||
default: col.default || null,
|
||||
isPrimaryKey: isPK,
|
||||
isForeignKey: !!fkMatch,
|
||||
}
|
||||
|
||||
if (fkMatch) {
|
||||
column.references = { table: fkMatch[1], column: fkMatch[2] }
|
||||
foreignKeys.push({
|
||||
column: colName,
|
||||
referencesTable: fkMatch[1],
|
||||
referencesColumn: fkMatch[2],
|
||||
})
|
||||
}
|
||||
|
||||
if (isPK) {
|
||||
primaryKey.push(colName)
|
||||
}
|
||||
|
||||
columns.push(column)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import type {
|
||||
SupabaseInvokeFunctionParams,
|
||||
SupabaseInvokeFunctionResponse,
|
||||
} from '@/tools/supabase/types'
|
||||
import { supabaseBaseUrl } from '@/tools/supabase/utils'
|
||||
import type { HttpMethod, ToolConfig } from '@/tools/types'
|
||||
|
||||
const ALLOWED_METHODS = new Set<HttpMethod>(['GET', 'POST', 'PUT', 'PATCH', 'DELETE'])
|
||||
|
||||
function resolveMethod(method?: string): HttpMethod {
|
||||
const normalized = (method || 'POST').toUpperCase() as HttpMethod
|
||||
return ALLOWED_METHODS.has(normalized) ? normalized : 'POST'
|
||||
}
|
||||
|
||||
/**
|
||||
* Edge Function names are URL path segments and may contain letters, digits,
|
||||
* underscores, and hyphens (e.g. `hello-world`). Reject anything else to
|
||||
* prevent path traversal / injection.
|
||||
*/
|
||||
function validateFunctionName(name: string): string {
|
||||
const trimmed = name?.trim()
|
||||
if (!trimmed || !/^[A-Za-z0-9_-]+$/.test(trimmed)) {
|
||||
throw new Error(
|
||||
'Invalid function name: must contain only letters, digits, underscores, and hyphens'
|
||||
)
|
||||
}
|
||||
return trimmed
|
||||
}
|
||||
|
||||
export const invokeFunctionTool: ToolConfig<
|
||||
SupabaseInvokeFunctionParams,
|
||||
SupabaseInvokeFunctionResponse
|
||||
> = {
|
||||
id: 'supabase_invoke_function',
|
||||
name: 'Supabase Invoke Edge Function',
|
||||
description: 'Invoke a Supabase Edge Function over HTTP',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
projectId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Your Supabase project ID (e.g., jdrkgepadsdopsntdlom)',
|
||||
},
|
||||
functionName: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The name of the Edge Function to invoke (e.g., "hello-world")',
|
||||
},
|
||||
method: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'HTTP method to use: GET, POST, PUT, PATCH, or DELETE (default: POST)',
|
||||
},
|
||||
body: {
|
||||
type: 'json',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Request payload to send to the function as a JSON object (ignored for GET)',
|
||||
},
|
||||
headers: {
|
||||
type: 'json',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Additional request headers as a JSON object of header name to value',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Your Supabase service role secret key',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const functionName = validateFunctionName(params.functionName)
|
||||
return `${supabaseBaseUrl(params.projectId)}/functions/v1/${functionName}`
|
||||
},
|
||||
method: (params) => resolveMethod(params.method),
|
||||
headers: (params) => {
|
||||
const headers: Record<string, string> = {
|
||||
apikey: params.apiKey,
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
if (params.headers && typeof params.headers === 'object' && !Array.isArray(params.headers)) {
|
||||
for (const [key, value] of Object.entries(params.headers)) {
|
||||
headers[key] = String(value)
|
||||
}
|
||||
}
|
||||
return headers
|
||||
},
|
||||
body: (params) => {
|
||||
if (resolveMethod(params.method) === 'GET') {
|
||||
return undefined
|
||||
}
|
||||
return params.body ?? {}
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Only the success path is handled here — the tool executor throws on
|
||||
* non-OK responses before `transformResponse` runs, surfacing the Edge
|
||||
* Function's error body via the shared error extractor.
|
||||
*/
|
||||
transformResponse: async (response: Response) => {
|
||||
const contentType = response.headers.get('content-type') || ''
|
||||
let results: unknown
|
||||
if (contentType.includes('application/json')) {
|
||||
try {
|
||||
results = await response.json()
|
||||
} catch (parseError) {
|
||||
throw new Error(`Failed to parse Supabase Edge Function response: ${parseError}`)
|
||||
}
|
||||
} else {
|
||||
results = await response.text()
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
message: 'Successfully invoked Edge Function',
|
||||
results,
|
||||
},
|
||||
error: undefined,
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
message: { type: 'string', description: 'Operation status message' },
|
||||
results: { type: 'json', description: 'Response body returned by the Edge Function' },
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
import { validateDatabaseIdentifier } from '@/lib/core/security/input-validation'
|
||||
import type { SupabaseQueryParams, SupabaseQueryResponse } from '@/tools/supabase/types'
|
||||
import { supabaseBaseUrl } from '@/tools/supabase/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const queryTool: ToolConfig<SupabaseQueryParams, SupabaseQueryResponse> = {
|
||||
id: 'supabase_query',
|
||||
name: 'Supabase Query',
|
||||
description: 'Query data from a Supabase table',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
projectId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Your Supabase project ID (e.g., jdrkgepadsdopsntdlom)',
|
||||
},
|
||||
table: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The name of the Supabase table to query',
|
||||
},
|
||||
schema: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Database schema to query from (default: public). Use this to access tables in other schemas.',
|
||||
},
|
||||
select: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Columns to return (comma-separated). Defaults to * (all columns)',
|
||||
},
|
||||
filter: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'PostgREST filter (e.g., "id=eq.123")',
|
||||
},
|
||||
orderBy: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Column to order by (add DESC for descending)',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Maximum number of rows to return',
|
||||
},
|
||||
offset: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Number of rows to skip (for pagination)',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Your Supabase service role secret key',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const tableValidation = validateDatabaseIdentifier(params.table, 'table')
|
||||
if (!tableValidation.isValid) throw new Error(tableValidation.error)
|
||||
const selectColumns = params.select?.trim() || '*'
|
||||
let url = `${supabaseBaseUrl(params.projectId)}/rest/v1/${encodeURIComponent(params.table)}?select=${encodeURIComponent(selectColumns)}`
|
||||
|
||||
// Add filters if provided - using PostgREST syntax
|
||||
if (params.filter?.trim()) {
|
||||
url += `&${params.filter.trim()}`
|
||||
}
|
||||
|
||||
// Add order by if provided
|
||||
if (params.orderBy) {
|
||||
let orderParam = params.orderBy.trim()
|
||||
|
||||
// Check if DESC is specified (case-insensitive)
|
||||
if (/\s+DESC$/i.test(orderParam)) {
|
||||
orderParam = `${orderParam.replace(/\s+DESC$/i, '').trim()}.desc`
|
||||
}
|
||||
// Check if ASC is specified (case-insensitive)
|
||||
else if (/\s+ASC$/i.test(orderParam)) {
|
||||
orderParam = `${orderParam.replace(/\s+ASC$/i, '').trim()}.asc`
|
||||
}
|
||||
// Default to ascending if no direction specified
|
||||
else {
|
||||
orderParam = `${orderParam}.asc`
|
||||
}
|
||||
|
||||
url += `&order=${orderParam}`
|
||||
}
|
||||
|
||||
// Add limit if provided
|
||||
if (params.limit !== undefined && params.limit !== null) {
|
||||
url += `&limit=${Number(params.limit)}`
|
||||
}
|
||||
|
||||
// Add offset if provided
|
||||
if (params.offset !== undefined && params.offset !== null) {
|
||||
url += `&offset=${Number(params.offset)}`
|
||||
}
|
||||
|
||||
return url
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params) => {
|
||||
const headers: Record<string, string> = {
|
||||
apikey: params.apiKey,
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
}
|
||||
if (params.schema) {
|
||||
headers['Accept-Profile'] = params.schema
|
||||
}
|
||||
return headers
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
let data
|
||||
try {
|
||||
data = await response.json()
|
||||
} catch (parseError) {
|
||||
throw new Error(`Failed to parse Supabase response: ${parseError}`)
|
||||
}
|
||||
|
||||
const rowCount = Array.isArray(data) ? data.length : 0
|
||||
|
||||
if (rowCount === 0) {
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
message: 'No rows found matching the query criteria',
|
||||
results: data,
|
||||
},
|
||||
error: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
message: `Successfully queried ${rowCount} row${rowCount === 1 ? '' : 's'} from Supabase`,
|
||||
results: data,
|
||||
},
|
||||
error: undefined,
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
message: { type: 'string', description: 'Operation status message' },
|
||||
results: { type: 'array', description: 'Array of records returned from the query' },
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
import { validateDatabaseIdentifier } from '@/lib/core/security/input-validation'
|
||||
import type { SupabaseRpcParams, SupabaseRpcResponse } from '@/tools/supabase/types'
|
||||
import { supabaseBaseUrl } from '@/tools/supabase/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const rpcTool: ToolConfig<SupabaseRpcParams, SupabaseRpcResponse> = {
|
||||
id: 'supabase_rpc',
|
||||
name: 'Supabase RPC',
|
||||
description: 'Call a PostgreSQL function in Supabase',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
projectId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Your Supabase project ID (e.g., jdrkgepadsdopsntdlom)',
|
||||
},
|
||||
functionName: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The name of the PostgreSQL function to call',
|
||||
},
|
||||
params: {
|
||||
type: 'object',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Parameters to pass to the function as a JSON object',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Your Supabase service role secret key',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const fnValidation = validateDatabaseIdentifier(params.functionName, 'functionName')
|
||||
if (!fnValidation.isValid) throw new Error(fnValidation.error)
|
||||
return `${supabaseBaseUrl(params.projectId)}/rest/v1/rpc/${encodeURIComponent(params.functionName)}`
|
||||
},
|
||||
method: 'POST',
|
||||
headers: (params) => ({
|
||||
apikey: params.apiKey,
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params) => {
|
||||
return params.params || {}
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
let data
|
||||
try {
|
||||
data = await response.json()
|
||||
} catch (parseError) {
|
||||
throw new Error(`Failed to parse Supabase RPC response: ${parseError}`)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
message: 'Successfully executed PostgreSQL function',
|
||||
results: data,
|
||||
},
|
||||
error: undefined,
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
message: { type: 'string', description: 'Operation status message' },
|
||||
results: { type: 'json', description: 'Result returned from the function' },
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
STORAGE_COPY_OUTPUT_PROPERTIES,
|
||||
type SupabaseStorageCopyParams,
|
||||
type SupabaseStorageCopyResponse,
|
||||
} from '@/tools/supabase/types'
|
||||
import { supabaseBaseUrl } from '@/tools/supabase/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const storageCopyTool: ToolConfig<SupabaseStorageCopyParams, SupabaseStorageCopyResponse> = {
|
||||
id: 'supabase_storage_copy',
|
||||
name: 'Supabase Storage Copy',
|
||||
description: 'Copy a file within 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',
|
||||
},
|
||||
fromPath: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The path of the source file (e.g., "folder/source.jpg")',
|
||||
},
|
||||
toPath: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The path for the copied file (e.g., "folder/copy.jpg")',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Your Supabase service role secret key',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
return `${supabaseBaseUrl(params.projectId)}/storage/v1/object/copy`
|
||||
},
|
||||
method: 'POST',
|
||||
headers: (params) => ({
|
||||
apikey: params.apiKey,
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params) => {
|
||||
return {
|
||||
bucketId: params.bucket,
|
||||
sourceKey: params.fromPath,
|
||||
destinationKey: params.toPath,
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
let data
|
||||
try {
|
||||
data = await response.json()
|
||||
} catch (parseError) {
|
||||
throw new Error(`Failed to parse Supabase storage copy response: ${parseError}`)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
message: 'Successfully copied file in storage',
|
||||
results: data,
|
||||
},
|
||||
error: undefined,
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
message: { type: 'string', description: 'Operation status message' },
|
||||
results: {
|
||||
type: 'object',
|
||||
description: 'Copy operation result with the destination object key',
|
||||
properties: STORAGE_COPY_OUTPUT_PROPERTIES,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
import {
|
||||
STORAGE_CREATE_BUCKET_OUTPUT_PROPERTIES,
|
||||
type SupabaseStorageCreateBucketParams,
|
||||
type SupabaseStorageCreateBucketResponse,
|
||||
} from '@/tools/supabase/types'
|
||||
import { supabaseBaseUrl } from '@/tools/supabase/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const storageCreateBucketTool: ToolConfig<
|
||||
SupabaseStorageCreateBucketParams,
|
||||
SupabaseStorageCreateBucketResponse
|
||||
> = {
|
||||
id: 'supabase_storage_create_bucket',
|
||||
name: 'Supabase Storage Create Bucket',
|
||||
description: 'Create a new storage bucket in Supabase',
|
||||
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 create',
|
||||
},
|
||||
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) => {
|
||||
return `${supabaseBaseUrl(params.projectId)}/storage/v1/bucket`
|
||||
},
|
||||
method: 'POST',
|
||||
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 create bucket response: ${parseError}`)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
message: 'Successfully created storage bucket',
|
||||
results: data,
|
||||
},
|
||||
error: undefined,
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
message: { type: 'string', description: 'Operation status message' },
|
||||
results: {
|
||||
type: 'object',
|
||||
description: 'Created bucket result (name)',
|
||||
properties: STORAGE_CREATE_BUCKET_OUTPUT_PROPERTIES,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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' },
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import type {
|
||||
SupabaseStorageCreateSignedUrlParams,
|
||||
SupabaseStorageCreateSignedUrlResponse,
|
||||
} from '@/tools/supabase/types'
|
||||
import { encodeStoragePath, encodeStorageSegment, supabaseBaseUrl } from '@/tools/supabase/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const storageCreateSignedUrlTool: ToolConfig<
|
||||
SupabaseStorageCreateSignedUrlParams,
|
||||
SupabaseStorageCreateSignedUrlResponse
|
||||
> = {
|
||||
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.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 path to the file (e.g., "folder/file.jpg")',
|
||||
},
|
||||
expiresIn: {
|
||||
type: 'number',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Number of seconds until the URL expires (e.g., 3600 for 1 hour)',
|
||||
},
|
||||
download: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'If true, forces download instead of inline display (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/sign/${bucket}/${path}`
|
||||
},
|
||||
method: 'POST',
|
||||
headers: (params) => ({
|
||||
apikey: params.apiKey,
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params) => ({
|
||||
expiresIn: Number(params.expiresIn),
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response, params?: SupabaseStorageCreateSignedUrlParams) => {
|
||||
let data
|
||||
try {
|
||||
data = await response.json()
|
||||
} catch (parseError) {
|
||||
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')
|
||||
}
|
||||
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,
|
||||
output: {
|
||||
message: 'Successfully created signed URL',
|
||||
signedUrl: fullUrl,
|
||||
},
|
||||
error: undefined,
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
message: { type: 'string', description: 'Operation status message' },
|
||||
signedUrl: {
|
||||
type: 'string',
|
||||
description: 'The temporary signed URL to access the file',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import {
|
||||
STORAGE_DELETED_FILE_OUTPUT_PROPERTIES,
|
||||
type SupabaseStorageDeleteParams,
|
||||
type SupabaseStorageDeleteResponse,
|
||||
} from '@/tools/supabase/types'
|
||||
import { encodeStorageSegment, supabaseBaseUrl } from '@/tools/supabase/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const storageDeleteTool: ToolConfig<
|
||||
SupabaseStorageDeleteParams,
|
||||
SupabaseStorageDeleteResponse
|
||||
> = {
|
||||
id: 'supabase_storage_delete',
|
||||
name: 'Supabase Storage Delete',
|
||||
description: 'Delete files from 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',
|
||||
},
|
||||
paths: {
|
||||
type: 'array',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Array of file paths to delete (e.g., ["folder/file1.jpg", "folder/file2.jpg"])',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Your Supabase service role secret key',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
return `${supabaseBaseUrl(params.projectId)}/storage/v1/object/${encodeStorageSegment(params.bucket)}`
|
||||
},
|
||||
method: 'DELETE',
|
||||
headers: (params) => ({
|
||||
apikey: params.apiKey,
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params) => {
|
||||
return {
|
||||
prefixes: params.paths,
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
let data
|
||||
try {
|
||||
data = await response.json()
|
||||
} catch (parseError) {
|
||||
throw new Error(`Failed to parse Supabase storage delete response: ${parseError}`)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
message: 'Successfully deleted files from storage',
|
||||
results: data,
|
||||
},
|
||||
error: undefined,
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
message: { type: 'string', description: 'Operation status message' },
|
||||
results: {
|
||||
type: 'array',
|
||||
description: 'Array of deleted file objects',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: STORAGE_DELETED_FILE_OUTPUT_PROPERTIES,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
STORAGE_MESSAGE_OUTPUT_PROPERTIES,
|
||||
type SupabaseStorageDeleteBucketParams,
|
||||
type SupabaseStorageDeleteBucketResponse,
|
||||
} from '@/tools/supabase/types'
|
||||
import { encodeStorageSegment, supabaseBaseUrl } from '@/tools/supabase/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const storageDeleteBucketTool: ToolConfig<
|
||||
SupabaseStorageDeleteBucketParams,
|
||||
SupabaseStorageDeleteBucketResponse
|
||||
> = {
|
||||
id: 'supabase_storage_delete_bucket',
|
||||
name: 'Supabase Storage Delete Bucket',
|
||||
description: 'Delete a storage bucket in Supabase',
|
||||
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 delete',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Your Supabase service role secret key',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
return `${supabaseBaseUrl(params.projectId)}/storage/v1/bucket/${encodeStorageSegment(params.bucket)}`
|
||||
},
|
||||
method: 'DELETE',
|
||||
headers: (params) => ({
|
||||
apikey: params.apiKey,
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
let data
|
||||
try {
|
||||
data = await response.json()
|
||||
} catch (parseError) {
|
||||
throw new Error(`Failed to parse Supabase storage delete bucket response: ${parseError}`)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
message: 'Successfully deleted storage bucket',
|
||||
results: data,
|
||||
},
|
||||
error: undefined,
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
message: { type: 'string', description: 'Operation status message' },
|
||||
results: {
|
||||
type: 'object',
|
||||
description: 'Delete operation result',
|
||||
properties: STORAGE_MESSAGE_OUTPUT_PROPERTIES,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
import { createLogger } from '@sim/logger'
|
||||
import {
|
||||
STORAGE_DOWNLOAD_OUTPUT_PROPERTIES,
|
||||
type SupabaseStorageDownloadParams,
|
||||
type SupabaseStorageDownloadResponse,
|
||||
} from '@/tools/supabase/types'
|
||||
import { encodeStoragePath, encodeStorageSegment, supabaseBaseUrl } from '@/tools/supabase/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
const logger = createLogger('SupabaseStorageDownloadTool')
|
||||
|
||||
export const storageDownloadTool: ToolConfig<
|
||||
SupabaseStorageDownloadParams,
|
||||
SupabaseStorageDownloadResponse
|
||||
> = {
|
||||
id: 'supabase_storage_download',
|
||||
name: 'Supabase Storage Download',
|
||||
description: 'Download a file from 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 path to the file to download (e.g., "folder/file.jpg")',
|
||||
},
|
||||
fileName: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Optional filename override',
|
||||
},
|
||||
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/${bucket}/${path}`
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params) => ({
|
||||
apikey: params.apiKey,
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response, params?: SupabaseStorageDownloadParams) => {
|
||||
try {
|
||||
if (!response.ok) {
|
||||
logger.error('Failed to download file from Supabase storage', {
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
})
|
||||
throw new Error(`Failed to download file: ${response.statusText}`)
|
||||
}
|
||||
|
||||
const contentType = response.headers.get('content-type') || 'application/octet-stream'
|
||||
|
||||
const pathParts = params?.path?.split('/') || []
|
||||
const defaultFileName = pathParts[pathParts.length - 1] || 'download'
|
||||
const resolvedName = params?.fileName || defaultFileName
|
||||
|
||||
logger.info('Downloading file from Supabase storage', {
|
||||
bucket: params?.bucket,
|
||||
path: params?.path,
|
||||
fileName: resolvedName,
|
||||
contentType,
|
||||
})
|
||||
|
||||
const arrayBuffer = await response.arrayBuffer()
|
||||
const fileBuffer = Buffer.from(arrayBuffer)
|
||||
|
||||
logger.info('File downloaded successfully from Supabase storage', {
|
||||
name: resolvedName,
|
||||
size: fileBuffer.length,
|
||||
contentType,
|
||||
})
|
||||
|
||||
const base64Data = fileBuffer.toString('base64')
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
file: {
|
||||
name: resolvedName,
|
||||
mimeType: contentType,
|
||||
data: base64Data,
|
||||
size: fileBuffer.length,
|
||||
},
|
||||
},
|
||||
error: undefined,
|
||||
}
|
||||
} catch (error: any) {
|
||||
logger.error('Error downloading file from Supabase storage', {
|
||||
error: error.message,
|
||||
stack: error.stack,
|
||||
})
|
||||
throw error
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
file: {
|
||||
type: 'file',
|
||||
description: 'Downloaded file stored in execution files',
|
||||
properties: STORAGE_DOWNLOAD_OUTPUT_PROPERTIES,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import type {
|
||||
SupabaseStorageGetPublicUrlParams,
|
||||
SupabaseStorageGetPublicUrlResponse,
|
||||
} from '@/tools/supabase/types'
|
||||
import { encodeStoragePath, encodeStorageSegment, supabaseBaseUrl } from '@/tools/supabase/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const storageGetPublicUrlTool: ToolConfig<
|
||||
SupabaseStorageGetPublicUrlParams,
|
||||
SupabaseStorageGetPublicUrlResponse
|
||||
> = {
|
||||
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.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 path to the file (e.g., "folder/file.jpg")',
|
||||
},
|
||||
download: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'If true, forces download instead of inline display (default: false)',
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* Public URLs are deterministic and built entirely from the project ID,
|
||||
* bucket, and path — no network request is required. `directExecution`
|
||||
* short-circuits the HTTP request so we never hit the API just to discard
|
||||
* its response.
|
||||
*/
|
||||
directExecution: async (params: SupabaseStorageGetPublicUrlParams) => {
|
||||
const bucket = encodeStorageSegment(params.bucket)
|
||||
const path = encodeStoragePath(params.path)
|
||||
let publicUrl = `${supabaseBaseUrl(params.projectId)}/storage/v1/object/public/${bucket}/${path}`
|
||||
|
||||
if (params.download) {
|
||||
// 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 {
|
||||
success: true,
|
||||
output: {
|
||||
message: 'Successfully generated public URL',
|
||||
publicUrl,
|
||||
},
|
||||
error: undefined,
|
||||
}
|
||||
},
|
||||
|
||||
request: {
|
||||
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: () => ({}),
|
||||
},
|
||||
|
||||
outputs: {
|
||||
message: { type: 'string', description: 'Operation status message' },
|
||||
publicUrl: {
|
||||
type: 'string',
|
||||
description: 'The public URL to access the file',
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import {
|
||||
STORAGE_FILE_OUTPUT_PROPERTIES,
|
||||
type SupabaseStorageListParams,
|
||||
type SupabaseStorageListResponse,
|
||||
} from '@/tools/supabase/types'
|
||||
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.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: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The folder path to list files from (default: root)',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Maximum number of files to return (default: 100)',
|
||||
},
|
||||
offset: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Number of files to skip (for pagination)',
|
||||
},
|
||||
sortBy: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Column to sort by: name, created_at, updated_at, last_accessed_at (default: name)',
|
||||
},
|
||||
sortOrder: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Sort order: asc or desc (default: asc)',
|
||||
},
|
||||
search: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Search term to filter files by name',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Your Supabase service role secret key',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
return `${supabaseBaseUrl(params.projectId)}/storage/v1/object/list/${encodeStorageSegment(params.bucket)}`
|
||||
},
|
||||
method: 'POST',
|
||||
headers: (params) => ({
|
||||
apikey: params.apiKey,
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params) => {
|
||||
const payload: any = {
|
||||
prefix: params.path || '',
|
||||
limit: params.limit ? Number(params.limit) : 100,
|
||||
offset: params.offset ? Number(params.offset) : 0,
|
||||
}
|
||||
|
||||
if (params.sortBy) {
|
||||
payload.sortBy = {
|
||||
column: params.sortBy,
|
||||
order: params.sortOrder || 'asc',
|
||||
}
|
||||
}
|
||||
|
||||
if (params.search) {
|
||||
payload.search = params.search
|
||||
}
|
||||
|
||||
return payload
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
let data
|
||||
try {
|
||||
data = await response.json()
|
||||
} catch (parseError) {
|
||||
throw new Error(`Failed to parse Supabase storage list response: ${parseError}`)
|
||||
}
|
||||
|
||||
const fileCount = Array.isArray(data) ? data.length : 0
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
message: `Successfully listed ${fileCount} file${fileCount === 1 ? '' : 's'}`,
|
||||
results: data,
|
||||
},
|
||||
error: undefined,
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
message: { type: 'string', description: 'Operation status message' },
|
||||
results: {
|
||||
type: 'array',
|
||||
description: 'Array of file objects with metadata',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: STORAGE_FILE_OUTPUT_PROPERTIES,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import {
|
||||
STORAGE_BUCKET_OUTPUT_PROPERTIES,
|
||||
type SupabaseStorageListBucketsParams,
|
||||
type SupabaseStorageListBucketsResponse,
|
||||
} from '@/tools/supabase/types'
|
||||
import { supabaseBaseUrl } from '@/tools/supabase/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const storageListBucketsTool: ToolConfig<
|
||||
SupabaseStorageListBucketsParams,
|
||||
SupabaseStorageListBucketsResponse
|
||||
> = {
|
||||
id: 'supabase_storage_list_buckets',
|
||||
name: 'Supabase Storage List Buckets',
|
||||
description: 'List all storage buckets in Supabase',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
projectId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Your Supabase project ID (e.g., jdrkgepadsdopsntdlom)',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Your Supabase service role secret key',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
return `${supabaseBaseUrl(params.projectId)}/storage/v1/bucket`
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params) => ({
|
||||
apikey: params.apiKey,
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
}),
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
let data
|
||||
try {
|
||||
data = await response.json()
|
||||
} catch (parseError) {
|
||||
throw new Error(`Failed to parse Supabase storage list buckets response: ${parseError}`)
|
||||
}
|
||||
|
||||
const bucketCount = Array.isArray(data) ? data.length : 0
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
message: `Successfully listed ${bucketCount} bucket${bucketCount === 1 ? '' : 's'}`,
|
||||
results: data,
|
||||
},
|
||||
error: undefined,
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
message: { type: 'string', description: 'Operation status message' },
|
||||
results: {
|
||||
type: 'array',
|
||||
description: 'Array of bucket objects',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: STORAGE_BUCKET_OUTPUT_PROPERTIES,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
import {
|
||||
STORAGE_MOVE_OUTPUT_PROPERTIES,
|
||||
type SupabaseStorageMoveParams,
|
||||
type SupabaseStorageMoveResponse,
|
||||
} from '@/tools/supabase/types'
|
||||
import { supabaseBaseUrl } from '@/tools/supabase/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const storageMoveTool: ToolConfig<SupabaseStorageMoveParams, SupabaseStorageMoveResponse> = {
|
||||
id: 'supabase_storage_move',
|
||||
name: 'Supabase Storage Move',
|
||||
description: 'Move a file within 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',
|
||||
},
|
||||
fromPath: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The current path of the file (e.g., "folder/old.jpg")',
|
||||
},
|
||||
toPath: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The new path for the file (e.g., "newfolder/new.jpg")',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Your Supabase service role secret key',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
return `${supabaseBaseUrl(params.projectId)}/storage/v1/object/move`
|
||||
},
|
||||
method: 'POST',
|
||||
headers: (params) => ({
|
||||
apikey: params.apiKey,
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params) => {
|
||||
return {
|
||||
bucketId: params.bucket,
|
||||
sourceKey: params.fromPath,
|
||||
destinationKey: params.toPath,
|
||||
}
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
let data
|
||||
try {
|
||||
data = await response.json()
|
||||
} catch (parseError) {
|
||||
throw new Error(`Failed to parse Supabase storage move response: ${parseError}`)
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
message: 'Successfully moved file in storage',
|
||||
results: data,
|
||||
},
|
||||
error: undefined,
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
message: { type: 'string', description: 'Operation status message' },
|
||||
results: {
|
||||
type: 'object',
|
||||
description: 'Move operation result',
|
||||
properties: STORAGE_MOVE_OUTPUT_PROPERTIES,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import {
|
||||
STORAGE_UPLOAD_OUTPUT_PROPERTIES,
|
||||
type SupabaseStorageUploadParams,
|
||||
type SupabaseStorageUploadResponse,
|
||||
} from '@/tools/supabase/types'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const storageUploadTool: ToolConfig<
|
||||
SupabaseStorageUploadParams,
|
||||
SupabaseStorageUploadResponse
|
||||
> = {
|
||||
id: 'supabase_storage_upload',
|
||||
name: 'Supabase Storage Upload',
|
||||
description: 'Upload a file 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',
|
||||
},
|
||||
fileName: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The name of the file (e.g., "document.pdf", "image.jpg")',
|
||||
},
|
||||
path: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Optional folder path (e.g., "folder/subfolder/")',
|
||||
},
|
||||
fileData: {
|
||||
type: 'json',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'File to upload - UserFile object (basic mode) or string content (advanced mode: base64 or plain text). Supports data URLs.',
|
||||
},
|
||||
contentType: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'MIME type of the file (e.g., "image/jpeg", "text/plain")',
|
||||
},
|
||||
cacheControl: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Cache-Control header value in seconds for the stored object (e.g., "3600"; default: "3600")',
|
||||
},
|
||||
upsert: {
|
||||
type: 'boolean',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'If true, overwrites existing file (default: false)',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Your Supabase service role secret key',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: '/api/tools/supabase/storage-upload',
|
||||
method: 'POST',
|
||||
headers: () => ({
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params) => ({
|
||||
projectId: params.projectId,
|
||||
apiKey: params.apiKey,
|
||||
bucket: params.bucket,
|
||||
fileName: params.fileName,
|
||||
path: params.path,
|
||||
fileData: params.fileData,
|
||||
contentType: params.contentType,
|
||||
cacheControl: params.cacheControl,
|
||||
upsert: params.upsert,
|
||||
}),
|
||||
},
|
||||
|
||||
outputs: {
|
||||
message: { type: 'string', description: 'Operation status message' },
|
||||
results: {
|
||||
type: 'object',
|
||||
description: 'Upload result including file path, bucket, and public URL',
|
||||
properties: STORAGE_UPLOAD_OUTPUT_PROPERTIES,
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { validateDatabaseIdentifier } from '@/lib/core/security/input-validation'
|
||||
import type { SupabaseTextSearchParams, SupabaseTextSearchResponse } from '@/tools/supabase/types'
|
||||
import { supabaseBaseUrl } from '@/tools/supabase/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const textSearchTool: ToolConfig<SupabaseTextSearchParams, SupabaseTextSearchResponse> = {
|
||||
id: 'supabase_text_search',
|
||||
name: 'Supabase Text Search',
|
||||
description: 'Perform full-text search on a Supabase table',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
projectId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Your Supabase project ID (e.g., jdrkgepadsdopsntdlom)',
|
||||
},
|
||||
table: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The name of the Supabase table to search',
|
||||
},
|
||||
schema: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Database schema to search in (default: public). Use this to access tables in other schemas.',
|
||||
},
|
||||
column: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The column to search in',
|
||||
},
|
||||
query: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The search query',
|
||||
},
|
||||
searchType: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Search type: plain, phrase, or websearch (default: websearch)',
|
||||
},
|
||||
language: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Language for text search configuration (default: english)',
|
||||
},
|
||||
limit: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Maximum number of rows to return',
|
||||
},
|
||||
offset: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Number of rows to skip (for pagination)',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Your Supabase service role secret key',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const tableValidation = validateDatabaseIdentifier(params.table, 'table')
|
||||
if (!tableValidation.isValid) throw new Error(tableValidation.error)
|
||||
const columnValidation = validateDatabaseIdentifier(params.column, 'column')
|
||||
if (!columnValidation.isValid) throw new Error(columnValidation.error)
|
||||
const searchType = params.searchType || 'websearch'
|
||||
const language = params.language || 'english'
|
||||
const languageValidation = validateDatabaseIdentifier(language, 'language')
|
||||
if (!languageValidation.isValid) throw new Error(languageValidation.error)
|
||||
|
||||
let url = `${supabaseBaseUrl(params.projectId)}/rest/v1/${encodeURIComponent(params.table)}?select=*`
|
||||
|
||||
// Map search types to PostgREST operators
|
||||
// plfts = plainto_tsquery (natural language), phfts = phraseto_tsquery, wfts = websearch_to_tsquery
|
||||
const operatorMap: Record<string, string> = {
|
||||
plain: 'plfts',
|
||||
phrase: 'phfts',
|
||||
websearch: 'wfts',
|
||||
}
|
||||
|
||||
const operator = operatorMap[searchType] || 'wfts'
|
||||
|
||||
// Add text search filter using PostgREST syntax
|
||||
url += `&${params.column}=${operator}(${language}).${encodeURIComponent(params.query)}`
|
||||
|
||||
// Add limit if provided
|
||||
if (params.limit !== undefined && params.limit !== null) {
|
||||
url += `&limit=${Number(params.limit)}`
|
||||
}
|
||||
|
||||
// Add offset if provided
|
||||
if (params.offset !== undefined && params.offset !== null) {
|
||||
url += `&offset=${Number(params.offset)}`
|
||||
}
|
||||
|
||||
return url
|
||||
},
|
||||
method: 'GET',
|
||||
headers: (params) => {
|
||||
const headers: Record<string, string> = {
|
||||
apikey: params.apiKey,
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
}
|
||||
if (params.schema) {
|
||||
headers['Accept-Profile'] = params.schema
|
||||
}
|
||||
return headers
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
let data
|
||||
try {
|
||||
data = await response.json()
|
||||
} catch (parseError) {
|
||||
throw new Error(`Failed to parse Supabase text search response: ${parseError}`)
|
||||
}
|
||||
|
||||
if (!response.ok && data?.message) {
|
||||
const errorMessage = data.message
|
||||
|
||||
if (errorMessage.includes('to_tsvector') && errorMessage.includes('does not exist')) {
|
||||
throw new Error(
|
||||
'Full-text search can only be performed on text columns. The selected column appears to be a non-text type (e.g., integer, boolean). Please select a text/varchar column or use a different operation.'
|
||||
)
|
||||
}
|
||||
|
||||
if (errorMessage.includes('column') && errorMessage.includes('does not exist')) {
|
||||
throw new Error(`The specified column does not exist in the table. Error: ${errorMessage}`)
|
||||
}
|
||||
|
||||
throw new Error(errorMessage)
|
||||
}
|
||||
|
||||
const rowCount = Array.isArray(data) ? data.length : 0
|
||||
|
||||
if (rowCount === 0) {
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
message: 'No results found matching the search query',
|
||||
results: data,
|
||||
},
|
||||
error: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
message: `Successfully found ${rowCount} result${rowCount === 1 ? '' : 's'}`,
|
||||
results: data,
|
||||
},
|
||||
error: undefined,
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
message: { type: 'string', description: 'Operation status message' },
|
||||
results: { type: 'array', description: 'Array of records matching the search query' },
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,686 @@
|
||||
import type { UserFile } from '@/executor/types'
|
||||
import type { OutputProperty, ToolResponse } from '@/tools/types'
|
||||
|
||||
/**
|
||||
* Shared output property definitions for Supabase API responses.
|
||||
* Based on official Supabase REST API and Storage API documentation.
|
||||
* @see https://supabase.com/docs/reference/javascript/introduction
|
||||
* @see https://supabase.com/docs/guides/storage
|
||||
*/
|
||||
|
||||
/**
|
||||
* Output definition for storage file metadata object
|
||||
* Returned by storage list operations
|
||||
* @see https://github.com/supabase/storage-js/blob/main/src/lib/types.ts
|
||||
*/
|
||||
export const STORAGE_FILE_METADATA_OUTPUT_PROPERTIES = {
|
||||
size: { type: 'number', description: 'File size in bytes', optional: true },
|
||||
mimetype: { type: 'string', description: 'MIME type of the file', optional: true },
|
||||
cacheControl: { type: 'string', description: 'Cache control header value', optional: true },
|
||||
lastModified: { type: 'string', description: 'Last modified timestamp', optional: true },
|
||||
eTag: { type: 'string', description: 'Entity tag for caching', optional: true },
|
||||
} as const satisfies Record<string, OutputProperty>
|
||||
|
||||
/**
|
||||
* Output definition for storage file objects returned by list operations
|
||||
* @see https://github.com/supabase/storage-js/blob/main/src/lib/types.ts
|
||||
*/
|
||||
export const STORAGE_FILE_OUTPUT_PROPERTIES = {
|
||||
id: { type: 'string', description: 'Unique file identifier' },
|
||||
name: { type: 'string', description: 'File name' },
|
||||
bucket_id: { type: 'string', description: 'Bucket identifier the file belongs to' },
|
||||
owner: { type: 'string', description: 'Owner identifier', optional: true },
|
||||
created_at: { type: 'string', description: 'File creation timestamp' },
|
||||
updated_at: { type: 'string', description: 'Last update timestamp' },
|
||||
last_accessed_at: { type: 'string', description: 'Last access timestamp' },
|
||||
metadata: {
|
||||
type: 'object',
|
||||
description: 'File metadata including size and MIME type',
|
||||
properties: STORAGE_FILE_METADATA_OUTPUT_PROPERTIES,
|
||||
optional: true,
|
||||
},
|
||||
} as const satisfies Record<string, OutputProperty>
|
||||
|
||||
/**
|
||||
* Complete storage file object output definition
|
||||
*/
|
||||
export const STORAGE_FILE_OUTPUT: OutputProperty = {
|
||||
type: 'object',
|
||||
description: 'Storage file object with metadata',
|
||||
properties: STORAGE_FILE_OUTPUT_PROPERTIES,
|
||||
}
|
||||
|
||||
/**
|
||||
* Output definition for storage bucket objects
|
||||
* @see https://github.com/supabase/storage-js/blob/main/src/lib/types.ts
|
||||
*/
|
||||
export const STORAGE_BUCKET_OUTPUT_PROPERTIES = {
|
||||
id: { type: 'string', description: 'Unique bucket identifier' },
|
||||
name: { type: 'string', description: 'Bucket name' },
|
||||
owner: { type: 'string', description: 'Owner identifier', optional: true },
|
||||
public: { type: 'boolean', description: 'Whether the bucket is publicly accessible' },
|
||||
created_at: { type: 'string', description: 'Bucket creation timestamp' },
|
||||
updated_at: { type: 'string', description: 'Last update timestamp' },
|
||||
file_size_limit: {
|
||||
type: 'number',
|
||||
description: 'Maximum file size allowed in bytes',
|
||||
optional: true,
|
||||
},
|
||||
allowed_mime_types: {
|
||||
type: 'array',
|
||||
description: 'List of allowed MIME types for uploads',
|
||||
items: { type: 'string', description: 'MIME type' },
|
||||
optional: true,
|
||||
},
|
||||
} as const satisfies Record<string, OutputProperty>
|
||||
|
||||
/**
|
||||
* Complete storage bucket object output definition
|
||||
*/
|
||||
export const STORAGE_BUCKET_OUTPUT: OutputProperty = {
|
||||
type: 'object',
|
||||
description: 'Storage bucket object with configuration',
|
||||
properties: STORAGE_BUCKET_OUTPUT_PROPERTIES,
|
||||
}
|
||||
|
||||
/**
|
||||
* Output definition for storage upload response.
|
||||
* The Supabase Storage REST API returns `{ Id, Key }` (Key required); Sim's
|
||||
* upload route augments this with `path`, `bucket`, and `publicUrl`.
|
||||
* @see https://supabase.com/docs/reference/javascript/storage-from-upload
|
||||
*/
|
||||
export const STORAGE_UPLOAD_OUTPUT_PROPERTIES = {
|
||||
Id: { type: 'string', description: 'Unique identifier for the uploaded file', optional: true },
|
||||
Key: { type: 'string', description: 'Full object key including bucket name' },
|
||||
path: { type: 'string', description: 'Path to the uploaded file within the bucket' },
|
||||
bucket: { type: 'string', description: 'Name of the bucket the file was uploaded to' },
|
||||
publicUrl: { type: 'string', description: 'Public URL for the uploaded file' },
|
||||
} as const satisfies Record<string, OutputProperty>
|
||||
|
||||
/**
|
||||
* Output definition for storage move response.
|
||||
* The move endpoint returns `{ message, Id, Key }`.
|
||||
* @see https://supabase.com/docs/reference/javascript/storage-from-move
|
||||
*/
|
||||
export const STORAGE_MOVE_OUTPUT_PROPERTIES = {
|
||||
message: { type: 'string', description: 'Operation status message' },
|
||||
Id: { type: 'string', description: 'Identifier of the destination object', optional: true },
|
||||
Key: { type: 'string', description: 'Full object key of the destination', optional: true },
|
||||
} as const satisfies Record<string, OutputProperty>
|
||||
|
||||
/**
|
||||
* Output definition for storage copy response.
|
||||
* The copy endpoint returns `{ Id, Key }` (Key required; Id deprecated).
|
||||
* @see https://github.com/supabase/storage/blob/master/src/http/routes/object/copyObject.ts
|
||||
*/
|
||||
export const STORAGE_COPY_OUTPUT_PROPERTIES = {
|
||||
Key: { type: 'string', description: 'Full object key of the copied file' },
|
||||
Id: { type: 'string', description: 'Identifier of the copied object', optional: true },
|
||||
} as const satisfies Record<string, OutputProperty>
|
||||
|
||||
/**
|
||||
* 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_MESSAGE_OUTPUT_PROPERTIES = {
|
||||
message: { type: 'string', description: 'Operation status message' },
|
||||
} as const satisfies Record<string, OutputProperty>
|
||||
|
||||
/**
|
||||
* Output definition for storage create bucket response
|
||||
* @see https://supabase.com/docs/reference/javascript/storage-createbucket
|
||||
*/
|
||||
export const STORAGE_CREATE_BUCKET_OUTPUT_PROPERTIES = {
|
||||
name: { type: 'string', description: 'Created bucket name' },
|
||||
} as const satisfies Record<string, OutputProperty>
|
||||
|
||||
/**
|
||||
* Output definition for storage delete files response
|
||||
* Returns array of deleted file objects
|
||||
*/
|
||||
export const STORAGE_DELETED_FILE_OUTPUT_PROPERTIES = {
|
||||
name: { type: 'string', description: 'Name of the deleted file' },
|
||||
bucket_id: { type: 'string', description: 'Bucket identifier', optional: true },
|
||||
owner: { type: 'string', description: 'Owner identifier', optional: true },
|
||||
id: { type: 'string', description: 'Unique file identifier', optional: true },
|
||||
updated_at: { type: 'string', description: 'Last update timestamp', optional: true },
|
||||
created_at: { type: 'string', description: 'File creation timestamp', optional: true },
|
||||
last_accessed_at: { type: 'string', description: 'Last access timestamp', optional: true },
|
||||
} as const satisfies Record<string, OutputProperty>
|
||||
|
||||
/**
|
||||
* Output definition for download file response
|
||||
*/
|
||||
export const STORAGE_DOWNLOAD_OUTPUT_PROPERTIES = {
|
||||
name: { type: 'string', description: 'File name' },
|
||||
mimeType: { type: 'string', description: 'MIME type of the file' },
|
||||
data: { type: 'string', description: 'Base64 encoded file content' },
|
||||
size: { type: 'number', description: 'File size in bytes' },
|
||||
} as const satisfies Record<string, OutputProperty>
|
||||
|
||||
/**
|
||||
* Complete download file object output definition
|
||||
*/
|
||||
export const STORAGE_DOWNLOAD_FILE_OUTPUT: OutputProperty = {
|
||||
type: 'object',
|
||||
description: 'Downloaded file with content and metadata',
|
||||
properties: STORAGE_DOWNLOAD_OUTPUT_PROPERTIES,
|
||||
}
|
||||
|
||||
/**
|
||||
* Output definition for public URL response
|
||||
*/
|
||||
export const STORAGE_PUBLIC_URL_OUTPUT_PROPERTIES = {
|
||||
publicUrl: { type: 'string', description: 'The public URL to access the file' },
|
||||
} as const satisfies Record<string, OutputProperty>
|
||||
|
||||
/**
|
||||
* Output definition for signed URL response
|
||||
*/
|
||||
export const STORAGE_SIGNED_URL_OUTPUT_PROPERTIES = {
|
||||
signedUrl: { type: 'string', description: 'The temporary signed URL to access the file' },
|
||||
} as const satisfies Record<string, OutputProperty>
|
||||
|
||||
/**
|
||||
* Storage files array output definition for list operations
|
||||
*/
|
||||
export const STORAGE_FILES_OUTPUT: OutputProperty = {
|
||||
type: 'array',
|
||||
description: 'Array of file objects with metadata',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: STORAGE_FILE_OUTPUT_PROPERTIES,
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage buckets array output definition for list buckets operations
|
||||
*/
|
||||
export const STORAGE_BUCKETS_OUTPUT: OutputProperty = {
|
||||
type: 'array',
|
||||
description: 'Array of bucket objects',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: STORAGE_BUCKET_OUTPUT_PROPERTIES,
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Storage deleted files array output definition
|
||||
*/
|
||||
export const STORAGE_DELETED_FILES_OUTPUT: OutputProperty = {
|
||||
type: 'array',
|
||||
description: 'Array of deleted file objects',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: STORAGE_DELETED_FILE_OUTPUT_PROPERTIES,
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* Output definition for foreign key reference in column schema
|
||||
*/
|
||||
export const INTROSPECT_REFERENCE_OUTPUT_PROPERTIES = {
|
||||
table: { type: 'string', description: 'Referenced table name' },
|
||||
column: { type: 'string', description: 'Referenced column name' },
|
||||
} as const satisfies Record<string, OutputProperty>
|
||||
|
||||
/**
|
||||
* Output definition for introspect column schema
|
||||
*/
|
||||
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 — 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: '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, when detected via SQL comment',
|
||||
optional: true,
|
||||
properties: INTROSPECT_REFERENCE_OUTPUT_PROPERTIES,
|
||||
},
|
||||
} as const satisfies Record<string, OutputProperty>
|
||||
|
||||
/**
|
||||
* Output definition for introspect foreign key
|
||||
*/
|
||||
export const INTROSPECT_FOREIGN_KEY_OUTPUT_PROPERTIES = {
|
||||
column: { type: 'string', description: 'Local column name' },
|
||||
referencesTable: { type: 'string', description: 'Referenced table name' },
|
||||
referencesColumn: { type: 'string', description: 'Referenced column name' },
|
||||
} as const satisfies Record<string, OutputProperty>
|
||||
|
||||
/**
|
||||
* Output definition for introspect index
|
||||
*/
|
||||
export const INTROSPECT_INDEX_OUTPUT_PROPERTIES = {
|
||||
name: { type: 'string', description: 'Index name' },
|
||||
columns: {
|
||||
type: 'array',
|
||||
description: 'Columns included in the index',
|
||||
items: { type: 'string', description: 'Column name' },
|
||||
},
|
||||
unique: { type: 'boolean', description: 'Whether the index enforces uniqueness' },
|
||||
} as const satisfies Record<string, OutputProperty>
|
||||
|
||||
/**
|
||||
* Output definition for introspect table schema
|
||||
*/
|
||||
export const INTROSPECT_TABLE_OUTPUT_PROPERTIES = {
|
||||
name: { type: 'string', description: 'Table name' },
|
||||
schema: { type: 'string', description: 'Database schema name' },
|
||||
columns: {
|
||||
type: 'array',
|
||||
description: 'Array of column definitions',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: INTROSPECT_COLUMN_OUTPUT_PROPERTIES,
|
||||
},
|
||||
},
|
||||
primaryKey: {
|
||||
type: 'array',
|
||||
description: 'Array of primary key column names',
|
||||
items: { type: 'string', description: 'Column name' },
|
||||
},
|
||||
foreignKeys: {
|
||||
type: 'array',
|
||||
description: 'Array of foreign key relationships',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: INTROSPECT_FOREIGN_KEY_OUTPUT_PROPERTIES,
|
||||
},
|
||||
},
|
||||
indexes: {
|
||||
type: 'array',
|
||||
description:
|
||||
'Always empty — index definitions are not exposed by the OpenAPI spec this tool reads',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: INTROSPECT_INDEX_OUTPUT_PROPERTIES,
|
||||
},
|
||||
},
|
||||
} as const satisfies Record<string, OutputProperty>
|
||||
|
||||
/**
|
||||
* Introspect tables array output definition
|
||||
*/
|
||||
export const INTROSPECT_TABLES_OUTPUT: OutputProperty = {
|
||||
type: 'array',
|
||||
description: 'Array of table schemas with columns, keys, and indexes',
|
||||
items: {
|
||||
type: 'object',
|
||||
properties: INTROSPECT_TABLE_OUTPUT_PROPERTIES,
|
||||
},
|
||||
}
|
||||
|
||||
export interface SupabaseQueryParams {
|
||||
apiKey: string
|
||||
projectId: string
|
||||
table: string
|
||||
schema?: string
|
||||
select?: string
|
||||
filter?: string
|
||||
orderBy?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
}
|
||||
|
||||
export interface SupabaseInsertParams {
|
||||
apiKey: string
|
||||
projectId: string
|
||||
table: string
|
||||
schema?: string
|
||||
data: any
|
||||
}
|
||||
|
||||
export interface SupabaseGetRowParams {
|
||||
apiKey: string
|
||||
projectId: string
|
||||
table: string
|
||||
schema?: string
|
||||
select?: string
|
||||
filter: string
|
||||
}
|
||||
|
||||
export interface SupabaseUpdateParams {
|
||||
apiKey: string
|
||||
projectId: string
|
||||
table: string
|
||||
schema?: string
|
||||
filter: string
|
||||
data: any
|
||||
}
|
||||
|
||||
export interface SupabaseDeleteParams {
|
||||
apiKey: string
|
||||
projectId: string
|
||||
table: string
|
||||
schema?: string
|
||||
filter: string
|
||||
}
|
||||
|
||||
export interface SupabaseUpsertParams {
|
||||
apiKey: string
|
||||
projectId: string
|
||||
table: string
|
||||
schema?: string
|
||||
data: any
|
||||
onConflict?: string
|
||||
}
|
||||
|
||||
export interface SupabaseVectorSearchParams {
|
||||
apiKey: string
|
||||
projectId: string
|
||||
functionName: string
|
||||
queryEmbedding: number[]
|
||||
matchThreshold?: number
|
||||
matchCount?: number
|
||||
}
|
||||
|
||||
interface SupabaseBaseResponse extends ToolResponse {
|
||||
output: {
|
||||
message: string
|
||||
results: any
|
||||
}
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface SupabaseQueryResponse extends SupabaseBaseResponse {}
|
||||
|
||||
export interface SupabaseInsertResponse extends SupabaseBaseResponse {}
|
||||
|
||||
export interface SupabaseGetRowResponse extends SupabaseBaseResponse {}
|
||||
|
||||
export interface SupabaseUpdateResponse extends SupabaseBaseResponse {}
|
||||
|
||||
export interface SupabaseDeleteResponse extends SupabaseBaseResponse {}
|
||||
|
||||
export interface SupabaseUpsertResponse extends SupabaseBaseResponse {}
|
||||
|
||||
export interface SupabaseVectorSearchResponse extends SupabaseBaseResponse {}
|
||||
|
||||
export interface SupabaseResponse extends SupabaseBaseResponse {}
|
||||
|
||||
export interface SupabaseRpcParams {
|
||||
apiKey: string
|
||||
projectId: string
|
||||
functionName: string
|
||||
params?: any
|
||||
}
|
||||
|
||||
export interface SupabaseRpcResponse extends SupabaseBaseResponse {}
|
||||
|
||||
export interface SupabaseTextSearchParams {
|
||||
apiKey: string
|
||||
projectId: string
|
||||
table: string
|
||||
schema?: string
|
||||
column: string
|
||||
query: string
|
||||
searchType?: string
|
||||
language?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
}
|
||||
|
||||
export interface SupabaseTextSearchResponse extends SupabaseBaseResponse {}
|
||||
|
||||
export interface SupabaseCountParams {
|
||||
apiKey: string
|
||||
projectId: string
|
||||
table: string
|
||||
schema?: string
|
||||
filter?: string
|
||||
countType?: string
|
||||
}
|
||||
|
||||
export interface SupabaseCountResponse extends ToolResponse {
|
||||
output: {
|
||||
message: string
|
||||
count: number
|
||||
}
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface SupabaseInvokeFunctionParams {
|
||||
apiKey: string
|
||||
projectId: string
|
||||
functionName: string
|
||||
method?: string
|
||||
body?: any
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
|
||||
export interface SupabaseInvokeFunctionResponse extends SupabaseBaseResponse {}
|
||||
|
||||
export interface SupabaseStorageUploadParams {
|
||||
apiKey: string
|
||||
projectId: string
|
||||
bucket: string
|
||||
fileName: string
|
||||
path?: string
|
||||
fileData: UserFile | string
|
||||
contentType?: string
|
||||
cacheControl?: string
|
||||
upsert?: boolean
|
||||
}
|
||||
|
||||
export interface SupabaseStorageUploadResponse extends SupabaseBaseResponse {}
|
||||
|
||||
export interface SupabaseStorageDownloadParams {
|
||||
apiKey: string
|
||||
projectId: string
|
||||
bucket: string
|
||||
path: string
|
||||
fileName?: string
|
||||
}
|
||||
|
||||
export interface SupabaseStorageDownloadResponse extends ToolResponse {
|
||||
output: {
|
||||
file: {
|
||||
name: string
|
||||
mimeType: string
|
||||
data: string | Buffer
|
||||
size: number
|
||||
}
|
||||
}
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface SupabaseStorageListParams {
|
||||
apiKey: string
|
||||
projectId: string
|
||||
bucket: string
|
||||
path?: string
|
||||
limit?: number
|
||||
offset?: number
|
||||
sortBy?: string
|
||||
sortOrder?: string
|
||||
search?: string
|
||||
}
|
||||
|
||||
export interface SupabaseStorageListResponse extends SupabaseBaseResponse {}
|
||||
|
||||
export interface SupabaseStorageDeleteParams {
|
||||
apiKey: string
|
||||
projectId: string
|
||||
bucket: string
|
||||
paths: string[]
|
||||
}
|
||||
|
||||
export interface SupabaseStorageDeleteResponse extends SupabaseBaseResponse {}
|
||||
|
||||
export interface SupabaseStorageMoveParams {
|
||||
apiKey: string
|
||||
projectId: string
|
||||
bucket: string
|
||||
fromPath: string
|
||||
toPath: string
|
||||
}
|
||||
|
||||
export interface SupabaseStorageMoveResponse extends SupabaseBaseResponse {}
|
||||
|
||||
export interface SupabaseStorageCopyParams {
|
||||
apiKey: string
|
||||
projectId: string
|
||||
bucket: string
|
||||
fromPath: string
|
||||
toPath: string
|
||||
}
|
||||
|
||||
export interface SupabaseStorageCopyResponse extends SupabaseBaseResponse {}
|
||||
|
||||
export interface SupabaseStorageCreateBucketParams {
|
||||
apiKey: string
|
||||
projectId: string
|
||||
bucket: string
|
||||
isPublic?: boolean
|
||||
fileSizeLimit?: number
|
||||
allowedMimeTypes?: string[]
|
||||
}
|
||||
|
||||
export interface SupabaseStorageCreateBucketResponse extends SupabaseBaseResponse {}
|
||||
|
||||
export interface SupabaseStorageListBucketsParams {
|
||||
apiKey: string
|
||||
projectId: string
|
||||
}
|
||||
|
||||
export interface SupabaseStorageListBucketsResponse extends SupabaseBaseResponse {}
|
||||
|
||||
export interface SupabaseStorageDeleteBucketParams {
|
||||
apiKey: string
|
||||
projectId: string
|
||||
bucket: string
|
||||
}
|
||||
|
||||
export interface SupabaseStorageDeleteBucketResponse extends SupabaseBaseResponse {}
|
||||
|
||||
export interface SupabaseStorageGetPublicUrlParams {
|
||||
projectId: string
|
||||
bucket: string
|
||||
path: string
|
||||
download?: boolean
|
||||
}
|
||||
|
||||
export interface SupabaseStorageGetPublicUrlResponse extends ToolResponse {
|
||||
output: {
|
||||
message: string
|
||||
publicUrl: string
|
||||
}
|
||||
error?: string
|
||||
}
|
||||
|
||||
export interface SupabaseStorageCreateSignedUrlParams {
|
||||
apiKey: string
|
||||
projectId: string
|
||||
bucket: string
|
||||
path: string
|
||||
expiresIn: number
|
||||
download?: boolean
|
||||
}
|
||||
|
||||
export interface SupabaseStorageCreateSignedUrlResponse extends ToolResponse {
|
||||
output: {
|
||||
message: string
|
||||
signedUrl: string
|
||||
}
|
||||
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
|
||||
*/
|
||||
export interface SupabaseIntrospectParams {
|
||||
apiKey: string
|
||||
projectId: string
|
||||
schema?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Column information for a database table
|
||||
*/
|
||||
export interface SupabaseColumnSchema {
|
||||
name: string
|
||||
type: string
|
||||
nullable: boolean
|
||||
default: string | null
|
||||
isPrimaryKey: boolean
|
||||
isForeignKey: boolean
|
||||
references?: { table: string; column: string }
|
||||
}
|
||||
|
||||
/**
|
||||
* Table schema information including columns, keys, and indexes
|
||||
*/
|
||||
export interface SupabaseTableSchema {
|
||||
name: string
|
||||
schema: string
|
||||
columns: SupabaseColumnSchema[]
|
||||
primaryKey: string[]
|
||||
foreignKeys: Array<{ column: string; referencesTable: string; referencesColumn: string }>
|
||||
indexes: Array<{ name: string; columns: string[]; unique: boolean }>
|
||||
}
|
||||
|
||||
/**
|
||||
* Response from the introspect operation
|
||||
*/
|
||||
export interface SupabaseIntrospectResponse extends ToolResponse {
|
||||
output: {
|
||||
message: string
|
||||
tables: SupabaseTableSchema[]
|
||||
schemas: string[]
|
||||
}
|
||||
error?: string
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
import { validateDatabaseIdentifier } from '@/lib/core/security/input-validation'
|
||||
import type { SupabaseUpdateParams, SupabaseUpdateResponse } from '@/tools/supabase/types'
|
||||
import { supabaseBaseUrl } from '@/tools/supabase/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
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.0',
|
||||
|
||||
params: {
|
||||
projectId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Your Supabase project ID (e.g., jdrkgepadsdopsntdlom)',
|
||||
},
|
||||
table: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The name of the Supabase table to update',
|
||||
},
|
||||
schema: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Database schema to update in (default: public). Use this to access tables in other schemas.',
|
||||
},
|
||||
filter: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'PostgREST filter to identify rows to update (e.g., "id=eq.123")',
|
||||
},
|
||||
data: {
|
||||
type: 'object',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Data to update in the matching rows',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Your Supabase service role secret key',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const tableValidation = validateDatabaseIdentifier(params.table, 'table')
|
||||
if (!tableValidation.isValid) throw new Error(tableValidation.error)
|
||||
|
||||
let url = `${supabaseBaseUrl(params.projectId)}/rest/v1/${encodeURIComponent(params.table)}?select=*`
|
||||
|
||||
if (params.filter?.trim()) {
|
||||
url += `&${params.filter.trim()}`
|
||||
} else {
|
||||
throw new Error(
|
||||
'Filter is required for update operations to prevent accidental update of all rows'
|
||||
)
|
||||
}
|
||||
|
||||
return url
|
||||
},
|
||||
method: 'PATCH',
|
||||
headers: (params) => {
|
||||
const headers: Record<string, string> = {
|
||||
apikey: params.apiKey,
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=representation',
|
||||
}
|
||||
if (params.schema) {
|
||||
headers['Content-Profile'] = params.schema
|
||||
}
|
||||
return headers
|
||||
},
|
||||
body: (params) => params.data,
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const text = await response.text()
|
||||
let data
|
||||
|
||||
if (text?.trim()) {
|
||||
try {
|
||||
data = JSON.parse(text)
|
||||
} catch (parseError) {
|
||||
throw new Error(`Failed to parse Supabase response: ${parseError}`)
|
||||
}
|
||||
} else {
|
||||
data = []
|
||||
}
|
||||
|
||||
const updatedCount = Array.isArray(data) ? data.length : 0
|
||||
|
||||
if (updatedCount === 0) {
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
message: 'No rows were updated (no matching records found)',
|
||||
results: data,
|
||||
},
|
||||
error: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
message: `Successfully updated ${updatedCount} row${updatedCount === 1 ? '' : 's'}`,
|
||||
results: data,
|
||||
},
|
||||
error: undefined,
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
message: { type: 'string', description: 'Operation status message' },
|
||||
results: { type: 'array', description: 'Array of updated records' },
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { validateDatabaseIdentifier } from '@/lib/core/security/input-validation'
|
||||
import type { SupabaseUpsertParams, SupabaseUpsertResponse } from '@/tools/supabase/types'
|
||||
import { supabaseBaseUrl } from '@/tools/supabase/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
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.0',
|
||||
|
||||
params: {
|
||||
projectId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Your Supabase project ID (e.g., jdrkgepadsdopsntdlom)',
|
||||
},
|
||||
table: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The name of the Supabase table to upsert data into',
|
||||
},
|
||||
schema: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Database schema to upsert into (default: public). Use this to access tables in other schemas.',
|
||||
},
|
||||
data: {
|
||||
type: 'array',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The data to upsert (insert or update) - array of objects or a single object',
|
||||
},
|
||||
onConflict: {
|
||||
type: 'string',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'Comma-separated column(s) with a unique or primary key constraint to resolve conflicts on (e.g., "email"). Defaults to the primary key.',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Your Supabase service role secret key',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const tableValidation = validateDatabaseIdentifier(params.table, 'table')
|
||||
if (!tableValidation.isValid) throw new Error(tableValidation.error)
|
||||
let url = `${supabaseBaseUrl(params.projectId)}/rest/v1/${encodeURIComponent(params.table)}?select=*`
|
||||
if (params.onConflict?.trim()) {
|
||||
url += `&on_conflict=${encodeURIComponent(params.onConflict.trim())}`
|
||||
}
|
||||
return url
|
||||
},
|
||||
method: 'POST',
|
||||
headers: (params) => {
|
||||
const headers: Record<string, string> = {
|
||||
apikey: params.apiKey,
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
Prefer: 'return=representation,resolution=merge-duplicates',
|
||||
}
|
||||
if (params.schema) {
|
||||
headers['Content-Profile'] = params.schema
|
||||
}
|
||||
return headers
|
||||
},
|
||||
body: (params) => {
|
||||
// Prepare the data - if it's an object but not an array, wrap it in an array
|
||||
const dataToSend =
|
||||
typeof params.data === 'object' && !Array.isArray(params.data) ? [params.data] : params.data
|
||||
|
||||
return dataToSend
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
const text = await response.text()
|
||||
|
||||
if (!text || text.trim() === '') {
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
message: 'Successfully upserted data into Supabase (no data returned)',
|
||||
results: [],
|
||||
},
|
||||
error: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
let data
|
||||
try {
|
||||
data = JSON.parse(text)
|
||||
} catch (parseError) {
|
||||
throw new Error(`Failed to parse Supabase response: ${parseError}`)
|
||||
}
|
||||
|
||||
// Check if results array is empty and provide better feedback
|
||||
const resultsArray = Array.isArray(data) ? data : [data]
|
||||
const isEmpty = resultsArray.length === 0 || (resultsArray.length === 1 && !resultsArray[0])
|
||||
|
||||
if (isEmpty) {
|
||||
return {
|
||||
success: false,
|
||||
output: {
|
||||
message: 'No data was upserted into Supabase',
|
||||
results: data,
|
||||
},
|
||||
error:
|
||||
'No data was upserted into Supabase. This usually indicates invalid data format or schema mismatch. Please check that your JSON is valid and matches your table schema.',
|
||||
}
|
||||
}
|
||||
|
||||
const upsertedCount = resultsArray.length
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
message: `Successfully upserted ${upsertedCount} row${upsertedCount === 1 ? '' : 's'} into Supabase`,
|
||||
results: data,
|
||||
},
|
||||
error: undefined,
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
message: { type: 'string', description: 'Operation status message' },
|
||||
results: { type: 'array', description: 'Array of upserted records' },
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
/**
|
||||
* @vitest-environment node
|
||||
*/
|
||||
import { envFlagsMock } from '@sim/testing'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
|
||||
vi.mock('@/lib/core/config/env-flags', () => envFlagsMock)
|
||||
|
||||
import { supabaseBaseUrl } from '@/tools/supabase/utils'
|
||||
|
||||
describe('supabaseBaseUrl', () => {
|
||||
it.concurrent('should return the correct URL for a valid project ID', () => {
|
||||
const url = supabaseBaseUrl('jdrkgepadsdopsntdlom')
|
||||
expect(url).toBe('https://jdrkgepadsdopsntdlom.supabase.co')
|
||||
})
|
||||
|
||||
it.concurrent('should throw on fragment injection attempt', () => {
|
||||
expect(() => supabaseBaseUrl('evil#attacker.com')).toThrow()
|
||||
})
|
||||
|
||||
it.concurrent('should throw on empty string', () => {
|
||||
expect(() => supabaseBaseUrl('')).toThrow()
|
||||
})
|
||||
|
||||
it.concurrent('should throw on path traversal', () => {
|
||||
expect(() => supabaseBaseUrl('evil/../../etc')).toThrow()
|
||||
})
|
||||
|
||||
it.concurrent('should throw on authority injection', () => {
|
||||
expect(() => supabaseBaseUrl('evil@attacker.com')).toThrow()
|
||||
})
|
||||
|
||||
it.concurrent('should throw on uppercase letters', () => {
|
||||
expect(() => supabaseBaseUrl('ABCDEFGHIJKLMNOPQRST')).toThrow()
|
||||
})
|
||||
|
||||
it.concurrent('should throw on too-short IDs', () => {
|
||||
expect(() => supabaseBaseUrl('abc')).toThrow()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,35 @@
|
||||
import { validateSupabaseProjectId } from '@/lib/core/security/input-validation'
|
||||
|
||||
/**
|
||||
* Returns the validated Supabase REST API base URL for a given project ID.
|
||||
* Throws if the project ID contains characters that could alter the URL
|
||||
* (e.g. `#`, `/`, `@`), preventing SSRF via fragment injection.
|
||||
*/
|
||||
export function supabaseBaseUrl(projectId: string): string {
|
||||
const result = validateSupabaseProjectId(projectId)
|
||||
if (!result.isValid) {
|
||||
throw new Error(result.error)
|
||||
}
|
||||
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('/')
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
import { validateDatabaseIdentifier } from '@/lib/core/security/input-validation'
|
||||
import type {
|
||||
SupabaseVectorSearchParams,
|
||||
SupabaseVectorSearchResponse,
|
||||
} from '@/tools/supabase/types'
|
||||
import { supabaseBaseUrl } from '@/tools/supabase/utils'
|
||||
import type { ToolConfig } from '@/tools/types'
|
||||
|
||||
export const vectorSearchTool: ToolConfig<
|
||||
SupabaseVectorSearchParams,
|
||||
SupabaseVectorSearchResponse
|
||||
> = {
|
||||
id: 'supabase_vector_search',
|
||||
name: 'Supabase Vector Search',
|
||||
description: 'Perform similarity search using pgvector in a Supabase table',
|
||||
version: '1.0.0',
|
||||
|
||||
params: {
|
||||
projectId: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Your Supabase project ID (e.g., jdrkgepadsdopsntdlom)',
|
||||
},
|
||||
functionName: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description:
|
||||
'The name of the PostgreSQL function that performs vector search (e.g., match_documents)',
|
||||
},
|
||||
queryEmbedding: {
|
||||
type: 'array',
|
||||
required: true,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'The query vector/embedding to search for similar items',
|
||||
},
|
||||
matchThreshold: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Minimum similarity threshold (0-1), typically 0.7-0.9',
|
||||
},
|
||||
matchCount: {
|
||||
type: 'number',
|
||||
required: false,
|
||||
visibility: 'user-or-llm',
|
||||
description: 'Maximum number of results to return (default: 10)',
|
||||
},
|
||||
apiKey: {
|
||||
type: 'string',
|
||||
required: true,
|
||||
visibility: 'user-only',
|
||||
description: 'Your Supabase service role secret key',
|
||||
},
|
||||
},
|
||||
|
||||
request: {
|
||||
url: (params) => {
|
||||
const fnValidation = validateDatabaseIdentifier(params.functionName, 'functionName')
|
||||
if (!fnValidation.isValid) throw new Error(fnValidation.error)
|
||||
return `${supabaseBaseUrl(params.projectId)}/rest/v1/rpc/${encodeURIComponent(params.functionName)}`
|
||||
},
|
||||
method: 'POST',
|
||||
headers: (params) => ({
|
||||
apikey: params.apiKey,
|
||||
Authorization: `Bearer ${params.apiKey}`,
|
||||
'Content-Type': 'application/json',
|
||||
}),
|
||||
body: (params) => {
|
||||
// Build the RPC call parameters
|
||||
const rpcParams: Record<string, any> = {
|
||||
query_embedding: params.queryEmbedding,
|
||||
}
|
||||
|
||||
// Add optional parameters if provided
|
||||
if (params.matchThreshold !== undefined) {
|
||||
rpcParams.match_threshold = Number(params.matchThreshold)
|
||||
}
|
||||
|
||||
if (params.matchCount !== undefined) {
|
||||
rpcParams.match_count = Number(params.matchCount)
|
||||
}
|
||||
|
||||
return rpcParams
|
||||
},
|
||||
},
|
||||
|
||||
transformResponse: async (response: Response) => {
|
||||
let data
|
||||
try {
|
||||
data = await response.json()
|
||||
} catch (parseError) {
|
||||
throw new Error(`Failed to parse Supabase vector search response: ${parseError}`)
|
||||
}
|
||||
|
||||
const resultCount = Array.isArray(data) ? data.length : 0
|
||||
|
||||
if (resultCount === 0) {
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
message: 'No similar vectors found matching the search criteria',
|
||||
results: data,
|
||||
},
|
||||
error: undefined,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
output: {
|
||||
message: `Successfully found ${resultCount} similar vector${resultCount === 1 ? '' : 's'}`,
|
||||
results: data,
|
||||
},
|
||||
error: undefined,
|
||||
}
|
||||
},
|
||||
|
||||
outputs: {
|
||||
message: { type: 'string', description: 'Operation status message' },
|
||||
results: {
|
||||
type: 'array',
|
||||
description:
|
||||
'Array of records with similarity scores from the vector search. Each record includes a similarity field (0-1) indicating how similar it is to the query vector.',
|
||||
},
|
||||
},
|
||||
}
|
||||
Reference in New Issue
Block a user