diff --git a/apps/studio/eslint.config.cjs b/apps/studio/eslint.config.cjs index 693497c89ff..83e90b2ae12 100644 --- a/apps/studio/eslint.config.cjs +++ b/apps/studio/eslint.config.cjs @@ -3,6 +3,37 @@ const barrelFiles = require('eslint-plugin-barrel-files') const jsxA11y = require('eslint-plugin-jsx-a11y') const supabaseConfig = require('eslint-config-supabase/next') +// Analytics SQL wire boundary — see the block below for context. Shared so the +// API/route block can re-include it (flat config replaces, not merges, a rule's +// options when blocks overlap, so the later block must carry these forward). +const ANALYTICS_SQL_RESTRICTED_SYNTAX = [ + { + selector: + "CallExpression[callee.name=/^(post|get)$/][arguments.0.value='/platform/projects/{ref}/analytics/endpoints/logs.all']", + message: + 'Do not call the analytics logs.all endpoint directly. Route through executeAnalyticsSql in @/data/logs/execute-analytics-sql so the SafeLogSqlFragment brand is enforced at compile time.', + }, + { + selector: + "CallExpression[callee.name=/^(post|get)$/][arguments.0.value='/platform/projects/{ref}/analytics/endpoints/logs.all.otel']", + message: + 'Do not call the analytics logs.all.otel endpoint directly. Route through executeAnalyticsSql in @/data/logs/execute-analytics-sql so the SafeLogSqlFragment brand is enforced at compile time.', + }, +] + +// Ban constructing a Supabase client at module scope in API route files. The +// TanStack server imports the entire route tree at boot (loadEntries), so a +// module-scope createClient with an env var that's unset in that environment +// (e.g. SUPABASE_URL on platform) throws on import and 500s every route — a +// runtime-only failure that's painful to catch. Construct it lazily inside the +// handler instead (see lib/api/self-hosted-admin.ts). +const NO_MODULE_SCOPE_CREATE_CLIENT = { + selector: + ":matches(Program, ExportNamedDeclaration) > VariableDeclaration > VariableDeclarator > CallExpression[callee.name='createClient']", + message: + 'Do not construct a Supabase client at module scope in API route files — the TanStack server evaluates every route module at boot, so a missing env var (e.g. SUPABASE_URL on platform) crashes every route. Construct it lazily inside the handler (see lib/api/self-hosted-admin.ts).', +} + module.exports = defineConfig([ { files: ['**/*.ts', '**/*.tsx'] }, supabaseConfig, @@ -40,21 +71,21 @@ module.exports = defineConfig([ { files: ['**/*.ts', '**/*.tsx'], ignores: ['data/logs/execute-analytics-sql.ts'], + rules: { + 'no-restricted-syntax': ['error', ...ANALYTICS_SQL_RESTRICTED_SYNTAX], + }, + }, + // API route modules are eagerly imported by the TanStack server at boot, so + // module-scope side effects there are especially dangerous. This block also + // re-includes the analytics selectors because flat config replaces (not + // merges) a rule's options for overlapping files. + { + files: ['pages/api/**/*.ts', 'pages/api/**/*.tsx', 'routes/**/*.ts', 'routes/**/*.tsx'], rules: { 'no-restricted-syntax': [ 'error', - { - selector: - "CallExpression[callee.name=/^(post|get)$/][arguments.0.value='/platform/projects/{ref}/analytics/endpoints/logs.all']", - message: - 'Do not call the analytics logs.all endpoint directly. Route through executeAnalyticsSql in @/data/logs/execute-analytics-sql so the SafeLogSqlFragment brand is enforced at compile time.', - }, - { - selector: - "CallExpression[callee.name=/^(post|get)$/][arguments.0.value='/platform/projects/{ref}/analytics/endpoints/logs.all.otel']", - message: - 'Do not call the analytics logs.all.otel endpoint directly. Route through executeAnalyticsSql in @/data/logs/execute-analytics-sql so the SafeLogSqlFragment brand is enforced at compile time.', - }, + ...ANALYTICS_SQL_RESTRICTED_SYNTAX, + NO_MODULE_SCOPE_CREATE_CLIENT, ], }, }, diff --git a/apps/studio/lib/api/self-hosted-admin.ts b/apps/studio/lib/api/self-hosted-admin.ts new file mode 100644 index 00000000000..30b23f13508 --- /dev/null +++ b/apps/studio/lib/api/self-hosted-admin.ts @@ -0,0 +1,22 @@ +import { createClient, SupabaseClient } from '@supabase/supabase-js' + +// Lazy admin client for self-hosted API routes under +// `pages/api/platform/{auth,storage}/**`. SUPABASE_URL and +// SUPABASE_SERVICE_KEY are only set on self-hosted deployments — the +// platform build doesn't need these env vars. But on the TanStack Start +// server, every API route's module gets evaluated when the single function +// handler loads, regardless of whether its URL is hit. Without a lazy +// wrapper, constructing the client at module scope with undefined +// credentials would crash every request on platform. +// +// Proxy defers client construction until a property is actually accessed, +// which only happens inside the handler (i.e. on self-hosted where the env +// vars are set). +let _client: SupabaseClient | undefined + +export const selfHostedSupabaseAdmin = new Proxy({} as SupabaseClient, { + get(_target, prop) { + _client ??= createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!) + return Reflect.get(_client, prop) + }, +}) diff --git a/apps/studio/pages/api/platform/auth/[ref]/users/[id]/factors.ts b/apps/studio/pages/api/platform/auth/[ref]/users/[id]/factors.ts index de399d7143c..dc03b15282b 100644 --- a/apps/studio/pages/api/platform/auth/[ref]/users/[id]/factors.ts +++ b/apps/studio/pages/api/platform/auth/[ref]/users/[id]/factors.ts @@ -1,9 +1,7 @@ -import { createClient } from '@supabase/supabase-js' import { NextApiRequest, NextApiResponse } from 'next' import apiWrapper from '@/lib/api/apiWrapper' - -const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!) +import { selfHostedSupabaseAdmin as supabase } from '@/lib/api/self-hosted-admin' export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler) diff --git a/apps/studio/pages/api/platform/auth/[ref]/users/[id]/index.ts b/apps/studio/pages/api/platform/auth/[ref]/users/[id]/index.ts index fd1b9ab1b03..78d0186402b 100644 --- a/apps/studio/pages/api/platform/auth/[ref]/users/[id]/index.ts +++ b/apps/studio/pages/api/platform/auth/[ref]/users/[id]/index.ts @@ -1,9 +1,7 @@ -import { createClient } from '@supabase/supabase-js' import { NextApiRequest, NextApiResponse } from 'next' import apiWrapper from '@/lib/api/apiWrapper' - -const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!) +import { selfHostedSupabaseAdmin as supabase } from '@/lib/api/self-hosted-admin' export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler) diff --git a/apps/studio/pages/api/platform/auth/[ref]/users/index.ts b/apps/studio/pages/api/platform/auth/[ref]/users/index.ts index a048ff84e24..4e7be6cf014 100644 --- a/apps/studio/pages/api/platform/auth/[ref]/users/index.ts +++ b/apps/studio/pages/api/platform/auth/[ref]/users/index.ts @@ -1,9 +1,7 @@ -import { createClient } from '@supabase/supabase-js' import { NextApiRequest, NextApiResponse } from 'next' import apiWrapper from '@/lib/api/apiWrapper' - -const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!) +import { selfHostedSupabaseAdmin as supabase } from '@/lib/api/self-hosted-admin' export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler) diff --git a/apps/studio/pages/api/platform/storage/[ref]/buckets/[id]/empty.ts b/apps/studio/pages/api/platform/storage/[ref]/buckets/[id]/empty.ts index e452568d997..1d6987f90ce 100644 --- a/apps/studio/pages/api/platform/storage/[ref]/buckets/[id]/empty.ts +++ b/apps/studio/pages/api/platform/storage/[ref]/buckets/[id]/empty.ts @@ -1,9 +1,7 @@ -import { createClient } from '@supabase/supabase-js' import { NextApiRequest, NextApiResponse } from 'next' import apiWrapper from '@/lib/api/apiWrapper' - -const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!) +import { selfHostedSupabaseAdmin as supabase } from '@/lib/api/self-hosted-admin' export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler) diff --git a/apps/studio/pages/api/platform/storage/[ref]/buckets/[id]/index.ts b/apps/studio/pages/api/platform/storage/[ref]/buckets/[id]/index.ts index 06100984874..f986a0570d2 100644 --- a/apps/studio/pages/api/platform/storage/[ref]/buckets/[id]/index.ts +++ b/apps/studio/pages/api/platform/storage/[ref]/buckets/[id]/index.ts @@ -1,9 +1,7 @@ -import { createClient } from '@supabase/supabase-js' import { NextApiRequest, NextApiResponse } from 'next' import apiWrapper from '@/lib/api/apiWrapper' - -const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!) +import { selfHostedSupabaseAdmin as supabase } from '@/lib/api/self-hosted-admin' export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler) diff --git a/apps/studio/pages/api/platform/storage/[ref]/buckets/[id]/objects/download.ts b/apps/studio/pages/api/platform/storage/[ref]/buckets/[id]/objects/download.ts index 1544687520c..6edfca45816 100644 --- a/apps/studio/pages/api/platform/storage/[ref]/buckets/[id]/objects/download.ts +++ b/apps/studio/pages/api/platform/storage/[ref]/buckets/[id]/objects/download.ts @@ -1,9 +1,7 @@ -import { createClient } from '@supabase/supabase-js' import { NextApiRequest, NextApiResponse } from 'next' import apiWrapper from '@/lib/api/apiWrapper' - -const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!) +import { selfHostedSupabaseAdmin as supabase } from '@/lib/api/self-hosted-admin' export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler) diff --git a/apps/studio/pages/api/platform/storage/[ref]/buckets/[id]/objects/index.ts b/apps/studio/pages/api/platform/storage/[ref]/buckets/[id]/objects/index.ts index c8ff7567799..861d0e2f8a0 100644 --- a/apps/studio/pages/api/platform/storage/[ref]/buckets/[id]/objects/index.ts +++ b/apps/studio/pages/api/platform/storage/[ref]/buckets/[id]/objects/index.ts @@ -1,9 +1,7 @@ -import { createClient } from '@supabase/supabase-js' import { NextApiRequest, NextApiResponse } from 'next' import apiWrapper from '@/lib/api/apiWrapper' - -const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!) +import { selfHostedSupabaseAdmin as supabase } from '@/lib/api/self-hosted-admin' export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler) diff --git a/apps/studio/pages/api/platform/storage/[ref]/buckets/[id]/objects/list.ts b/apps/studio/pages/api/platform/storage/[ref]/buckets/[id]/objects/list.ts index 845cc22deab..d9e76d9fa2b 100644 --- a/apps/studio/pages/api/platform/storage/[ref]/buckets/[id]/objects/list.ts +++ b/apps/studio/pages/api/platform/storage/[ref]/buckets/[id]/objects/list.ts @@ -1,9 +1,7 @@ -import { createClient } from '@supabase/supabase-js' import { NextApiRequest, NextApiResponse } from 'next' import apiWrapper from '@/lib/api/apiWrapper' - -const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!) +import { selfHostedSupabaseAdmin as supabase } from '@/lib/api/self-hosted-admin' export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler) diff --git a/apps/studio/pages/api/platform/storage/[ref]/buckets/[id]/objects/move.ts b/apps/studio/pages/api/platform/storage/[ref]/buckets/[id]/objects/move.ts index 8598f42a62d..9978c8b17cf 100644 --- a/apps/studio/pages/api/platform/storage/[ref]/buckets/[id]/objects/move.ts +++ b/apps/studio/pages/api/platform/storage/[ref]/buckets/[id]/objects/move.ts @@ -1,9 +1,7 @@ -import { createClient } from '@supabase/supabase-js' import { NextApiRequest, NextApiResponse } from 'next' import apiWrapper from '@/lib/api/apiWrapper' - -const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!) +import { selfHostedSupabaseAdmin as supabase } from '@/lib/api/self-hosted-admin' export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler) diff --git a/apps/studio/pages/api/platform/storage/[ref]/buckets/[id]/objects/public-url.ts b/apps/studio/pages/api/platform/storage/[ref]/buckets/[id]/objects/public-url.ts index c0b01c6a871..886d19886d7 100644 --- a/apps/studio/pages/api/platform/storage/[ref]/buckets/[id]/objects/public-url.ts +++ b/apps/studio/pages/api/platform/storage/[ref]/buckets/[id]/objects/public-url.ts @@ -1,9 +1,7 @@ -import { createClient } from '@supabase/supabase-js' import { NextApiRequest, NextApiResponse } from 'next' import apiWrapper from '@/lib/api/apiWrapper' - -const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!) +import { selfHostedSupabaseAdmin as supabase } from '@/lib/api/self-hosted-admin' export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler) diff --git a/apps/studio/pages/api/platform/storage/[ref]/buckets/[id]/objects/sign-multi.ts b/apps/studio/pages/api/platform/storage/[ref]/buckets/[id]/objects/sign-multi.ts index c670f177322..472f20c2e5e 100644 --- a/apps/studio/pages/api/platform/storage/[ref]/buckets/[id]/objects/sign-multi.ts +++ b/apps/studio/pages/api/platform/storage/[ref]/buckets/[id]/objects/sign-multi.ts @@ -1,9 +1,7 @@ -import { createClient } from '@supabase/supabase-js' import { NextApiRequest, NextApiResponse } from 'next' import apiWrapper from '@/lib/api/apiWrapper' - -const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!) +import { selfHostedSupabaseAdmin as supabase } from '@/lib/api/self-hosted-admin' const wrappedHandler = (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler) diff --git a/apps/studio/pages/api/platform/storage/[ref]/buckets/[id]/objects/sign.ts b/apps/studio/pages/api/platform/storage/[ref]/buckets/[id]/objects/sign.ts index 26bf7c88d6e..f077d8201c8 100644 --- a/apps/studio/pages/api/platform/storage/[ref]/buckets/[id]/objects/sign.ts +++ b/apps/studio/pages/api/platform/storage/[ref]/buckets/[id]/objects/sign.ts @@ -1,9 +1,7 @@ -import { createClient } from '@supabase/supabase-js' import { NextApiRequest, NextApiResponse } from 'next' import apiWrapper from '@/lib/api/apiWrapper' - -const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!) +import { selfHostedSupabaseAdmin as supabase } from '@/lib/api/self-hosted-admin' export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler) diff --git a/apps/studio/pages/api/platform/storage/[ref]/buckets/index.ts b/apps/studio/pages/api/platform/storage/[ref]/buckets/index.ts index 010e7b723e5..45d11d1895e 100644 --- a/apps/studio/pages/api/platform/storage/[ref]/buckets/index.ts +++ b/apps/studio/pages/api/platform/storage/[ref]/buckets/index.ts @@ -1,9 +1,7 @@ -import { createClient } from '@supabase/supabase-js' import { NextApiRequest, NextApiResponse } from 'next' import apiWrapper from '@/lib/api/apiWrapper' - -const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!) +import { selfHostedSupabaseAdmin as supabase } from '@/lib/api/self-hosted-admin' export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler) diff --git a/apps/studio/pages/api/platform/storage/[ref]/vector-buckets/[id]/index.ts b/apps/studio/pages/api/platform/storage/[ref]/vector-buckets/[id]/index.ts index da26596bbe3..b9b0f56cb84 100644 --- a/apps/studio/pages/api/platform/storage/[ref]/vector-buckets/[id]/index.ts +++ b/apps/studio/pages/api/platform/storage/[ref]/vector-buckets/[id]/index.ts @@ -1,9 +1,7 @@ -import { createClient } from '@supabase/supabase-js' import { NextApiRequest, NextApiResponse } from 'next' import apiWrapper from '@/lib/api/apiWrapper' - -const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!) +import { selfHostedSupabaseAdmin as supabase } from '@/lib/api/self-hosted-admin' // eslint-disable-next-line import/no-anonymous-default-export export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler) diff --git a/apps/studio/pages/api/platform/storage/[ref]/vector-buckets/[id]/indexes/[indexName].ts b/apps/studio/pages/api/platform/storage/[ref]/vector-buckets/[id]/indexes/[indexName].ts index 671361ae94f..a34c6f86c9d 100644 --- a/apps/studio/pages/api/platform/storage/[ref]/vector-buckets/[id]/indexes/[indexName].ts +++ b/apps/studio/pages/api/platform/storage/[ref]/vector-buckets/[id]/indexes/[indexName].ts @@ -1,9 +1,7 @@ -import { createClient } from '@supabase/supabase-js' import { NextApiRequest, NextApiResponse } from 'next' import apiWrapper from '@/lib/api/apiWrapper' - -const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!) +import { selfHostedSupabaseAdmin as supabase } from '@/lib/api/self-hosted-admin' // eslint-disable-next-line import/no-anonymous-default-export export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler) diff --git a/apps/studio/pages/api/platform/storage/[ref]/vector-buckets/[id]/indexes/index.ts b/apps/studio/pages/api/platform/storage/[ref]/vector-buckets/[id]/indexes/index.ts index 272fdc74a9d..cc3d5e071da 100644 --- a/apps/studio/pages/api/platform/storage/[ref]/vector-buckets/[id]/indexes/index.ts +++ b/apps/studio/pages/api/platform/storage/[ref]/vector-buckets/[id]/indexes/index.ts @@ -1,9 +1,7 @@ -import { createClient } from '@supabase/supabase-js' import { NextApiRequest, NextApiResponse } from 'next' import apiWrapper from '@/lib/api/apiWrapper' - -const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!) +import { selfHostedSupabaseAdmin as supabase } from '@/lib/api/self-hosted-admin' // eslint-disable-next-line import/no-anonymous-default-export export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler) diff --git a/apps/studio/pages/api/platform/storage/[ref]/vector-buckets/index.ts b/apps/studio/pages/api/platform/storage/[ref]/vector-buckets/index.ts index 2d1b91ee053..9aea341ef1d 100644 --- a/apps/studio/pages/api/platform/storage/[ref]/vector-buckets/index.ts +++ b/apps/studio/pages/api/platform/storage/[ref]/vector-buckets/index.ts @@ -1,9 +1,7 @@ -import { createClient } from '@supabase/supabase-js' import { NextApiRequest, NextApiResponse } from 'next' import apiWrapper from '@/lib/api/apiWrapper' - -const supabase = createClient(process.env.SUPABASE_URL!, process.env.SUPABASE_SERVICE_KEY!) +import { selfHostedSupabaseAdmin as supabase } from '@/lib/api/self-hosted-admin' // eslint-disable-next-line import/no-anonymous-default-export export default (req: NextApiRequest, res: NextApiResponse) => apiWrapper(req, res, handler)