diff --git a/apps/studio/components/interfaces/Storage/StorageMenuV2.tsx b/apps/studio/components/interfaces/Storage/StorageMenuV2.tsx
index 9ea0cbe55e5..65d3746b816 100644
--- a/apps/studio/components/interfaces/Storage/StorageMenuV2.tsx
+++ b/apps/studio/components/interfaces/Storage/StorageMenuV2.tsx
@@ -1,4 +1,4 @@
-import { IS_PLATFORM, useParams } from 'common'
+import { useParams } from 'common'
import Link from 'next/link'
import { useRouter } from 'next/router'
import { Badge, Menu } from 'ui'
@@ -10,6 +10,7 @@ import {
useIsAnalyticsBucketsEnabled,
useIsVectorBucketsEnabled,
} from '@/data/config/project-storage-config-query'
+import { useDeploymentMode } from '@/hooks/misc/useDeploymentMode'
import { useIsFeatureEnabled } from '@/hooks/misc/useIsFeatureEnabled'
import { SHORTCUT_IDS, type ShortcutId } from '@/state/shortcuts/registry'
import { useShortcut } from '@/state/shortcuts/useShortcut'
@@ -25,6 +26,8 @@ export const StorageMenuV2 = () => {
const { ref } = useParams()
const page = useStorageV2Page()
+ const { isCli, isPlatform } = useDeploymentMode()
+
const { storageAnalytics, storageVectors } = useIsFeatureEnabled([
'storage:analytics',
'storage:vectors',
@@ -33,8 +36,8 @@ export const StorageMenuV2 = () => {
const isAnalyticsBucketsEnabled = useIsAnalyticsBucketsEnabled({ projectRef: ref })
const isVectorBucketsEnabled = useIsVectorBucketsEnabled({ projectRef: ref })
- const showAnalytics = IS_PLATFORM && storageAnalytics
- const showVectors = IS_PLATFORM && storageVectors
+ const showAnalytics = isPlatform && storageAnalytics
+ const showVectors = (isPlatform && storageVectors) || isCli
useShortcut(SHORTCUT_IDS.NAV_STORAGE_FILES, () => router.push(`/project/${ref}/storage/files`))
useShortcut(
@@ -48,7 +51,7 @@ export const StorageMenuV2 = () => {
{ enabled: showVectors }
)
useShortcut(SHORTCUT_IDS.NAV_STORAGE_S3, () => router.push(`/project/${ref}/storage/s3`), {
- enabled: IS_PLATFORM,
+ enabled: isPlatform,
})
const bucketTypes = Object.entries(BUCKET_TYPES).filter(([key]) => {
@@ -95,7 +98,7 @@ export const StorageMenuV2 = () => {
})}
- {IS_PLATFORM && (
+ {isPlatform && (
<>
diff --git a/apps/studio/components/interfaces/Storage/VectorBuckets/CreateVectorBucketDialog.tsx b/apps/studio/components/interfaces/Storage/VectorBuckets/CreateVectorBucketDialog.tsx
index c3099f00e31..fb0f8c40c42 100644
--- a/apps/studio/components/interfaces/Storage/VectorBuckets/CreateVectorBucketDialog.tsx
+++ b/apps/studio/components/interfaces/Storage/VectorBuckets/CreateVectorBucketDialog.tsx
@@ -141,15 +141,22 @@ export const CreateVectorBucketDialog = ({
}
await createS3VectorsWrapper({ bucketName: values.name })
+ toast.success(`Successfully created vector bucket ${values.name}`)
} catch (error: any) {
- toast.warning(
- `Failed to create vector bucket integration: ${error.message}. The bucket will be created but you will need to manually install the integration.`
+ toast.success(
+
+
Successfully created vector bucket {values.name}
+
+ However, bucket integration will need to be manually installed as we ran into an error:
+
+
{error.message}
+
,
+ { duration: 8000 }
)
}
- setIsLoading(false)
+ setIsLoading(false)
track('storage_bucket_created', { bucketType: 'vector' })
- toast.success(`Successfully created vector bucket ${values.name}`)
form.reset()
setVisible(false)
}
diff --git a/apps/studio/components/interfaces/Storage/VectorBuckets/CreateVectorTableSheet.tsx b/apps/studio/components/interfaces/Storage/VectorBuckets/CreateVectorTableSheet.tsx
index af0a1b2e206..a8ff4e49bd9 100644
--- a/apps/studio/components/interfaces/Storage/VectorBuckets/CreateVectorTableSheet.tsx
+++ b/apps/studio/components/interfaces/Storage/VectorBuckets/CreateVectorTableSheet.tsx
@@ -34,6 +34,7 @@ import { DocsButton } from '@/components/ui/DocsButton'
import { useFDWImportForeignSchemaMutation } from '@/data/fdw/fdw-import-foreign-schema-mutation'
import { useVectorBucketIndexCreateMutation } from '@/data/storage/vector-bucket-index-create-mutation'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
+import { useDeploymentMode } from '@/hooks/misc/useDeploymentMode'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
import { DOCS_URL } from '@/lib/constants'
@@ -104,6 +105,7 @@ interface CreateVectorTableSheetProps {
export const CreateVectorTableSheet = ({ bucketName }: CreateVectorTableSheetProps) => {
const { data: project } = useSelectedProjectQuery()
+ const { isCli } = useDeploymentMode()
const [visible, setVisible] = useQueryState(
'newTable',
@@ -117,7 +119,8 @@ export const CreateVectorTableSheet = ({ bucketName }: CreateVectorTableSheetPro
?.split('supabase_target_schema=')[1]
// [Joshen] Can remove this once this restriction is removed
- const showIndexCreationNotice = isStagingLocal && !!project && project?.region !== 'us-east-1'
+ const showIndexCreationNotice =
+ isStagingLocal && !isCli && !!project && project?.region !== 'us-east-1'
const defaultValues = {
name: '',
diff --git a/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketsErrorState.test.tsx b/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketsErrorState.test.tsx
new file mode 100644
index 00000000000..296c90edbfd
--- /dev/null
+++ b/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketsErrorState.test.tsx
@@ -0,0 +1,52 @@
+import { screen } from '@testing-library/react'
+import { beforeEach, describe, expect, test, vi } from 'vitest'
+
+import { VectorBucketsErrorState } from './VectorBucketsErrorState'
+import type { DeploymentMode } from '@/hooks/misc/useDeploymentMode'
+import { customRender } from '@/tests/lib/custom-render'
+import { ResponseError } from '@/types'
+
+const { mockUseDeploymentMode } = vi.hoisted(() => ({
+ mockUseDeploymentMode: vi.fn<() => DeploymentMode>(),
+}))
+
+vi.mock('@/hooks/misc/useDeploymentMode', () => ({
+ useDeploymentMode: mockUseDeploymentMode,
+}))
+
+vi.mock('@/lib/telemetry/track', () => ({
+ useTrack: () => vi.fn(),
+}))
+
+const deploymentMode = (overrides: Partial
): DeploymentMode => ({
+ isPlatform: false,
+ isCli: false,
+ isSelfHosted: false,
+ ...overrides,
+})
+
+describe('VectorBucketsErrorState', () => {
+ beforeEach(() => {
+ mockUseDeploymentMode.mockReset()
+ })
+
+ test('CLI: shows the config.toml enable guidance, not the support error', () => {
+ mockUseDeploymentMode.mockReturnValue(deploymentMode({ isCli: true }))
+
+ customRender()
+
+ expect(screen.getByText('Vector buckets are not enabled')).toBeInTheDocument()
+ expect(screen.getByText('supabase/config.toml')).toBeInTheDocument()
+ expect(screen.queryByText('Contact support')).not.toBeInTheDocument()
+ })
+
+ test('platform: shows the generic support error', () => {
+ mockUseDeploymentMode.mockReturnValue(deploymentMode({ isPlatform: true }))
+
+ customRender()
+
+ expect(screen.getByText('Failed to retrieve vector buckets')).toBeInTheDocument()
+ expect(screen.getByText('Contact support')).toBeInTheDocument()
+ expect(screen.queryByText('Vector buckets are not enabled')).not.toBeInTheDocument()
+ })
+})
diff --git a/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketsErrorState.tsx b/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketsErrorState.tsx
new file mode 100644
index 00000000000..6bd95e679e3
--- /dev/null
+++ b/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketsErrorState.tsx
@@ -0,0 +1,16 @@
+import { VectorBucketsLocalDisabledState } from './VectorBucketsLocalDisabledState'
+import AlertError from '@/components/ui/AlertError'
+import { useDeploymentMode } from '@/hooks/misc/useDeploymentMode'
+import type { ResponseError } from '@/types'
+
+interface VectorBucketsErrorStateProps {
+ error: ResponseError | null
+}
+
+export const VectorBucketsErrorState = ({ error }: VectorBucketsErrorStateProps) => {
+ const { isCli } = useDeploymentMode()
+
+ if (isCli) return
+
+ return
+}
diff --git a/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketsLocalDisabledState.tsx b/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketsLocalDisabledState.tsx
new file mode 100644
index 00000000000..b58bd8fd8fb
--- /dev/null
+++ b/apps/studio/components/interfaces/Storage/VectorBuckets/VectorBucketsLocalDisabledState.tsx
@@ -0,0 +1,28 @@
+import { Admonition } from 'ui-patterns/admonition'
+import { CodeBlock } from 'ui-patterns/CodeBlock'
+
+const CONFIG_SNIPPET = `[storage.vector]
+enabled = true
+max_buckets = 10
+max_indexes = 5`
+
+/**
+ * Shown on the local CLI when listing vector buckets fails — most commonly
+ * because `[storage.vector]` is not enabled in `config.toml`. Studio can't read
+ * `config.toml` directly, so we surface the snippet to enable the feature rather
+ * than the generic "contact support" error.
+ */
+export const VectorBucketsLocalDisabledState = () => {
+ return (
+
+
+ To use vector buckets locally, enable them in your{' '}
+ supabase/config.toml and restart with{' '}
+ supabase start.
+
+
+ {CONFIG_SNIPPET}
+
+
+ )
+}
diff --git a/apps/studio/components/interfaces/Storage/VectorBuckets/index.tsx b/apps/studio/components/interfaces/Storage/VectorBuckets/index.tsx
index 0f628ec1b70..4003cc2db9c 100644
--- a/apps/studio/components/interfaces/Storage/VectorBuckets/index.tsx
+++ b/apps/studio/components/interfaces/Storage/VectorBuckets/index.tsx
@@ -14,7 +14,7 @@ import { TimestampInfo } from 'ui-patterns/TimestampInfo'
import { EmptyBucketState } from '../EmptyBucketState'
import { CreateBucketButton } from '../NewBucketButton'
import { CreateVectorBucketDialog } from './CreateVectorBucketDialog'
-import AlertError from '@/components/ui/AlertError'
+import { VectorBucketsErrorState } from './VectorBucketsErrorState'
import { AlphaNotice } from '@/components/ui/AlphaNotice'
import { useVectorBucketsQuery } from '@/data/storage/vector-buckets-query'
import { createNavigationHandler } from '@/lib/navigation'
@@ -62,9 +62,7 @@ export const VectorsBuckets = () => {
{isLoadingBuckets && }
- {isErrorBuckets && (
-
- )}
+ {isErrorBuckets && }
{isSuccessBuckets && (
<>
diff --git a/apps/studio/data/config/project-storage-config-query.test.tsx b/apps/studio/data/config/project-storage-config-query.test.tsx
new file mode 100644
index 00000000000..b0bdc6469c0
--- /dev/null
+++ b/apps/studio/data/config/project-storage-config-query.test.tsx
@@ -0,0 +1,113 @@
+import { waitFor } from '@testing-library/react'
+import { HttpResponse } from 'msw'
+import { beforeEach, describe, expect, test, vi } from 'vitest'
+
+import {
+ useIsVectorBucketsEnabled,
+ type ProjectStorageConfigData,
+} from './project-storage-config-query'
+import type { DeploymentMode } from '@/hooks/misc/useDeploymentMode'
+import { customRenderHook } from '@/tests/lib/custom-render'
+import { addAPIMock } from '@/tests/lib/msw'
+
+const { mockIsPlatform, mockUseDeploymentMode } = vi.hoisted(() => ({
+ mockIsPlatform: { value: false },
+ mockUseDeploymentMode: vi.fn<() => DeploymentMode>(),
+}))
+
+// `useProjectStorageConfigQuery` (same module as the hook under test) gates its
+// fetch on the build-time `IS_PLATFORM` constant — mock it so the query fires in
+// the platform cases.
+vi.mock('@/lib/constants', async () => {
+ const actual = await vi.importActual>('@/lib/constants')
+ return {
+ ...actual,
+ get IS_PLATFORM() {
+ return mockIsPlatform.value
+ },
+ }
+})
+
+vi.mock('@/hooks/misc/useDeploymentMode', () => ({
+ useDeploymentMode: mockUseDeploymentMode,
+}))
+
+const createStorageConfig = (vectorBucketsEnabled: boolean): ProjectStorageConfigData =>
+ ({
+ capabilities: { iceberg_catalog: false, list_v2: false },
+ databasePoolMode: 'transaction',
+ external: { upstreamTarget: 'main' },
+ features: {
+ icebergCatalog: { enabled: false, maxCatalogs: 0, maxNamespaces: 0, maxTables: 0 },
+ imageTransformation: { enabled: false },
+ s3Protocol: { enabled: false },
+ vectorBuckets: { enabled: vectorBucketsEnabled, maxBuckets: 0, maxIndexes: 0 },
+ },
+ fileSizeLimit: 0,
+ migrationVersion: 'v1',
+ }) as ProjectStorageConfigData
+
+const deploymentMode = (overrides: Partial): DeploymentMode => ({
+ isPlatform: false,
+ isCli: false,
+ isSelfHosted: false,
+ ...overrides,
+})
+
+describe('useIsVectorBucketsEnabled', () => {
+ beforeEach(() => {
+ mockIsPlatform.value = false
+ mockUseDeploymentMode.mockReset()
+ })
+
+ test('platform + storage config flag enabled: true', async () => {
+ mockIsPlatform.value = true
+ mockUseDeploymentMode.mockReturnValue(deploymentMode({ isPlatform: true }))
+ addAPIMock({
+ method: 'get',
+ path: '/platform/projects/:ref/config/storage',
+ response: () => HttpResponse.json(createStorageConfig(true)),
+ })
+
+ const { result } = customRenderHook(() => useIsVectorBucketsEnabled({ projectRef: 'default' }))
+
+ await waitFor(() => expect(result.current).toBe(true))
+ })
+
+ test('platform + storage config flag disabled: false', async () => {
+ mockIsPlatform.value = true
+ mockUseDeploymentMode.mockReturnValue(deploymentMode({ isPlatform: true }))
+
+ let configRequested = false
+ addAPIMock({
+ method: 'get',
+ path: '/platform/projects/:ref/config/storage',
+ response: () => {
+ configRequested = true
+ return HttpResponse.json(createStorageConfig(false))
+ },
+ })
+
+ const { result } = customRenderHook(() => useIsVectorBucketsEnabled({ projectRef: 'default' }))
+
+ await waitFor(() => expect(configRequested).toBe(true))
+ expect(result.current).toBe(false)
+ })
+
+ test('CLI: true regardless of the storage config flag', () => {
+ // Query is disabled off-platform, so no storage config endpoint is needed.
+ mockUseDeploymentMode.mockReturnValue(deploymentMode({ isCli: true }))
+
+ const { result } = customRenderHook(() => useIsVectorBucketsEnabled({ projectRef: 'default' }))
+
+ expect(result.current).toBe(true)
+ })
+
+ test('self-hosted: false', () => {
+ mockUseDeploymentMode.mockReturnValue(deploymentMode({ isSelfHosted: true }))
+
+ const { result } = customRenderHook(() => useIsVectorBucketsEnabled({ projectRef: 'default' }))
+
+ expect(result.current).toBe(false)
+ })
+})
diff --git a/apps/studio/data/config/project-storage-config-query.ts b/apps/studio/data/config/project-storage-config-query.ts
index 37e21ec49e5..987a0bbe364 100644
--- a/apps/studio/data/config/project-storage-config-query.ts
+++ b/apps/studio/data/config/project-storage-config-query.ts
@@ -3,6 +3,7 @@ import { useQuery } from '@tanstack/react-query'
import { configKeys } from './keys'
import { components } from '@/data/api'
import { get, handleError } from '@/data/fetchers'
+import { useDeploymentMode } from '@/hooks/misc/useDeploymentMode'
import { IS_PLATFORM } from '@/lib/constants'
import type { ResponseError, UseCustomQueryOptions } from '@/types'
@@ -60,6 +61,8 @@ export const useIsAnalyticsBucketsEnabled = ({ projectRef }: { projectRef?: stri
export const useIsVectorBucketsEnabled = ({ projectRef }: { projectRef?: string }) => {
const { data } = useProjectStorageConfigQuery({ projectRef })
- const isVectorBucketsEnabled = !!data?.features.vectorBuckets?.enabled
+ const { isCli, isPlatform } = useDeploymentMode()
+
+ const isVectorBucketsEnabled = isCli || (isPlatform && !!data?.features.vectorBuckets?.enabled)
return isVectorBucketsEnabled
}
diff --git a/apps/studio/data/misc/keys.ts b/apps/studio/data/misc/keys.ts
index 02ec349d343..36d2eed504e 100644
--- a/apps/studio/data/misc/keys.ts
+++ b/apps/studio/data/misc/keys.ts
@@ -5,4 +5,5 @@ export const miscKeys = {
ipAddress: () => ['ip-address'] as const,
clockSkew: () => ['clock-skew'] as const,
enabledFeaturesOverride: () => ['enabled-features-override'] as const,
+ localS3Keys: () => ['local-s3-keys'] as const,
}
diff --git a/apps/studio/data/misc/local-s3-keys-query.ts b/apps/studio/data/misc/local-s3-keys-query.ts
new file mode 100644
index 00000000000..1ca08be065d
--- /dev/null
+++ b/apps/studio/data/misc/local-s3-keys-query.ts
@@ -0,0 +1,32 @@
+import { useQuery } from '@tanstack/react-query'
+
+import { miscKeys } from './keys'
+import { fetchHandler } from '@/data/fetchers'
+import { BASE_PATH, IS_PLATFORM } from '@/lib/constants'
+import type { ResponseError, UseCustomQueryOptions } from '@/types'
+
+export async function getLocalS3Keys() {
+ try {
+ const data = await fetchHandler(`${BASE_PATH}/api/get-s3-keys`).then((res) => res.json())
+ return data as { accessKey?: string; secretKey?: string }
+ } catch (error) {
+ throw error
+ }
+}
+
+export type LocalS3KeysData = Awaited>
+export type LocalS3KeysError = ResponseError
+
+/**
+ * Specifically only for local CLI - to use the S3 keys as defined in the env file
+ */
+export const useLocalS3KeysQuery = ({
+ enabled = true,
+ ...options
+}: UseCustomQueryOptions = {}) =>
+ useQuery({
+ queryKey: miscKeys.localS3Keys(),
+ queryFn: () => getLocalS3Keys(),
+ enabled: enabled && !IS_PLATFORM,
+ ...options,
+ })
diff --git a/apps/studio/data/storage/s3-vectors-wrapper-create-mutation.ts b/apps/studio/data/storage/s3-vectors-wrapper-create-mutation.ts
index da9f22f595b..c77b8c329a6 100644
--- a/apps/studio/data/storage/s3-vectors-wrapper-create-mutation.ts
+++ b/apps/studio/data/storage/s3-vectors-wrapper-create-mutation.ts
@@ -1,5 +1,7 @@
import { PermissionAction } from '@supabase/shared-types/out/constants'
+import { IS_PLATFORM } from 'common'
+import { useLocalS3KeysQuery } from '../misc/local-s3-keys-query'
import { useS3AccessKeyCreateMutation } from './s3-access-key-create-mutation'
import { WRAPPERS } from '@/components/interfaces/Integrations/Wrappers/Wrappers.constants'
import { getVectorURI } from '@/components/interfaces/Storage/StorageSettings/StorageSettings.utils'
@@ -11,14 +13,29 @@ import {
import { useProjectSettingsV2Query } from '@/data/config/project-settings-v2-query'
import { FDWCreateVariables, useFDWCreateMutation } from '@/data/fdw/fdw-create-mutation'
import { useAsyncCheckPermissions } from '@/hooks/misc/useCheckPermissions'
+import { useDeploymentMode } from '@/hooks/misc/useDeploymentMode'
import { useSelectedProjectQuery } from '@/hooks/misc/useSelectedProject'
export const useS3VectorsWrapperCreateMutation = () => {
const { data: project } = useSelectedProjectQuery()
+ const { data: localKeys } = useLocalS3KeysQuery()
+ const { isPlatform } = useDeploymentMode()
const { data: settings } = useProjectSettingsV2Query({ projectRef: project?.ref })
const protocol = settings?.app_config?.protocol ?? 'https'
- const endpoint = settings?.app_config?.storage_endpoint || settings?.app_config?.endpoint
+
+ /**
+ * [Joshen] Endpoint for vectors FDW needs to use docker domain
+ * Not sure if this affects other areas of the dashboard hence why conditionally rendering here
+ * instead of updating `lib/api/self-hosted/settings -> api_config`
+ */
+ const port =
+ !isPlatform && !!settings
+ ? new URL(`${protocol}://${settings.app_config?.endpoint}`).port
+ : null
+ const endpoint = !isPlatform
+ ? `host.docker.internal:${port}`
+ : settings?.app_config?.storage_endpoint || settings?.app_config?.endpoint
const wrapperMeta = WRAPPERS.find((wrapper) => wrapper.name === 's3_vectors_wrapper')
@@ -30,13 +47,32 @@ export const useS3VectorsWrapperCreateMutation = () => {
const { mutateAsync: createS3AccessKey, isPending: isCreatingS3AccessKey } =
useS3AccessKeyCreateMutation()
- const { mutateAsync: createFDW, isPending: isCreatingFDW } = useFDWCreateMutation()
+ // [Joshen] Silence the error, handled upstream
+ const { mutateAsync: createFDW, isPending: isCreatingFDW } = useFDWCreateMutation({
+ onError: () => {},
+ })
const mutateAsync = async ({ bucketName }: { bucketName: string }) => {
- const createS3KeyData = await createS3AccessKey({
- projectRef: project?.ref,
- description: getVectorBucketS3KeyName(bucketName),
- })
+ let accessKey: string | undefined
+ let secretKey: string | undefined
+
+ if (IS_PLATFORM) {
+ const createS3KeyData = await createS3AccessKey({
+ projectRef: project?.ref,
+ description: getVectorBucketS3KeyName(bucketName),
+ })
+ accessKey = createS3KeyData.access_key
+ secretKey = createS3KeyData.secret_key
+ } else {
+ accessKey = localKeys?.accessKey
+ secretKey = localKeys?.secretKey
+ }
+
+ if (!accessKey || !secretKey) {
+ throw new Error(
+ IS_PLATFORM ? 'Failed to obtain S3 keys from the API' : 'Local S3 keys are not configured'
+ )
+ }
const wrapperName = getVectorBucketFDWName(bucketName)
const serverName = getVectorBucketFDWServerName(bucketName)
@@ -48,8 +84,8 @@ export const useS3VectorsWrapperCreateMutation = () => {
formState: {
wrapper_name: wrapperName,
server_name: serverName,
- vault_access_key_id: createS3KeyData?.access_key,
- vault_secret_access_key: createS3KeyData?.secret_key,
+ vault_access_key_id: accessKey,
+ vault_secret_access_key: secretKey,
aws_region: settings!.region,
endpoint_url: getVectorURI(project?.ref ?? '', protocol, endpoint),
},
diff --git a/apps/studio/lib/api/self-hosted/settings.test.ts b/apps/studio/lib/api/self-hosted/settings.test.ts
index d4458dc34c8..4903332909c 100644
--- a/apps/studio/lib/api/self-hosted/settings.test.ts
+++ b/apps/studio/lib/api/self-hosted/settings.test.ts
@@ -51,7 +51,7 @@ describe('api/self-hosted/settings', () => {
expect(settings.db_port).toBe(5432)
expect(settings.db_user).toBe('postgres')
expect(settings.ref).toBe('default')
- expect(settings.region).toBe('ap-southeast-1')
+ expect(settings.region).toBe('local')
expect(settings.status).toBe('ACTIVE_HEALTHY')
expect(settings.ssl_enforced).toBe(false)
})
diff --git a/apps/studio/lib/api/self-hosted/settings.ts b/apps/studio/lib/api/self-hosted/settings.ts
index f7b8a40ae06..4a35bb2fbb8 100644
--- a/apps/studio/lib/api/self-hosted/settings.ts
+++ b/apps/studio/lib/api/self-hosted/settings.ts
@@ -40,7 +40,7 @@ export function getProjectSettings() {
process.env.AUTH_JWT_SECRET ?? 'super-secret-jwt-token-with-at-least-32-characters-long',
name: process.env.DEFAULT_PROJECT_NAME || 'Default Project',
ref: 'default',
- region: 'ap-southeast-1',
+ region: 'local',
service_api_keys: [
{
api_key: process.env.SUPABASE_SERVICE_KEY ?? '',
diff --git a/apps/studio/pages/api/get-s3-keys.ts b/apps/studio/pages/api/get-s3-keys.ts
new file mode 100644
index 00000000000..f6331fbf0bf
--- /dev/null
+++ b/apps/studio/pages/api/get-s3-keys.ts
@@ -0,0 +1,10 @@
+import { NextApiRequest, NextApiResponse } from 'next'
+
+const accessKey = process.env.S3_PROTOCOL_ACCESS_KEY_ID
+const secretKey = process.env.S3_PROTOCOL_ACCESS_KEY_SECRET
+
+const handler = async (_req: NextApiRequest, res: NextApiResponse) => {
+ return res.status(200).json({ accessKey: accessKey, secretKey })
+}
+
+export default handler
diff --git a/apps/studio/pages/api/platform/storage/[ref]/vector-buckets/[id]/index.ts b/apps/studio/pages/api/platform/storage/[ref]/vector-buckets/[id]/index.ts
new file mode 100644
index 00000000000..da26596bbe3
--- /dev/null
+++ b/apps/studio/pages/api/platform/storage/[ref]/vector-buckets/[id]/index.ts
@@ -0,0 +1,37 @@
+import { createClient } from '@supabase/supabase-js'
+import { NextApiRequest, NextApiResponse } from 'next'
+
+import apiWrapper from '@/lib/api/apiWrapper'
+
+const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!)
+
+// eslint-disable-next-line import/no-anonymous-default-export
+export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler)
+
+async function handler(req: NextApiRequest, res: NextApiResponse) {
+ const { method } = req
+
+ switch (method) {
+ case 'GET':
+ return handleGet(req, res)
+ case 'DELETE':
+ return handleDelete(req, res)
+ default:
+ res.setHeader('Allow', ['GET', 'PATCH', 'DELETE'])
+ res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } })
+ }
+}
+
+const handleGet = async (req: NextApiRequest, res: NextApiResponse) => {
+ const { id } = req.query
+ const { data, error } = await supabase.storage.vectors.getBucket(id as string)
+ if (error) return res.status(400).json({ error: { message: error.message } })
+ return res.status(200).json(data.vectorBucket)
+}
+
+const handleDelete = async (req: NextApiRequest, res: NextApiResponse) => {
+ const { id } = req.query
+ const { data, error } = await supabase.storage.vectors.deleteBucket(id as string)
+ if (error) return res.status(400).json({ error: { message: error.message } })
+ return res.status(200).json(data)
+}
diff --git a/apps/studio/pages/api/platform/storage/[ref]/vector-buckets/[id]/indexes/[indexName].ts b/apps/studio/pages/api/platform/storage/[ref]/vector-buckets/[id]/indexes/[indexName].ts
new file mode 100644
index 00000000000..671361ae94f
--- /dev/null
+++ b/apps/studio/pages/api/platform/storage/[ref]/vector-buckets/[id]/indexes/[indexName].ts
@@ -0,0 +1,32 @@
+import { createClient } from '@supabase/supabase-js'
+import { NextApiRequest, NextApiResponse } from 'next'
+
+import apiWrapper from '@/lib/api/apiWrapper'
+
+const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!)
+
+// eslint-disable-next-line import/no-anonymous-default-export
+export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler)
+
+async function handler(req: NextApiRequest, res: NextApiResponse) {
+ const { method } = req
+
+ switch (method) {
+ case 'DELETE':
+ return handleDelete(req, res)
+
+ default:
+ res.setHeader('Allow', ['GET', 'POST'])
+ res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } })
+ }
+}
+const handleDelete = async (req: NextApiRequest, res: NextApiResponse) => {
+ const { id, indexName } = req.query
+
+ const { data, error } = await supabase.storage.vectors
+ .from(id as string)
+ .deleteIndex(indexName as string)
+
+ if (error) return res.status(400).json({ error: { message: error.message } })
+ return res.status(200).json(data)
+}
diff --git a/apps/studio/pages/api/platform/storage/[ref]/vector-buckets/[id]/indexes/index.ts b/apps/studio/pages/api/platform/storage/[ref]/vector-buckets/[id]/indexes/index.ts
new file mode 100644
index 00000000000..272fdc74a9d
--- /dev/null
+++ b/apps/studio/pages/api/platform/storage/[ref]/vector-buckets/[id]/indexes/index.ts
@@ -0,0 +1,58 @@
+import { createClient } from '@supabase/supabase-js'
+import { NextApiRequest, NextApiResponse } from 'next'
+
+import apiWrapper from '@/lib/api/apiWrapper'
+
+const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!)
+
+// eslint-disable-next-line import/no-anonymous-default-export
+export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler)
+
+async function handler(req: NextApiRequest, res: NextApiResponse) {
+ const { method } = req
+
+ switch (method) {
+ case 'GET':
+ return handleGet(req, res)
+ case 'POST':
+ return handlePost(req, res)
+
+ default:
+ res.setHeader('Allow', ['GET', 'POST'])
+ res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } })
+ }
+}
+
+const handleGet = async (req: NextApiRequest, res: NextApiResponse) => {
+ const { id } = req.query
+
+ const { data, error } = await supabase.storage.vectors
+ .from(id as string)
+ .listIndexes({ maxResults: 100 })
+
+ if (error) return res.status(500).json({ error: { message: error.message } })
+
+ const indexes = await Promise.all(
+ data.indexes.map(async ({ indexName }) => {
+ return (await supabase.storage.vectors.from(id as string).getIndex(indexName)).data?.index
+ })
+ )
+
+ return res.status(200).json({ indexes, nextToken: data.nextToken })
+}
+
+const handlePost = async (req: NextApiRequest, res: NextApiResponse) => {
+ const { id } = req.query
+ const { indexName, dataType, dimension, distanceMetric, metadataKeys } = req.body
+ const payload = {
+ indexName,
+ dataType,
+ dimension,
+ distanceMetric,
+ metadataConfiguration: { nonFilterableMetadataKeys: metadataKeys },
+ }
+
+ const { data, error } = await supabase.storage.vectors.from(id as string).createIndex(payload)
+ if (error) return res.status(400).json({ error: { message: error.message } })
+ return res.status(200).json(data)
+}
diff --git a/apps/studio/pages/api/platform/storage/[ref]/vector-buckets/index.ts b/apps/studio/pages/api/platform/storage/[ref]/vector-buckets/index.ts
new file mode 100644
index 00000000000..2d1b91ee053
--- /dev/null
+++ b/apps/studio/pages/api/platform/storage/[ref]/vector-buckets/index.ts
@@ -0,0 +1,38 @@
+import { createClient } from '@supabase/supabase-js'
+import { NextApiRequest, NextApiResponse } from 'next'
+
+import apiWrapper from '@/lib/api/apiWrapper'
+
+const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!)
+
+// eslint-disable-next-line import/no-anonymous-default-export
+export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler)
+
+async function handler(req: NextApiRequest, res: NextApiResponse) {
+ const { method } = req
+
+ switch (method) {
+ case 'GET':
+ return handleGet(req, res)
+ case 'POST':
+ return handlePost(req, res)
+
+ default:
+ res.setHeader('Allow', ['GET', 'POST'])
+ res.status(405).json({ data: null, error: { message: `Method ${method} Not Allowed` } })
+ }
+}
+
+const handleGet = async (_req: NextApiRequest, res: NextApiResponse) => {
+ const { data, error } = await supabase.storage.vectors.listBuckets()
+ if (error) return res.status(500).json({ error: { message: error.message } })
+
+ return res.status(200).json(data)
+}
+
+const handlePost = async (req: NextApiRequest, res: NextApiResponse) => {
+ const { bucketName } = req.body
+ const { data, error } = await supabase.storage.vectors.createBucket(bucketName)
+ if (error) return res.status(400).json({ error: { message: error.message } })
+ return res.status(200).json(data)
+}
diff --git a/apps/studio/pages/project/[ref]/storage/vectors/index.tsx b/apps/studio/pages/project/[ref]/storage/vectors/index.tsx
index edc2cd6a79e..8a277b627ae 100644
--- a/apps/studio/pages/project/[ref]/storage/vectors/index.tsx
+++ b/apps/studio/pages/project/[ref]/storage/vectors/index.tsx
@@ -1,4 +1,4 @@
-import { useParams } from 'common'
+import { IS_PLATFORM, useParams } from 'common'
import { BucketsUpgradePlan } from '@/components/interfaces/Storage/BucketsUpgradePlan'
import { VectorsBuckets } from '@/components/interfaces/Storage/VectorBuckets'
@@ -23,10 +23,12 @@ const StorageVectorsPage: NextPageWithLayout = () => {
project?.region ?? ''
)
- if (!isAvailableInProjectRegion) {
+ if (IS_PLATFORM && !isAvailableInProjectRegion) {
return
- } else if (!isVectorBucketsEnabled) {
+ } else if (IS_PLATFORM && !isVectorBucketsEnabled) {
return
+ } else if (!isVectorBucketsEnabled) {
+ return null
} else {
return
}
diff --git a/apps/studio/tests/pages/project/[ref]/storage/vectors/index.test.tsx b/apps/studio/tests/pages/project/[ref]/storage/vectors/index.test.tsx
new file mode 100644
index 00000000000..f5c0cd53e3e
--- /dev/null
+++ b/apps/studio/tests/pages/project/[ref]/storage/vectors/index.test.tsx
@@ -0,0 +1,155 @@
+import { screen, waitFor } from '@testing-library/react'
+import { http, HttpResponse } from 'msw'
+import { beforeEach, describe, expect, test, vi } from 'vitest'
+
+import type { ProjectStorageConfigData } from '@/data/config/project-storage-config-query'
+import type { ProjectDetail } from '@/data/projects/project-detail-query'
+import { API_URL } from '@/lib/constants'
+import StorageVectorsPage from '@/pages/project/[ref]/storage/vectors'
+import { customRender } from '@/tests/lib/custom-render'
+import { addAPIMock, mswServer } from '@/tests/lib/msw'
+
+const { mockIsPlatform } = vi.hoisted(() => ({
+ mockIsPlatform: { value: true },
+}))
+
+// `IS_PLATFORM` is a build-time constant, so it can't be driven over the
+// network — mock it in both modules that read it (the page imports from
+// `common`; the data hooks read from `@/lib/constants`). Everything else the
+// page branches on comes from real queries via MSW.
+vi.mock('common', async (importOriginal) => {
+ const actual = await importOriginal>()
+ return {
+ ...actual,
+ useParams: () => ({ ref: 'default' }),
+ get IS_PLATFORM() {
+ return mockIsPlatform.value
+ },
+ }
+})
+
+vi.mock('@/lib/constants', async (importOriginal) => {
+ const actual = await importOriginal>()
+ return {
+ ...actual,
+ get IS_PLATFORM() {
+ return mockIsPlatform.value
+ },
+ }
+})
+
+// Stub the heavy leaf children — each fires its own queries and renders a large
+// tree. We only assert which branch the page picks. Keep the real
+// `VECTOR_BUCKETS_AVAILABLE_REGIONS` so the region gate runs for real.
+vi.mock(
+ '@/components/interfaces/Storage/VectorBuckets/RegionLimitation',
+ async (importOriginal) => ({
+ ...(await importOriginal>()),
+ RegionLimitation: () => region-limitation
,
+ })
+)
+
+vi.mock('@/components/interfaces/Storage/VectorBuckets', () => ({
+ VectorsBuckets: () => vectors-buckets
,
+}))
+
+vi.mock('@/components/interfaces/Storage/BucketsUpgradePlan', () => ({
+ BucketsUpgradePlan: ({ type }: { type: string }) => buckets-upgrade-plan-{type}
,
+}))
+
+const AVAILABLE_REGION = 'us-east-1'
+const UNAVAILABLE_REGION = 'ap-south-1'
+
+const mockProject = (region: string) => {
+ addAPIMock({
+ method: 'get',
+ path: '/platform/projects/:ref',
+ // The page only reads `region` off the project
+ response: () => HttpResponse.json({ region } as unknown as ProjectDetail),
+ })
+}
+
+const mockStorageConfig = (vectorBucketsEnabled: boolean) => {
+ addAPIMock({
+ method: 'get',
+ path: '/platform/projects/:ref/config/storage',
+ response: () =>
+ HttpResponse.json({
+ capabilities: { iceberg_catalog: false, list_v2: false },
+ databasePoolMode: 'transaction',
+ external: { upstreamTarget: 'main' },
+ features: {
+ icebergCatalog: { enabled: false, maxCatalogs: 0, maxNamespaces: 0, maxTables: 0 },
+ imageTransformation: { enabled: false },
+ s3Protocol: { enabled: false },
+ vectorBuckets: { enabled: vectorBucketsEnabled, maxBuckets: 0, maxIndexes: 0 },
+ },
+ fileSizeLimit: 0,
+ migrationVersion: 'v1',
+ } as ProjectStorageConfigData),
+ })
+}
+
+const mockDeploymentMode = (isCli: boolean) => {
+ mswServer.use(
+ http.get(`${API_URL}/platform/deployment-mode`, () => HttpResponse.json({ is_cli_mode: isCli }))
+ )
+}
+
+describe('StorageVectorsPage', () => {
+ beforeEach(() => {
+ mockIsPlatform.value = true
+ mockProject(AVAILABLE_REGION)
+ })
+
+ test('platform + region not supported: shows region limitation', async () => {
+ mockProject(UNAVAILABLE_REGION)
+ mockStorageConfig(true)
+
+ customRender()
+
+ expect(await screen.findByText('region-limitation')).toBeInTheDocument()
+ expect(screen.queryByText('vectors-buckets')).not.toBeInTheDocument()
+ })
+
+ test('platform + supported region + not enabled: shows upgrade plan', async () => {
+ mockStorageConfig(false)
+
+ customRender()
+
+ expect(await screen.findByText('buckets-upgrade-plan-vector')).toBeInTheDocument()
+ expect(screen.queryByText('vectors-buckets')).not.toBeInTheDocument()
+ })
+
+ test('platform + supported region + enabled: shows vector buckets', async () => {
+ mockStorageConfig(true)
+
+ customRender()
+
+ expect(await screen.findByText('vectors-buckets')).toBeInTheDocument()
+ })
+
+ test('CLI (non-platform, enabled): shows vector buckets, skips region/upgrade gates', async () => {
+ mockIsPlatform.value = false
+ // Even an unsupported region shouldn't gate off-platform
+ mockProject(UNAVAILABLE_REGION)
+ mockDeploymentMode(true)
+
+ customRender()
+
+ expect(await screen.findByText('vectors-buckets')).toBeInTheDocument()
+ expect(screen.queryByText('region-limitation')).not.toBeInTheDocument()
+ })
+
+ test('self-hosted (non-platform): renders nothing', async () => {
+ mockIsPlatform.value = false
+ mockDeploymentMode(false)
+
+ const { container } = customRender()
+
+ // `useDeploymentMode` defaults to CLI during its loading window, so the page
+ // briefly renders before resolving to self-hosted — wait for it to settle.
+ await waitFor(() => expect(container).toBeEmptyDOMElement())
+ expect(screen.queryByText('vectors-buckets')).not.toBeInTheDocument()
+ })
+})
diff --git a/apps/studio/turbo.jsonc b/apps/studio/turbo.jsonc
index 483593c5843..53b62b1c357 100644
--- a/apps/studio/turbo.jsonc
+++ b/apps/studio/turbo.jsonc
@@ -107,6 +107,8 @@
"VERCEL_GIT_COMMIT_SHA",
"SNIPPETS_MANAGEMENT_FOLDER",
"EDGE_FUNCTIONS_MANAGEMENT_FOLDER",
+ "S3_PROTOCOL_ACCESS_KEY_ID",
+ "S3_PROTOCOL_ACCESS_KEY_SECRET",
],
"outputs": [".next/**", "!.next/cache/**", "!.next/dev/**/*"],
},