refactor(studio): lazy self-hosted admin client + enforce in API routes (from #46424) (#47104)

Extracted from the TanStack Start migration (#46424) to shrink that PR.

The self-hosted storage/auth API routes each constructed a module-scope
admin client (`createClient(process.env.SUPABASE_URL!,
process.env.SUPABASE_SERVICE_KEY!)`). Those env vars only exist on
self-hosted, so eager module-scope construction is wasteful on platform
and fragile on any runtime that evaluates an API module before its route
is hit (constructing with `undefined` credentials throws on import).

**Changed:**
- Add `lib/api/self-hosted-admin.ts` — `selfHostedSupabaseAdmin`, a
`Proxy` that defers `createClient(...)` until first property access
(inside a handler, i.e. on self-hosted where the vars are set).
- Swap **all 17** storage/auth/vector-bucket handlers from module-scope
`createClient(...)` to `import { selfHostedSupabaseAdmin as supabase }`.
- **Enforce it:** add an eslint `no-restricted-syntax` rule banning
module-scope `createClient` in `pages/api/**` + `routes/**` (now that
every flagged handler is lazy). The same eslint config block also
carries an analytics-SQL boundary rule — 0 violations on master.

Behaviour is unchanged (the client is still built lazily inside the
handler). This is also the change that makes those routes safe under
TanStack's single-handler module evaluation.

## To test
- Self-hosted Studio: storage buckets/objects, vector buckets, and auth
users operations work as before.

## Verification
studio lint (0 errors, both rules active) ✓ · studio typecheck ✓.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **Refactor**
* Standardized self-hosted Supabase admin client usage across platform
authentication and storage endpoints, removing per-route client setup.
* Improved reliability by lazily creating the admin client only when
first used.
* **Chores / Tooling**
* Updated ESLint rules to prevent module-scope Supabase client creation
in API routes and to enforce safe analytics SQL access patterns.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->

---------

Co-authored-by: Alaister Young <10985857+alaister@users.noreply.github.com>
Co-authored-by: Ali Waseem <waseema393@gmail.com>
This commit is contained in:
authored and GitHub committed 2026-06-22 13:37:15 +00:00
1 parent 2fdb59a905
commit e81c714aae
19 files changed
+82 -63

No files matched your search

+43 -12
View File
@@ -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,
],
},
},
+22
View File
@@ -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)
},
})
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)
@@ -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)