From 5db9d3f5bdc232602c2ac7335492fb3466782ee7 Mon Sep 17 00:00:00 2001 From: Katerina Skroumpelou Date: Tue, 25 Aug 2026 15:16:29 +0300 Subject: [PATCH] fix: construct supabaseAdmin lazily on first access --- docs/auth-modes.md | 2 +- docs/environment-variables.md | 12 ++-- src/core/lazy-client.test.ts | 79 +++++++++++++++++++++++ src/core/lazy-client.ts | 35 ++++++++++ src/middleware/admin-client/index.test.ts | 64 ++++++++++++++++-- src/middleware/admin-client/index.ts | 33 ++++++---- src/with-supabase.test.ts | 37 ++++++++++- src/with-supabase.ts | 15 +++-- 8 files changed, 246 insertions(+), 31 deletions(-) create mode 100644 src/core/lazy-client.test.ts create mode 100644 src/core/lazy-client.ts diff --git a/docs/auth-modes.md b/docs/auth-modes.md index f00e5ab..b7e08f9 100644 --- a/docs/auth-modes.md +++ b/docs/auth-modes.md @@ -209,5 +209,5 @@ withSupabase({ auth: ['user', 'publishable:web'] }, async (_req, ctx) => { 1. `extractCredentials(request)` reads `Authorization: Bearer ` and `apikey` from headers 2. Each mode in `auth` is tried in order against the extracted credentials 3. First match wins — returns an `AuthResult` with `authMode`, `token`, `userClaims`, `jwtClaims`, and `keyName`. A mode falls through to the next only when its credential is absent; a credential that is present but invalid terminates the chain with `InvalidCredentialsError`. -4. The auth result is used to create scoped clients (`supabase` with the user's token, `supabaseAdmin` with the secret key) +4. The auth result is used to create scoped clients (`supabase` with the user's token, `supabaseAdmin` with the secret key — constructed on its first property access) 5. Everything is bundled into a `SupabaseContext` and passed to your handler diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 2a73af8..ffcd6d5 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -15,12 +15,12 @@ On Supabase Platform and Local Development (CLI), all variables are auto-provisi Set these based on which auth modes your app uses: -| Variable | Required when | -| -------------------------------------- | ----------------------------------------- | -| `SUPABASE_URL` | Always | -| `SUPABASE_SECRET_KEY` | `auth: 'secret'` or using `supabaseAdmin` | -| `SUPABASE_PUBLISHABLE_KEY` | `auth: 'publishable'` | -| `SUPABASE_JWKS` or `SUPABASE_JWKS_URL` | `auth: 'user'` (JWT verification) | +| Variable | Required when | +| -------------------------------------- | -------------------------------------------------------------- | +| `SUPABASE_URL` | Always | +| `SUPABASE_SECRET_KEY` | `auth: 'secret'`, or when the handler accesses `supabaseAdmin` | +| `SUPABASE_PUBLISHABLE_KEY` | `auth: 'publishable'` | +| `SUPABASE_JWKS` or `SUPABASE_JWKS_URL` | `auth: 'user'` (JWT verification) | ### Minimal `.env` example diff --git a/src/core/lazy-client.test.ts b/src/core/lazy-client.test.ts new file mode 100644 index 0000000..4f867dc --- /dev/null +++ b/src/core/lazy-client.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest' + +import { lazyClient } from './lazy-client.js' + +class Thing { + #secret = 42 + label = 'thing' + + reveal(): number { + return this.#secret + } + + get computed(): number { + return this.#secret + 1 + } +} + +function counted(): { proxy: Thing; calls: () => number; last: () => Thing } { + let calls = 0 + let instance: Thing | undefined + const proxy = lazyClient(() => { + calls++ + instance = new Thing() + return instance + }) + return { proxy, calls: () => calls, last: () => instance! } +} + +describe('lazyClient', () => { + it('defers construction until the first property access', () => { + const { proxy, calls } = counted() + expect(calls()).toBe(0) + expect(proxy.label).toBe('thing') + expect(calls()).toBe(1) + }) + + it('constructs at most once across traps', () => { + const { proxy, calls } = counted() + expect(proxy.label).toBe('thing') + expect('reveal' in proxy).toBe(true) + expect(Object.keys(proxy)).toContain('label') + expect(proxy instanceof Thing).toBe(true) + expect(calls()).toBe(1) + }) + + it('binds methods so private fields resolve against the instance', () => { + const { proxy } = counted() + const reveal = proxy.reveal + expect(reveal()).toBe(42) + }) + + it('runs prototype getters against the instance', () => { + const { proxy } = counted() + expect(proxy.computed).toBe(43) + }) + + it('surfaces a factory throw at the access point and retries', () => { + let calls = 0 + const proxy = lazyClient(() => { + calls++ + throw new Error(`boom ${calls}`) + }) + expect(() => proxy.label).toThrow('boom 1') + expect(() => proxy.label).toThrow('boom 2') + expect(calls).toBe(2) + }) + + it('forwards writes to the underlying instance', () => { + const { proxy, last } = counted() + proxy.label = 'renamed' + expect(last().label).toBe('renamed') + expect(proxy.label).toBe('renamed') + }) + + it('spreads the instance own keys', () => { + const { proxy } = counted() + expect({ ...proxy }).toEqual({ label: 'thing' }) + }) +}) diff --git a/src/core/lazy-client.ts b/src/core/lazy-client.ts new file mode 100644 index 0000000..2ac7c86 --- /dev/null +++ b/src/core/lazy-client.ts @@ -0,0 +1,35 @@ +/** + * Wraps a client factory in a Proxy that defers construction to the first + * interaction with the client — a property read or write, an `in` check, + * `instanceof`, or key enumeration. Construction happens at most once: every + * trap delegates to the same memoized instance. A factory throw surfaces at + * the interaction that triggered it, and the factory runs again on the next + * interaction. + * + * Function-valued properties are bound to the real instance, so class + * internals (private fields, prototype getters) always see the instance + * itself rather than the proxy. + * + * Inspecting an unconstructed proxy (`console.log`, test diff printers) + * enumerates its keys and therefore triggers construction — in a + * misconfigured environment, that inspection throws. + */ +export function lazyClient(build: () => T): T { + let client: T | undefined + const instance = () => (client ??= build()) + return new Proxy({} as T, { + get(_target, prop) { + const c = instance() + const value = Reflect.get(c, prop) as unknown + return typeof value === 'function' + ? (value as (...args: never[]) => unknown).bind(c) + : value + }, + set: (_target, prop, value) => Reflect.set(instance(), prop, value), + has: (_target, prop) => Reflect.has(instance(), prop), + getPrototypeOf: () => Reflect.getPrototypeOf(instance()), + ownKeys: () => Reflect.ownKeys(instance()), + getOwnPropertyDescriptor: (_target, prop) => + Reflect.getOwnPropertyDescriptor(instance(), prop), + }) +} diff --git a/src/middleware/admin-client/index.test.ts b/src/middleware/admin-client/index.test.ts index f752bee..a7a83fc 100644 --- a/src/middleware/admin-client/index.test.ts +++ b/src/middleware/admin-client/index.test.ts @@ -1,7 +1,7 @@ import { pipeline } from '@supabase/middleware' import { describe, expect, it } from 'vitest' -import type { SupabaseClient } from '@supabase/supabase-js' +import { SupabaseClient } from '@supabase/supabase-js' import { EnvError, @@ -35,8 +35,12 @@ describe('withSupabaseAdminClient', () => { }) it("selects the matched secret key from an upstream withSupabase context's authKeyName", async () => { - const handler = pipeline([withSupabaseAdminClient({ env: baseEnv })], () => - Promise.resolve(Response.json({ ok: true })), + const handler = pipeline( + [withSupabaseAdminClient({ env: baseEnv })], + async (_req, ctx) => { + ctx.supabaseAdmin.from('t') + return Response.json({ ok: true }) + }, ) // authKeyName 'internal' is not in the key set — the throw proves the @@ -50,12 +54,25 @@ describe('withSupabaseAdminClient', () => { ).rejects.toMatchObject({ code: MissingSecretKeyError }) }) - it('throws EnvError when no secret key exists', async () => { + it('succeeds without a secret key when the handler never accesses supabaseAdmin', async () => { const handler = pipeline( [withSupabaseAdminClient({ env: { ...baseEnv, secretKeys: {} } })], async () => Response.json({ ok: true }), ) + const res = await handler(new Request('http://localhost')) + expect(res.status).toBe(200) + }) + + it('throws EnvError at the first supabaseAdmin access when no secret key exists', async () => { + const handler = pipeline( + [withSupabaseAdminClient({ env: { ...baseEnv, secretKeys: {} } })], + async (_req, ctx) => { + ctx.supabaseAdmin.from('t') + return Response.json({ ok: true }) + }, + ) + await expect( handler(new Request('http://localhost')), ).rejects.toMatchObject({ code: MissingDefaultSecretKeyError }) @@ -63,4 +80,43 @@ describe('withSupabaseAdminClient', () => { handler(new Request('http://localhost')), ).rejects.toBeInstanceOf(EnvError) }) + + it('constructs the client once per request across accesses', async () => { + let first: unknown + let second: unknown + const handler = pipeline( + [withSupabaseAdminClient({ env: baseEnv })], + async (_req, ctx) => { + // `auth` is a constructor-assigned data property — identity across + // accesses proves a single underlying client. (`functions` is a + // getter that mints a new client per read, so it can't prove this.) + first = ctx.supabaseAdmin.auth + second = ctx.supabaseAdmin.auth + return Response.json({ ok: true }) + }, + ) + + const res = await handler(new Request('http://localhost')) + expect(res.status).toBe(200) + expect(first).toBeDefined() + expect(first).toBe(second) + }) + + it('exposes a working SupabaseClient through the proxy', async () => { + const handler = pipeline( + [withSupabaseAdminClient({ env: baseEnv })], + async (_req, ctx) => { + return Response.json({ + isClient: ctx.supabaseAdmin instanceof SupabaseClient, + hasSelect: typeof ctx.supabaseAdmin.from('t').select === 'function', + }) + }, + ) + + const res = await handler(new Request('http://localhost')) + expect(res.status).toBe(200) + const body = await res.json() + expect(body.isClient).toBe(true) + expect(body.hasSelect).toBe(true) + }) }) diff --git a/src/middleware/admin-client/index.ts b/src/middleware/admin-client/index.ts index 9d2bab6..0dc834b 100644 --- a/src/middleware/admin-client/index.ts +++ b/src/middleware/admin-client/index.ts @@ -3,6 +3,7 @@ import type { Entry } from '@supabase/middleware' import type { SupabaseClient } from '@supabase/supabase-js' import { createAdminClient } from '../../core/create-admin-client.js' +import { lazyClient } from '../../core/lazy-client.js' import { readUpstreamAuth } from '../../core/read-upstream-auth.js' import { CreateSupabaseClientError, EnvError, Errors } from '../../errors.js' import type { CreateAdminClientOptions } from '../../types.js' @@ -33,16 +34,20 @@ const base = defineMiddleware< const keyName = upstream.authMode === 'secret' ? upstream.authKeyName : undefined - let supabaseAdmin: SupabaseClient - try { - supabaseAdmin = createAdminClient({ - auth: { keyName }, - env: config?.env, - supabaseOptions: config?.supabaseOptions, - }) - } catch (e) { - throw e instanceof EnvError ? e : Errors[CreateSupabaseClientError]() - } + // Constructed on the first property access and memoized for the request — + // a handler that never touches ctx.supabaseAdmin never resolves the + // secret key. + const supabaseAdmin = lazyClient(() => { + try { + return createAdminClient({ + auth: { keyName }, + env: config?.env, + supabaseOptions: config?.supabaseOptions, + }) + } catch (e) { + throw e instanceof EnvError ? e : Errors[CreateSupabaseClientError]() + } + }) return { supabaseAdmin } }, }) @@ -52,9 +57,13 @@ const base = defineMiddleware< * Row-Level Security, authenticated with a secret key. This is the same * middleware `withSupabase` composes internally to build its context. * + * The client is constructed on the first property access of + * `ctx.supabaseAdmin` and memoized for the request. A handler that never + * accesses it requires no secret key. + * * @throws {@link index.EnvError} When `SUPABASE_URL` or the secret key is - * missing — composing wrappers (like `withSupabase`) map this to a 500 - * response; standalone pipelines see it as a thrown error. + * missing — thrown at the first `ctx.supabaseAdmin` property access, inside + * the handler or downstream middleware that performs it. * * @example Standalone pipeline * ```ts diff --git a/src/with-supabase.test.ts b/src/with-supabase.test.ts index 1445adf..ba976ca 100644 --- a/src/with-supabase.test.ts +++ b/src/with-supabase.test.ts @@ -3,7 +3,7 @@ import { defineMiddleware, getEnv } from '@supabase/middleware' import type { FetchHandler } from '@supabase/middleware' import { _resetAllowDeprecationWarned } from './core/utils/deprecation.js' -import { EnvError } from './errors.js' +import { EnvError, MissingDefaultSecretKeyError } from './errors.js' import { withOAuthProtectedResource } from './oauth-protected-resource/with-oauth-protected-resource.js' import { withSupabase } from './with-supabase.js' @@ -413,6 +413,41 @@ describe('withSupabase', () => { 'handler-level env failure', ) }) + + it('serves requests without a secret key when the handler never accesses supabaseAdmin', async () => { + let ran = false + const handler = withSupabase( + { + auth: 'none', + env: { ...baseEnv, secretKeys: {} }, + }, + async () => { + ran = true + return Response.json({ ok: true }) + }, + ) + + const res = await handler(new Request('http://localhost')) + expect(res.status).toBe(200) + expect(ran).toBe(true) + }) + + it('propagates the missing-secret-key EnvError at the supabaseAdmin access point', async () => { + const handler = withSupabase( + { + auth: 'none', + env: { ...baseEnv, secretKeys: {} }, + }, + async (_req, ctx) => { + ctx.supabaseAdmin.from('t') + return Response.json({ ok: true }) + }, + ) + + await expect( + handler(new Request('http://localhost')), + ).rejects.toMatchObject({ code: MissingDefaultSecretKeyError }) + }) }) describe('allow → auth deprecation', () => { diff --git a/src/with-supabase.ts b/src/with-supabase.ts index 45b159f..8272526 100644 --- a/src/with-supabase.ts +++ b/src/with-supabase.ts @@ -187,10 +187,11 @@ export function withSupabase( ) } - // Track whether the request has moved past client construction: only - // failures from the two client entries map to the historical JSON error - // responses — user middleware and handler throws propagate unchanged, - // exactly as before the rewrite. + // Track whether the request has moved past the client entries: only + // client-phase construction failures (the `supabase` entry) map to JSON + // error responses. User middleware and handler throws propagate + // unchanged — including the EnvError a lazily constructed + // `supabaseAdmin` throws at its first property access. let inClientPhase = true const markUserPhase: AnyHandler = (r, ctx) => { inClientPhase = false @@ -223,10 +224,10 @@ export function withSupabase( ...upstreamAuth, }) } catch (e) { - // Client construction failures keep their historical response shape: + // Client-phase construction failures map to JSON error responses: // EnvError (missing URL / keys) and the client middleware's - // CreateSupabaseClientError map to the same JSON errors - // createSupabaseContext produced. + // CreateSupabaseClientError take the same shape as the JSON errors + // createSupabaseContext produces. const mapped = !inClientPhase ? null : e instanceof EnvError