mirror of
https://github.com/supabase/supabase.git
synced 2026-10-05 09:25:06 +03:00
feat(self-hosted): add new API keys to self-hosted Studio and MCP server (#46173)
This commit is contained in:
1 parent
ba39e9c387
commit
c1276c8e9a
7 files changed
+270
-6
No files matched your search
@@ -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'
|
||||
)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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',
|
||||
])
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
|
||||
Reference in new issue
Block a user