From c1276c8e9a7885d9ee4ce4ca680df97d355bd419 Mon Sep 17 00:00:00 2001 From: "Andrey A." <56412611+aantti@users.noreply.github.com> Date: Fri, 22 May 2026 12:11:00 +0200 Subject: [PATCH] feat(self-hosted): add new API keys to self-hosted Studio and MCP server (#46173) --- apps/studio/.env | 2 + apps/studio/lib/api/self-hosted/mcp.test.ts | 81 ++++++++++ apps/studio/lib/api/self-hosted/mcp.ts | 17 ++- .../pages/api/v1/projects/[ref]/api-keys.ts | 26 ++++ .../api/v1/projects/[ref]/api-keys.test.ts | 144 ++++++++++++++++++ apps/studio/turbo.jsonc | 2 + docker/docker-compose.yml | 4 +- 7 files changed, 270 insertions(+), 6 deletions(-) create mode 100644 apps/studio/lib/api/self-hosted/mcp.test.ts create mode 100644 apps/studio/tests/pages/api/v1/projects/[ref]/api-keys.test.ts diff --git a/apps/studio/.env b/apps/studio/.env index 580824a0180..6a7f9dd8585 100644 --- a/apps/studio/.env +++ b/apps/studio/.env @@ -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 diff --git a/apps/studio/lib/api/self-hosted/mcp.test.ts b/apps/studio/lib/api/self-hosted/mcp.test.ts new file mode 100644 index 00000000000..04936ab180a --- /dev/null +++ b/apps/studio/lib/api/self-hosted/mcp.test.ts @@ -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 + + 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' + ) + }) + }) +}) diff --git a/apps/studio/lib/api/self-hosted/mcp.ts b/apps/studio/lib/api/self-hosted/mcp.ts index 4b7146b84e5..cd4b5ba2793 100644 --- a/apps/studio/lib/api/self-hosted/mcp.ts +++ b/apps/studio/lib/api/self-hosted/mcp.ts @@ -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) { diff --git a/apps/studio/pages/api/v1/projects/[ref]/api-keys.ts b/apps/studio/pages/api/v1/projects/[ref]/api-keys.ts index e0f4fb3e4f9..60ee93438b2 100644 --- a/apps/studio/pages/api/v1/projects/[ref]/api-keys.ts +++ b/apps/studio/pages/api/v1/projects/[ref]/api-keys.ts @@ -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) diff --git a/apps/studio/tests/pages/api/v1/projects/[ref]/api-keys.test.ts b/apps/studio/tests/pages/api/v1/projects/[ref]/api-keys.test.ts new file mode 100644 index 00000000000..906c2195d54 --- /dev/null +++ b/apps/studio/tests/pages/api/v1/projects/[ref]/api-keys.test.ts @@ -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', + ]) + }) + }) +}) diff --git a/apps/studio/turbo.jsonc b/apps/studio/turbo.jsonc index 25d64c35428..483593c5843 100644 --- a/apps/studio/turbo.jsonc +++ b/apps/studio/turbo.jsonc @@ -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", diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 660a32e550c..010908eb39c 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -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}