diff --git a/apps/studio/lib/hosted-api-allowlist.ts b/apps/studio/lib/hosted-api-allowlist.ts new file mode 100644 index 00000000000..614d863ddc3 --- /dev/null +++ b/apps/studio/lib/hosted-api-allowlist.ts @@ -0,0 +1,37 @@ +// [Joshen] Allowlist of API endpoints supported in hosted (platform) mode. +// Every other /api/* route must 404 in platform mode. Shared by the Next +// middleware (proxy.ts) and the TanStack request middleware (start.ts) so +// the list can't drift between the two frameworks while both run in parallel. +export const HOSTED_SUPPORTED_API_URLS = [ + '/ai/sql/generate-v4', + '/ai/sql/policy', + '/ai/feedback/rate', + '/ai/code/complete', + '/ai/sql/cron-v2', + '/ai/sql/title-v2', + '/ai/sql/filter-v1', + '/ai/onboarding/design', + '/ai/feedback/classify', + '/ai/docs', + '/ai/sql/parse-client-code', + '/get-ip-address', + '/get-utc-time', + '/get-deployment-commit', + '/check-cname', + '/edge-functions/test', + '/edge-functions/body', + '/generate-attachment-url', + '/incident-status', + '/incident-banner', + '/status-override', + '/api/integrations/stripe-sync', + '/content/graphql', + '/parse-query', +] + +// `pathname` must be basePath-relative — Next's `nextUrl.pathname` already is, +// and the TanStack guard strips BASE_PATH before calling. Entries are path +// suffixes, so `endsWith` stays correct regardless. +export function isHostedSupportedApiPath(pathname: string): boolean { + return HOSTED_SUPPORTED_API_URLS.some((url) => pathname.endsWith(url)) +} diff --git a/apps/studio/proxy.ts b/apps/studio/proxy.ts index 2ca590998b7..cfb19146dfe 100644 --- a/apps/studio/proxy.ts +++ b/apps/studio/proxy.ts @@ -1,44 +1,17 @@ import type { NextRequest } from 'next/server' import { IS_PLATFORM } from '@/lib/constants' +import { isHostedSupportedApiPath } from '@/lib/hosted-api-allowlist' export const config = { matcher: '/api/:function*', } -// [Joshen] Return 404 for all next.js API endpoints EXCEPT the ones we use in hosted: -const HOSTED_SUPPORTED_API_URLS = [ - '/ai/sql/generate-v4', - '/ai/sql/policy', - '/ai/feedback/rate', - '/ai/code/complete', - '/ai/sql/cron-v2', - '/ai/sql/title-v2', - '/ai/sql/filter-v1', - '/ai/onboarding/design', - '/ai/feedback/classify', - '/ai/docs', - '/ai/sql/parse-client-code', - '/get-ip-address', - '/get-utc-time', - '/get-deployment-commit', - '/check-cname', - '/edge-functions/test', - '/edge-functions/body', - '/generate-attachment-url', - '/incident-status', - '/incident-banner', - '/status-override', - '/api/integrations/stripe-sync', - '/content/graphql', - '/parse-query', -] - +// Return 404 for all next.js API endpoints EXCEPT the ones we use in hosted. +// The allowlist is shared with the TanStack guard (start.ts) — see +// lib/hosted-api-allowlist.ts. export function proxy(request: NextRequest) { - if ( - IS_PLATFORM && - !HOSTED_SUPPORTED_API_URLS.some((url) => request.nextUrl.pathname.endsWith(url)) - ) { + if (IS_PLATFORM && !isHostedSupportedApiPath(request.nextUrl.pathname)) { return Response.json( { success: false, message: 'Endpoint not supported on hosted' }, { status: 404 } diff --git a/apps/studio/start.ts b/apps/studio/start.ts new file mode 100644 index 00000000000..158248a618a --- /dev/null +++ b/apps/studio/start.ts @@ -0,0 +1,35 @@ +import { createMiddleware, createStart } from '@tanstack/react-start' + +import { BASE_PATH, IS_PLATFORM } from '@/lib/constants' +import { isHostedSupportedApiPath } from '@/lib/hosted-api-allowlist' + +// Self-hosted-only API routes must 404 in platform (hosted) mode. Under the +// Next pages router this lives in middleware (proxy.ts), but TanStack Start +// has no middleware runtime, so the guard is migrated here as a global +// request middleware sharing the same allowlist (lib/hosted-api-allowlist.ts). +// On Vercel our `/api/*` (and `/_serverFn/*`) requests are rewritten to the +// api/server.js function which runs the Start handler, so createStartHandler +// runs this server-side for every API request — even though pages are served +// as a static SPA shell. The guard therefore covers all API routes from a +// single place. + +const platformApiGuard = createMiddleware({ type: 'request' }).server(({ request, next }) => { + const { pathname } = new URL(request.url) + // Path relative to the configured basePath — mirrors Next's basePath- + // relative middleware matcher. + const relativePath = + BASE_PATH && pathname.startsWith(BASE_PATH) ? pathname.slice(BASE_PATH.length) : pathname + + if (IS_PLATFORM && relativePath.startsWith('/api/') && !isHostedSupportedApiPath(relativePath)) { + return Response.json( + { success: false, message: 'Endpoint not supported on hosted' }, + { status: 404 } + ) + } + + return next() +}) + +export const startInstance = createStart(() => ({ + requestMiddleware: [platformApiGuard], +}))