Skip to content

Commit c1276c8

Browse files
authored
feat(self-hosted): add new API keys to self-hosted Studio and MCP server (supabase#46173)
1 parent ba39e9c commit c1276c8

7 files changed

Lines changed: 270 additions & 6 deletions

File tree

apps/studio/.env

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ SUPABASE_URL=http://localhost:8000
1010
SUPABASE_PUBLIC_URL=http://localhost:8000
1111
SUPABASE_ANON_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJhbm9uIiwKICAgICJpc3MiOiAic3VwYWJhc2UtZGVtbyIsCiAgICAiaWF0IjogMTY0MTc2OTIwMCwKICAgICJleHAiOiAxNzk5NTM1NjAwCn0.dc_X5iR_VP_qT0zsiyj_I_OZ2T9FtRU2BBNWN8Bu4GE
1212
SUPABASE_SERVICE_KEY=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyAgCiAgICAicm9sZSI6ICJzZXJ2aWNlX3JvbGUiLAogICAgImlzcyI6ICJzdXBhYmFzZS1kZW1vIiwKICAgICJpYXQiOiAxNjQxNzY5MjAwLAogICAgImV4cCI6IDE3OTk1MzU2MDAKfQ.DaYlNEoUrrEn2Ig7tqibS-PHK5vgusbcbo7X36XVt4Q
13+
SUPABASE_PUBLISHABLE_KEY=
14+
SUPABASE_SECRET_KEY=
1315
SENTRY_IGNORE_API_RESOLUTION_ERROR=1
1416
LOGFLARE_URL=http://localhost:4000
1517
LOGFLARE_PRIVATE_ACCESS_TOKEN=your-super-secret-and-long-logflare-key-private
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
2+
3+
import { getDevelopmentOperations } from './mcp'
4+
5+
vi.mock('./settings', () => ({
6+
getProjectSettings: vi.fn(),
7+
}))
8+
9+
vi.mock('./generate-types', () => ({
10+
generateTypescriptTypes: vi.fn(),
11+
}))
12+
13+
describe('api/self-hosted/mcp', () => {
14+
describe('getDevelopmentOperations.getPublishableKeys', () => {
15+
let getProjectSettingsMock: ReturnType<typeof vi.fn>
16+
17+
beforeEach(async () => {
18+
vi.clearAllMocks()
19+
vi.unstubAllEnvs()
20+
const settings = await import('./settings')
21+
getProjectSettingsMock = vi.mocked(settings.getProjectSettings)
22+
})
23+
24+
afterEach(() => {
25+
vi.unstubAllEnvs()
26+
})
27+
28+
it('returns a publishable-typed key from SUPABASE_PUBLISHABLE_KEY when set', async () => {
29+
vi.stubEnv('SUPABASE_PUBLISHABLE_KEY', 'sb_publishable_abc')
30+
31+
const ops = getDevelopmentOperations({})
32+
const keys = await ops.getPublishableKeys('default')
33+
34+
expect(keys).toEqual([
35+
{
36+
api_key: 'sb_publishable_abc',
37+
name: 'publishable',
38+
type: 'publishable',
39+
},
40+
])
41+
// When the env var is set we should short-circuit and never consult project settings.
42+
expect(getProjectSettingsMock).not.toHaveBeenCalled()
43+
})
44+
45+
it('falls back to the anon key from project settings with type "legacy" when env var is unset', async () => {
46+
vi.stubEnv('SUPABASE_PUBLISHABLE_KEY', '')
47+
getProjectSettingsMock.mockReturnValue({
48+
service_api_keys: [
49+
{ api_key: 'service-key-value', name: 'service_role key', tags: 'service_role' },
50+
{ api_key: 'anon-key-value', name: 'anon key', tags: 'anon' },
51+
],
52+
})
53+
54+
const ops = getDevelopmentOperations({})
55+
const keys = await ops.getPublishableKeys('default')
56+
57+
expect(keys).toEqual([
58+
{
59+
api_key: 'anon-key-value',
60+
name: 'anon key',
61+
type: 'legacy',
62+
},
63+
])
64+
})
65+
66+
it('throws when env var is unset and the anon key is missing from project settings', async () => {
67+
vi.stubEnv('SUPABASE_PUBLISHABLE_KEY', '')
68+
getProjectSettingsMock.mockReturnValue({
69+
service_api_keys: [
70+
{ api_key: 'service-key-value', name: 'service_role key', tags: 'service_role' },
71+
],
72+
})
73+
74+
const ops = getDevelopmentOperations({})
75+
76+
await expect(ops.getPublishableKeys('default')).rejects.toThrow(
77+
'Anon key not found in project settings'
78+
)
79+
})
80+
})
81+
})

apps/studio/lib/api/self-hosted/mcp.ts

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -74,24 +74,31 @@ export function getDevelopmentOperations({
7474
return `${settings.app_config.protocol}://${settings.app_config.endpoint}`
7575
},
7676
async getPublishableKeys(_projectRef) {
77+
if (process.env.SUPABASE_PUBLISHABLE_KEY) {
78+
const publishableKeysArray: ApiKey[] = [
79+
{
80+
api_key: process.env.SUPABASE_PUBLISHABLE_KEY,
81+
name: 'publishable',
82+
type: 'publishable' as ApiKeyType,
83+
},
84+
]
85+
return publishableKeysArray
86+
}
87+
7788
const settings = getProjectSettings()
7889
const anonKey = settings.service_api_keys.find((key) => key.name === 'anon key')
7990

8091
if (!anonKey) {
8192
throw new Error('Anon key not found in project settings')
8293
}
8394

84-
// For self-hosted, only the legacy anon key is available and returned here.
85-
// There is currently no publishable key variable in self-hosted configuration,
86-
// and the migration to new publishable keys requires additional Auth and service setup.
8795
const publishableKeysArray: ApiKey[] = [
8896
{
8997
api_key: anonKey.api_key,
9098
name: anonKey.name,
91-
type: 'anon' as ApiKeyType,
99+
type: 'legacy' as ApiKeyType,
92100
},
93101
]
94-
95102
return publishableKeysArray
96103
},
97104
async generateTypescriptTypes(_projectRef) {

apps/studio/pages/api/v1/projects/[ref]/api-keys.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,32 @@ const handleGetAll = async (_req: NextApiRequest, res: NextApiResponse) => {
4444
prefix: '',
4545
description: 'Legacy service_role API key',
4646
},
47+
...(process.env.SUPABASE_PUBLISHABLE_KEY
48+
? [
49+
{
50+
name: 'publishable',
51+
api_key: process.env.SUPABASE_PUBLISHABLE_KEY,
52+
id: 'publishable',
53+
type: 'publishable',
54+
hash: '',
55+
prefix: '',
56+
description: 'Publishable API key (anon role)',
57+
},
58+
]
59+
: []),
60+
...(process.env.SUPABASE_SECRET_KEY
61+
? [
62+
{
63+
name: 'secret',
64+
api_key: process.env.SUPABASE_SECRET_KEY,
65+
id: 'secret',
66+
type: 'secret',
67+
hash: '',
68+
prefix: '',
69+
description: 'Secret API key (service_role)',
70+
},
71+
]
72+
: []),
4773
]
4874

4975
return res.status(200).json(response)
Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { createMocks } from 'node-mocks-http'
2+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
3+
4+
import handler from '../../../../../../pages/api/v1/projects/[ref]/api-keys'
5+
import { mswServer } from '@/tests/lib/msw'
6+
7+
vi.mock('@/lib/constants', () => ({
8+
IS_PLATFORM: false,
9+
API_URL: 'https://api.example.com',
10+
}))
11+
12+
describe('/api/v1/projects/[ref]/api-keys', () => {
13+
beforeEach(() => {
14+
// The handler does not hit the network; disable MSW so unrelated unhandled-request errors don't fire.
15+
mswServer.close()
16+
vi.unstubAllEnvs()
17+
})
18+
19+
afterEach(() => {
20+
vi.unstubAllEnvs()
21+
})
22+
23+
describe('Method handling', () => {
24+
it('should return 405 for non-GET methods', async () => {
25+
const { req, res } = createMocks({ method: 'POST', query: { ref: 'default' } })
26+
27+
await handler(req, res)
28+
29+
expect(res._getStatusCode()).toBe(405)
30+
expect(JSON.parse(res._getData())).toEqual({
31+
data: null,
32+
error: { message: 'Method POST Not Allowed' },
33+
})
34+
expect(res.getHeader('Allow')).toEqual(['GET'])
35+
})
36+
})
37+
38+
describe('GET', () => {
39+
it('returns only the two legacy keys when no new-key env vars are set', async () => {
40+
vi.stubEnv('SUPABASE_ANON_KEY', 'anon-key-value')
41+
vi.stubEnv('SUPABASE_SERVICE_KEY', 'service-key-value')
42+
vi.stubEnv('SUPABASE_PUBLISHABLE_KEY', '')
43+
vi.stubEnv('SUPABASE_SECRET_KEY', '')
44+
45+
const { req, res } = createMocks({ method: 'GET', query: { ref: 'default' } })
46+
await handler(req, res)
47+
48+
expect(res._getStatusCode()).toBe(200)
49+
const data = JSON.parse(res._getData())
50+
expect(data).toHaveLength(2)
51+
expect(data[0]).toMatchObject({
52+
name: 'anon',
53+
id: 'anon',
54+
type: 'legacy',
55+
api_key: 'anon-key-value',
56+
})
57+
expect(data[1]).toMatchObject({
58+
name: 'service_role',
59+
id: 'service_role',
60+
type: 'legacy',
61+
api_key: 'service-key-value',
62+
})
63+
})
64+
65+
it('falls back to empty strings when legacy env vars are unset', async () => {
66+
vi.stubEnv('SUPABASE_ANON_KEY', '')
67+
vi.stubEnv('SUPABASE_SERVICE_KEY', '')
68+
vi.stubEnv('SUPABASE_PUBLISHABLE_KEY', '')
69+
vi.stubEnv('SUPABASE_SECRET_KEY', '')
70+
71+
const { req, res } = createMocks({ method: 'GET', query: { ref: 'default' } })
72+
await handler(req, res)
73+
74+
const data = JSON.parse(res._getData())
75+
expect(data[0].api_key).toBe('')
76+
expect(data[1].api_key).toBe('')
77+
})
78+
79+
it('appends a publishable entry when SUPABASE_PUBLISHABLE_KEY is set', async () => {
80+
vi.stubEnv('SUPABASE_ANON_KEY', 'anon-key-value')
81+
vi.stubEnv('SUPABASE_SERVICE_KEY', 'service-key-value')
82+
vi.stubEnv('SUPABASE_PUBLISHABLE_KEY', 'sb_publishable_abc')
83+
vi.stubEnv('SUPABASE_SECRET_KEY', '')
84+
85+
const { req, res } = createMocks({ method: 'GET', query: { ref: 'default' } })
86+
await handler(req, res)
87+
88+
const data = JSON.parse(res._getData())
89+
expect(data).toHaveLength(3)
90+
expect(data[2]).toEqual({
91+
name: 'publishable',
92+
api_key: 'sb_publishable_abc',
93+
id: 'publishable',
94+
type: 'publishable',
95+
hash: '',
96+
prefix: '',
97+
description: 'Publishable API key (anon role)',
98+
})
99+
expect(data.find((k: { type: string }) => k.type === 'secret')).toBeUndefined()
100+
})
101+
102+
it('appends a secret entry when SUPABASE_SECRET_KEY is set', async () => {
103+
vi.stubEnv('SUPABASE_ANON_KEY', 'anon-key-value')
104+
vi.stubEnv('SUPABASE_SERVICE_KEY', 'service-key-value')
105+
vi.stubEnv('SUPABASE_PUBLISHABLE_KEY', '')
106+
vi.stubEnv('SUPABASE_SECRET_KEY', 'sb_secret_xyz')
107+
108+
const { req, res } = createMocks({ method: 'GET', query: { ref: 'default' } })
109+
await handler(req, res)
110+
111+
const data = JSON.parse(res._getData())
112+
expect(data).toHaveLength(3)
113+
expect(data[2]).toEqual({
114+
name: 'secret',
115+
api_key: 'sb_secret_xyz',
116+
id: 'secret',
117+
type: 'secret',
118+
hash: '',
119+
prefix: '',
120+
description: 'Secret API key (service_role)',
121+
})
122+
expect(data.find((k: { type: string }) => k.type === 'publishable')).toBeUndefined()
123+
})
124+
125+
it('appends both new entries when both env vars are set, in publishable-then-secret order', async () => {
126+
vi.stubEnv('SUPABASE_ANON_KEY', 'anon-key-value')
127+
vi.stubEnv('SUPABASE_SERVICE_KEY', 'service-key-value')
128+
vi.stubEnv('SUPABASE_PUBLISHABLE_KEY', 'sb_publishable_abc')
129+
vi.stubEnv('SUPABASE_SECRET_KEY', 'sb_secret_xyz')
130+
131+
const { req, res } = createMocks({ method: 'GET', query: { ref: 'default' } })
132+
await handler(req, res)
133+
134+
const data = JSON.parse(res._getData())
135+
expect(data).toHaveLength(4)
136+
expect(data.map((k: { id: string }) => k.id)).toEqual([
137+
'anon',
138+
'service_role',
139+
'publishable',
140+
'secret',
141+
])
142+
})
143+
})
144+
})

apps/studio/turbo.jsonc

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,8 @@
6363
"READ_ONLY_API_KEY",
6464
"SUPABASE_SERVICE_KEY",
6565
"SUPABASE_ANON_KEY",
66+
"SUPABASE_PUBLISHABLE_KEY",
67+
"SUPABASE_SECRET_KEY",
6668
"SUPABASE_PUBLIC_URL",
6769
"DEFAULT_PROJECT_NAME",
6870
"DEFAULT_ORGANIZATION_NAME",

docker/docker-compose.yml

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,13 +48,15 @@ services:
4848

4949
DEFAULT_ORGANIZATION_NAME: ${STUDIO_DEFAULT_ORGANIZATION}
5050
DEFAULT_PROJECT_NAME: ${STUDIO_DEFAULT_PROJECT}
51-
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
51+
OPENAI_API_KEY: ${OPENAI_API_KEY}
5252

5353
SUPABASE_URL: http://kong:8000
5454
SUPABASE_PUBLIC_URL: ${SUPABASE_PUBLIC_URL}
5555
SUPABASE_ANON_KEY: ${ANON_KEY}
5656
SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
5757
AUTH_JWT_SECRET: ${JWT_SECRET}
58+
SUPABASE_PUBLISHABLE_KEY: ${SUPABASE_PUBLISHABLE_KEY}
59+
SUPABASE_SECRET_KEY: ${SUPABASE_SECRET_KEY}
5860

5961
# LOGFLARE_API_KEY is deprecated
6062
LOGFLARE_API_KEY: ${LOGFLARE_PUBLIC_ACCESS_TOKEN}

0 commit comments

Comments
 (0)