diff --git a/apps/studio/pages/api/get-deployment-commit.ts b/apps/studio/pages/api/get-deployment-commit.ts index f45b0c7efb6..9323a95a788 100644 --- a/apps/studio/pages/api/get-deployment-commit.ts +++ b/apps/studio/pages/api/get-deployment-commit.ts @@ -1,19 +1,29 @@ -import { NextApiRequest, NextApiResponse } from 'next' +import type { NextApiRequest, NextApiResponse } from 'next' +import { z } from 'zod' + +const commitSchema = z.object({ + committer: z.object({ date: z.string().datetime({ offset: true }) }), +}) async function getCommitTime(commitSha: string) { try { - const response = await fetch(`https://github.com/supabase/supabase/commit/${commitSha}.json`, { - headers: { - Accept: 'application/json', - }, - }) + const response = await fetch( + `https://api.github.com/repos/supabase/supabase/git/commits/${commitSha}`, + { + headers: { + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2026-03-10', + 'User-Agent': 'Supabase-Studio', + }, + } + ) if (!response.ok) { throw new Error('Failed to fetch commit details') } - const data = await response.json() - return new Date(data.payload.commit.committedDate).toISOString() + const data = commitSchema.parse(await response.json()) + return new Date(data.committer.date).toISOString() } catch (error) { console.error('Error fetching commit time:', error) return 'unknown' @@ -24,15 +34,15 @@ export default async function handler( _req: NextApiRequest, res: NextApiResponse<{ commitSha: string; commitTime: string }> ) { - // Set cache control headers for 10 minutes so that we don't get banned by GitHub API - res.setHeader('Cache-Control', 's-maxage=600') - - // Get the build commit SHA from Vercel environment variable const commitSha = process.env.VERCEL_GIT_COMMIT_SHA || 'development' - - // Only fetch commit time if we have a valid SHA const commitTime = commitSha !== 'development' ? await getCommitTime(commitSha) : 'unknown' + // Valid metadata is identical for all visitors; failed lookups must remain retryable. + res.setHeader( + 'Cache-Control', + commitTime === 'unknown' ? 'private, no-store' : 'public, max-age=0, s-maxage=600' + ) + res.status(200).json({ commitSha, commitTime, diff --git a/apps/studio/tests/pages/api/get-deployment-commit.test.ts b/apps/studio/tests/pages/api/get-deployment-commit.test.ts new file mode 100644 index 00000000000..44c09c40949 --- /dev/null +++ b/apps/studio/tests/pages/api/get-deployment-commit.test.ts @@ -0,0 +1,156 @@ +import type { NextApiRequest, NextApiResponse } from 'next' +import { createMocks } from 'node-mocks-http' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { toWebHandler } from '@/compat/next/api' +import handler from '@/pages/api/get-deployment-commit' + +const SHA = '0123456789abcdef0123456789abcdef01234567' +const COMMIT_URL = `https://api.github.com/repos/supabase/supabase/git/commits/${SHA}` +const fetchMock = vi.fn() + +async function invokeNext() { + const { req, res } = createMocks({ + method: 'GET', + query: { sha: 'untrusted-request-sha' }, + headers: { authorization: 'Bearer test-session', cookie: 'session=test-session' }, + }) + await handler(req, res) + return { + status: res._getStatusCode(), + body: res._getJSONData(), + cacheControl: res.getHeader('Cache-Control'), + } +} + +async function invokeTanstack() { + const response = await toWebHandler(handler)({ + request: new Request( + 'https://studio.example/api/get-deployment-commit?sha=untrusted-request-sha', + { + headers: { authorization: 'Bearer test-session', cookie: 'session=test-session' }, + } + ), + }) + return { + status: response.status, + body: await response.json(), + cacheControl: response.headers.get('Cache-Control'), + } +} + +beforeEach(() => { + vi.stubEnv('VERCEL_GIT_COMMIT_SHA', SHA) + fetchMock.mockReset() + vi.stubGlobal('fetch', fetchMock) + vi.spyOn(console, 'error').mockImplementation(() => {}) +}) + +afterEach(() => { + vi.unstubAllGlobals() + vi.unstubAllEnvs() + vi.restoreAllMocks() +}) + +describe.each([ + { runtime: 'Next.js', invoke: invokeNext }, + { runtime: 'TanStack adapter', invoke: invokeTanstack }, +])('$runtime deployment metadata', ({ invoke }) => { + it.each([ + ['2026-09-29T19:28:49Z', '2026-09-29T19:28:49.000Z'], + ['2026-09-29T15:28:49-04:00', '2026-09-29T19:28:49.000Z'], + ])('normalizes committer date %s and caches public metadata', async (date, commitTime) => { + fetchMock.mockResolvedValue( + Response.json({ + committer: { date, name: 'Example Committer' }, + author: { date: '2020-01-01T00:00:00Z' }, + sha: SHA, + }) + ) + + expect(await invoke()).toEqual({ + status: 200, + body: { commitSha: SHA, commitTime }, + cacheControl: 'public, max-age=0, s-maxage=600', + }) + expect(fetchMock).toHaveBeenCalledExactlyOnceWith(COMMIT_URL, { + headers: { + Accept: 'application/vnd.github+json', + 'X-GitHub-Api-Version': '2026-03-10', + 'User-Agent': 'Supabase-Studio', + }, + }) + }) + + it.each([undefined, '', 'development'])('skips GitHub when the build SHA is %s', async (sha) => { + vi.stubEnv('VERCEL_GIT_COMMIT_SHA', sha) + + expect(await invoke()).toEqual({ + status: 200, + body: { commitSha: 'development', commitTime: 'unknown' }, + cacheControl: 'private, no-store', + }) + expect(fetchMock).not.toHaveBeenCalled() + }) + + it.each([ + ['missing committer', {}], + ['null payload', null], + ['null committer', { committer: null }], + ['missing date', { committer: {} }], + ['null date', { committer: { date: null } }], + ['numeric date', { committer: { date: 0 } }], + ['empty date', { committer: { date: '' } }], + ['invalid date', { committer: { date: 'not-a-date' } }], + ['impossible date', { committer: { date: '2026-02-30T00:00:00Z' } }], + ['author date only', { author: { date: '2026-09-29T19:28:49Z' } }], + [ + 'website payload', + { payload: { commitRoute: { commit: { committedDate: '2026-09-29T19:28:49Z' } } } }, + ], + ])('keeps %s private with the existing unknown fallback', async (_name, data) => { + fetchMock.mockResolvedValue(Response.json(data)) + + expect(await invoke()).toEqual({ + status: 200, + body: { commitSha: SHA, commitTime: 'unknown' }, + cacheControl: 'private, no-store', + }) + }) + + it.each([403, 404, 429, 500])('does not cache GitHub HTTP %s failures', async (status) => { + fetchMock.mockResolvedValue(new Response('Upstream failure', { status })) + + expect(await invoke()).toEqual({ + status: 200, + body: { commitSha: SHA, commitTime: 'unknown' }, + cacheControl: 'private, no-store', + }) + }) + + it('does not cache invalid JSON', async () => { + fetchMock.mockResolvedValue(new Response('{invalid json')) + + expect(await invoke()).toEqual({ + status: 200, + body: { commitSha: SHA, commitTime: 'unknown' }, + cacheControl: 'private, no-store', + }) + }) + + it('allows recovery after a network failure', async () => { + fetchMock.mockRejectedValueOnce(new Error('Connection failed')) + fetchMock.mockResolvedValueOnce(Response.json({ committer: { date: '2026-09-29T19:28:49Z' } })) + + expect(await invoke()).toEqual({ + status: 200, + body: { commitSha: SHA, commitTime: 'unknown' }, + cacheControl: 'private, no-store', + }) + expect(await invoke()).toEqual({ + status: 200, + body: { commitSha: SHA, commitTime: '2026-09-29T19:28:49.000Z' }, + cacheControl: 'public, max-age=0, s-maxage=600', + }) + }) +}) diff --git a/apps/studio/vercel.test.ts b/apps/studio/vercel.test.ts new file mode 100644 index 00000000000..ee2f729c77f --- /dev/null +++ b/apps/studio/vercel.test.ts @@ -0,0 +1,73 @@ +import { createRequire } from 'node:module' +import type { VercelConfig } from '@vercel/config/v1' +import { afterEach, describe, expect, it, vi } from 'vitest' + +// Compile with the routing utilities consumed by the installed Vercel config SDK. +const require = createRequire(import.meta.url) +const vercelRequire = createRequire(require.resolve('@vercel/config/v1')) +const getTransformedRoutes: (config: { headers: VercelConfig['headers'] }) => { + error: unknown + routes: { src?: string; headers?: Record }[] | null +} = vercelRequire('@vercel/routing-utils').getTransformedRoutes + +afterEach(() => { + vi.unstubAllEnvs() + vi.resetModules() +}) + +describe.each(['', '/dashboard'])('Vercel headers with base path "%s"', (basePath) => { + it('leaves only exact deployment metadata paths to the handler cache policy', async () => { + vi.stubEnv('STUDIO_FRAMEWORK', 'tanstack') + vi.stubEnv('NEXT_PUBLIC_BASE_PATH', basePath) + const { config } = await import('./vercel') + const compiled = getTransformedRoutes({ headers: config.headers }) + expect(compiled.error).toBeNull() + expect(compiled.routes).not.toBeNull() + + function cacheHeaders(pathname: string) { + return compiled.routes!.flatMap(({ src, headers }) => + src && new RegExp(src).test(pathname) && headers?.['Cache-Control'] + ? [headers['Cache-Control']] + : [] + ) + } + + for (const prefix of basePath ? ['', basePath] : ['']) { + expect(cacheHeaders(`${prefix}/api/get-deployment-commit`)).toEqual([]) + expect(cacheHeaders(`${prefix}/api/get-deployment-commit/`)).toEqual([]) + + for (const path of [ + '/api/get-deployment-commit/extra', + '/api/get-deployment-commit-other', + '/api/get-deployment-commit.json', + '/api/Get-deployment-commit', + '/api/platform/profile', + '/api/v1/projects/example/api-keys', + '/api/status-page', + '/_serverFn/function-id', + ]) { + expect(cacheHeaders(`${prefix}${path}`), `${prefix}${path}`).toEqual(['private, no-store']) + } + + const securityRule = compiled.routes!.find( + ({ src, headers }) => + src && + new RegExp(src).test(`${prefix}/api/get-deployment-commit`) && + headers?.['X-Frame-Options'] + ) + expect(securityRule).toBeDefined() + } + }) + + it.each([undefined, 'next'])( + 'leaves the Next.js Vercel config empty for framework %s', + async (framework) => { + vi.stubEnv('STUDIO_FRAMEWORK', framework) + vi.stubEnv('NEXT_PUBLIC_BASE_PATH', basePath) + const { config, default: defaultConfig } = await import('./vercel') + + expect(config).toEqual({}) + expect(defaultConfig).toBe(config) + } + ) +}) diff --git a/apps/studio/vercel.ts b/apps/studio/vercel.ts index 9fcd4a9d39b..a76ed02879c 100644 --- a/apps/studio/vercel.ts +++ b/apps/studio/vercel.ts @@ -35,10 +35,12 @@ function headersFor(prefix: string) { // from the CDN, so they live at the edge. Matches next.config's `/(.*?)` // block (CSP, X-Frame-Options, HSTS, etc.). { source: `${prefix}/(.*)`, headers: getSecurityHeaders() }, - // Dynamic function responses must not be cached by any shared cache — - // handlers can still opt in with their own Cache-Control on the - // Response when a response IS safe to cache. - routes.cacheControl(`${prefix}/api/(.*)`, { private: true, noStore: true }), + // Deployment metadata sets its own cache policy based on lookup success. + // All other API paths retain the private default, including nested paths. + routes.cacheControl(`${prefix}/api/((?!get-deployment-commit/?$).*)`, { + private: true, + noStore: true, + }), routes.cacheControl(`${prefix}/_serverFn/(.*)`, { private: true, noStore: true }), // Hashed chunks are covered by Nitro (`/_vercel/immutable/*`, immutable). // Static images and favicons aren't content-hashed, so they can't be