feat(self-hosted): add new API keys to self-hosted Studio and MCP server (#46173)

This commit is contained in:
Andrey A. authored and GitHub committed 2026-05-22 12:11:00 +02:00
1 parent ba39e9c387
commit c1276c8e9a
7 files changed
+270 -6

No files matched your search

+2
View File
@@ -10,6 +10,8 @@ SUPABASE_URL=http://localhost:8000
SUPABASE_PUBLIC_URL=http://localhost:8000
SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE
SUPABASE_SERVICE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q
SUPABASE_PUBLISHABLE_KEY=
SUPABASE_SECRET_KEY=
SENTRY_IGNORE_API_RESOLUTION_ERROR=1
LOGFLARE_URL=http://localhost:4000
LOGFLARE_PRIVATE_ACCESS_TOKEN=your-super-secret-and-long-logflare-key-private
@@ -0,0 +1,81 @@
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getDevelopmentOperations } from './mcp'
vi.mock('./settings', () => ({
getProjectSettings: vi.fn(),
}))
vi.mock('./generate-types', () => ({
generateTypescriptTypes: vi.fn(),
}))
describe('api/self-hosted/mcp', () => {
describe('getDevelopmentOperations.getPublishableKeys', () => {
let getProjectSettingsMock: ReturnType<typeof vi.fn>
beforeEach(async () => {
vi.clearAllMocks()
vi.unstubAllEnvs()
const settings = await import('./settings')
getProjectSettingsMock = vi.mocked(settings.getProjectSettings)
})
afterEach(() => {
vi.unstubAllEnvs()
})
it('returns a publishable-typed key from SUPABASE_PUBLISHABLE_KEY when set', async () => {
vi.stubEnv('SUPABASE_PUBLISHABLE_KEY', 'sb_publishable_abc')
const ops = getDevelopmentOperations({})
const keys = await ops.getPublishableKeys('default')
expect(keys).toEqual([
{
api_key: 'sb_publishable_abc',
name: 'publishable',
type: 'publishable',
},
])
// When the env var is set we should short-circuit and never consult project settings.
expect(getProjectSettingsMock).not.toHaveBeenCalled()
})
it('falls back to the anon key from project settings with type "legacy" when env var is unset', async () => {
vi.stubEnv('SUPABASE_PUBLISHABLE_KEY', '')
getProjectSettingsMock.mockReturnValue({
service_api_keys: [
{ api_key: 'service-key-value', name: 'service_role key', tags: 'service_role' },
{ api_key: 'anon-key-value', name: 'anon key', tags: 'anon' },
],
})
const ops = getDevelopmentOperations({})
const keys = await ops.getPublishableKeys('default')
expect(keys).toEqual([
{
api_key: 'anon-key-value',
name: 'anon key',
type: 'legacy',
},
])
})
it('throws when env var is unset and the anon key is missing from project settings', async () => {
vi.stubEnv('SUPABASE_PUBLISHABLE_KEY', '')
getProjectSettingsMock.mockReturnValue({
service_api_keys: [
{ api_key: 'service-key-value', name: 'service_role key', tags: 'service_role' },
],
})
const ops = getDevelopmentOperations({})
await expect(ops.getPublishableKeys('default')).rejects.toThrow(
'Anon key not found in project settings'
)
})
})
})
+12 -5
View File
@@ -74,6 +74,17 @@ export function getDevelopmentOperations({
return `${settings.app_config.protocol}://${settings.app_config.endpoint}`
},
async getPublishableKeys(_projectRef) {
if (process.env.SUPABASE_PUBLISHABLE_KEY) {
const publishableKeysArray: ApiKey[] = [
{
api_key: process.env.SUPABASE_PUBLISHABLE_KEY,
name: 'publishable',
type: 'publishable' as ApiKeyType,
},
]
return publishableKeysArray
}
const settings = getProjectSettings()
const anonKey = settings.service_api_keys.find((key) => key.name === 'anon key')
@@ -81,17 +92,13 @@ export function getDevelopmentOperations({
throw new Error('Anon key not found in project settings')
}
// For self-hosted, only the legacy anon key is available and returned here.
// There is currently no publishable key variable in self-hosted configuration,
// and the migration to new publishable keys requires additional Auth and service setup.
const publishableKeysArray: ApiKey[] = [
{
api_key: anonKey.api_key,
name: anonKey.name,
type: 'anon' as ApiKeyType,
type: 'legacy' as ApiKeyType,
},
]
return publishableKeysArray
},
async generateTypescriptTypes(_projectRef) {
@@ -44,6 +44,32 @@ const handleGetAll = async (_req: NextApiRequest, res: NextApiResponse) => {
prefix: '',
description: 'Legacy service_role API key',
},
...(process.env.SUPABASE_PUBLISHABLE_KEY
? [
{
name: 'publishable',
api_key: process.env.SUPABASE_PUBLISHABLE_KEY,
id: 'publishable',
type: 'publishable',
hash: '',
prefix: '',
description: 'Publishable API key (anon role)',
},
]
: []),
...(process.env.SUPABASE_SECRET_KEY
? [
{
name: 'secret',
api_key: process.env.SUPABASE_SECRET_KEY,
id: 'secret',
type: 'secret',
hash: '',
prefix: '',
description: 'Secret API key (service_role)',
},
]
: []),
]
return res.status(200).json(response)
@@ -0,0 +1,144 @@
import { createMocks } from 'node-mocks-http'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import handler from '../../../../../../pages/api/v1/projects/[ref]/api-keys'
import { mswServer } from '@/tests/lib/msw'
vi.mock('@/lib/constants', () => ({
IS_PLATFORM: false,
API_URL: 'https://api.example.com',
}))
describe('/api/v1/projects/[ref]/api-keys', () => {
beforeEach(() => {
// The handler does not hit the network; disable MSW so unrelated unhandled-request errors don't fire.
mswServer.close()
vi.unstubAllEnvs()
})
afterEach(() => {
vi.unstubAllEnvs()
})
describe('Method handling', () => {
it('should return 405 for non-GET methods', async () => {
const { req, res } = createMocks({ method: 'POST', query: { ref: 'default' } })
await handler(req, res)
expect(res._getStatusCode()).toBe(405)
expect(JSON.parse(res._getData())).toEqual({
data: null,
error: { message: 'Method POST Not Allowed' },
})
expect(res.getHeader('Allow')).toEqual(['GET'])
})
})
describe('GET', () => {
it('returns only the two legacy keys when no new-key env vars are set', async () => {
vi.stubEnv('SUPABASE_ANON_KEY', 'anon-key-value')
vi.stubEnv('SUPABASE_SERVICE_KEY', 'service-key-value')
vi.stubEnv('SUPABASE_PUBLISHABLE_KEY', '')
vi.stubEnv('SUPABASE_SECRET_KEY', '')
const { req, res } = createMocks({ method: 'GET', query: { ref: 'default' } })
await handler(req, res)
expect(res._getStatusCode()).toBe(200)
const data = JSON.parse(res._getData())
expect(data).toHaveLength(2)
expect(data[0]).toMatchObject({
name: 'anon',
id: 'anon',
type: 'legacy',
api_key: 'anon-key-value',
})
expect(data[1]).toMatchObject({
name: 'service_role',
id: 'service_role',
type: 'legacy',
api_key: 'service-key-value',
})
})
it('falls back to empty strings when legacy env vars are unset', async () => {
vi.stubEnv('SUPABASE_ANON_KEY', '')
vi.stubEnv('SUPABASE_SERVICE_KEY', '')
vi.stubEnv('SUPABASE_PUBLISHABLE_KEY', '')
vi.stubEnv('SUPABASE_SECRET_KEY', '')
const { req, res } = createMocks({ method: 'GET', query: { ref: 'default' } })
await handler(req, res)
const data = JSON.parse(res._getData())
expect(data[0].api_key).toBe('')
expect(data[1].api_key).toBe('')
})
it('appends a publishable entry when SUPABASE_PUBLISHABLE_KEY is set', async () => {
vi.stubEnv('SUPABASE_ANON_KEY', 'anon-key-value')
vi.stubEnv('SUPABASE_SERVICE_KEY', 'service-key-value')
vi.stubEnv('SUPABASE_PUBLISHABLE_KEY', 'sb_publishable_abc')
vi.stubEnv('SUPABASE_SECRET_KEY', '')
const { req, res } = createMocks({ method: 'GET', query: { ref: 'default' } })
await handler(req, res)
const data = JSON.parse(res._getData())
expect(data).toHaveLength(3)
expect(data[2]).toEqual({
name: 'publishable',
api_key: 'sb_publishable_abc',
id: 'publishable',
type: 'publishable',
hash: '',
prefix: '',
description: 'Publishable API key (anon role)',
})
expect(data.find((k: { type: string }) => k.type === 'secret')).toBeUndefined()
})
it('appends a secret entry when SUPABASE_SECRET_KEY is set', async () => {
vi.stubEnv('SUPABASE_ANON_KEY', 'anon-key-value')
vi.stubEnv('SUPABASE_SERVICE_KEY', 'service-key-value')
vi.stubEnv('SUPABASE_PUBLISHABLE_KEY', '')
vi.stubEnv('SUPABASE_SECRET_KEY', 'sb_secret_xyz')
const { req, res } = createMocks({ method: 'GET', query: { ref: 'default' } })
await handler(req, res)
const data = JSON.parse(res._getData())
expect(data).toHaveLength(3)
expect(data[2]).toEqual({
name: 'secret',
api_key: 'sb_secret_xyz',
id: 'secret',
type: 'secret',
hash: '',
prefix: '',
description: 'Secret API key (service_role)',
})
expect(data.find((k: { type: string }) => k.type === 'publishable')).toBeUndefined()
})
it('appends both new entries when both env vars are set, in publishable-then-secret order', async () => {
vi.stubEnv('SUPABASE_ANON_KEY', 'anon-key-value')
vi.stubEnv('SUPABASE_SERVICE_KEY', 'service-key-value')
vi.stubEnv('SUPABASE_PUBLISHABLE_KEY', 'sb_publishable_abc')
vi.stubEnv('SUPABASE_SECRET_KEY', 'sb_secret_xyz')
const { req, res } = createMocks({ method: 'GET', query: { ref: 'default' } })
await handler(req, res)
const data = JSON.parse(res._getData())
expect(data).toHaveLength(4)
expect(data.map((k: { id: string }) => k.id)).toEqual([
'anon',
'service_role',
'publishable',
'secret',
])
})
})
})
+2
View File
@@ -63,6 +63,8 @@
"READ_ONLY_API_KEY",
"SUPABASE_SERVICE_KEY",
"SUPABASE_ANON_KEY",
"SUPABASE_PUBLISHABLE_KEY",
"SUPABASE_SECRET_KEY",
"SUPABASE_PUBLIC_URL",
"DEFAULT_PROJECT_NAME",
"DEFAULT_ORGANIZATION_NAME",
+3 -1
View File
@@ -48,13 +48,15 @@ services:
DEFAULT_ORGANIZATION_NAME: ${STUDIO_DEFAULT_ORGANIZATION}
DEFAULT_PROJECT_NAME: ${STUDIO_DEFAULT_PROJECT}
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
OPENAI_API_KEY: ${OPENAI_API_KEY}
SUPABASE_URL: http://kong:8000
SUPABASE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL}
SUPABASE_ANON_KEY: ${ANON_KEY}
SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
AUTH_JWT_SECRET: ${JWT_SECRET}
SUPABASE_PUBLISHABLE_KEY: ${SUPABASE_PUBLISHABLE_KEY}
SUPABASE_SECRET_KEY: ${SUPABASE_SECRET_KEY}
# LOGFLARE_API_KEY is deprecated
LOGFLARE_API_KEY: ${LOGFLARE_PUBLIC_ACCESS_TOKEN}