Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
56 changes: 37 additions & 19 deletions src/core/create-admin-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,37 +16,43 @@ const validEnv = {

describe('createAdminClient', () => {
it('creates client with valid env', () => {
const client = createAdminClient(validEnv)
const client = createAdminClient({ env: validEnv })
expect(client).toBeDefined()
})

it('throws EnvError when SUPABASE_URL is missing', () => {
expect(() =>
createAdminClient({
url: '',
publishableKeys: { default: 'sb_publishable_xyz' },
secretKeys: { default: 'sb_secret_xyz' },
jwks: null,
env: {
url: '',
publishableKeys: { default: 'sb_publishable_xyz' },
secretKeys: { default: 'sb_secret_xyz' },
jwks: null,
},
}),
).toThrow(EnvError)
})

it('throws EnvError when secret keys are empty', () => {
expect(() =>
createAdminClient({
url: 'https://test.supabase.co',
publishableKeys: { default: 'sb_publishable_xyz' },
secretKeys: {},
jwks: null,
env: {
url: 'https://test.supabase.co',
publishableKeys: { default: 'sb_publishable_xyz' },
secretKeys: {},
jwks: null,
},
}),
).toThrow(EnvError)

try {
createAdminClient({
url: 'https://test.supabase.co',
publishableKeys: { default: 'sb_publishable_xyz' },
secretKeys: {},
jwks: null,
env: {
url: 'https://test.supabase.co',
publishableKeys: { default: 'sb_publishable_xyz' },
secretKeys: {},
jwks: null,
},
})
} catch (e) {
expect(e).toBeInstanceOf(EnvError)
Expand All @@ -65,15 +71,17 @@ describe('createAdminClient', () => {
},
jwks: null,
}
const client = createAdminClient(env, 'web')
const client = createAdminClient({ auth: { keyName: 'web' }, env })
expect(client).toBeDefined()
})

it('throws when named key does not exist', () => {
expect(() => createAdminClient(validEnv, 'nonexistent')).toThrow(EnvError)
expect(() =>
createAdminClient({ auth: { keyName: 'nonexistent' }, env: validEnv }),
).toThrow(EnvError)

try {
createAdminClient(validEnv, 'nonexistent')
createAdminClient({ auth: { keyName: 'nonexistent' }, env: validEnv })
} catch (e) {
expect(e).toBeInstanceOf(EnvError)
expect((e as EnvError).code).toBe(MissingSecretKeyError)
Expand All @@ -90,7 +98,7 @@ describe('createAdminClient', () => {
},
jwks: null,
}
const client = createAdminClient(env, null)
const client = createAdminClient({ auth: { keyName: null }, env })
expect(client).toBeDefined()
})

Expand All @@ -104,7 +112,7 @@ describe('createAdminClient', () => {
},
jwks: null,
}
const client = createAdminClient(env, null)
const client = createAdminClient({ auth: { keyName: null }, env })
expect(client).toBeDefined()
})

Expand All @@ -115,6 +123,16 @@ describe('createAdminClient', () => {
secretKeys: {},
jwks: null,
}
expect(() => createAdminClient(env, null)).toThrow(EnvError)
expect(() => createAdminClient({ auth: { keyName: null }, env })).toThrow(
EnvError,
)
})

it('creates admin client with custom supabaseOptions', () => {
const client = createAdminClient({
env: validEnv,
supabaseOptions: { db: { schema: 'api' } },
})
expect(client).toBeDefined()
Comment thread
tomaspozo marked this conversation as resolved.
})
})
33 changes: 23 additions & 10 deletions src/core/create-admin-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,18 +5,15 @@ import {
MissingDefaultSecretKeyError,
MissingSecretKeyError,
} from '../errors.js'
import type { SupabaseEnv } from '../types.js'
import type { CreateAdminClientOptions } from '../types.js'
import { resolveEnv } from './resolve-env.js'

/**
* Creates an admin Supabase client that bypasses Row-Level Security.
*
* Uses a secret key for authentication, giving full access to all data.
* Session persistence is disabled (stateless, one client per request).
* Stateless — one client per request.
*
* @param env - Optional environment overrides (passed through to {@link resolveEnv}).
* @param keyName - Name of the secret key to use. Falls back to `"default"`, then first available.
* @returns A configured {@link SupabaseClient} with admin (service-role) privileges.
* @throws {@link EnvError} If `SUPABASE_URL` is missing or the specified secret key is not found.
*
* @example
Expand All @@ -26,12 +23,14 @@ import { resolveEnv } from './resolve-env.js'
* ```
*/
export function createAdminClient<Database = unknown>(
env?: Partial<SupabaseEnv>,
keyName?: string | null,
options?: CreateAdminClientOptions,
): SupabaseClient<Database> {
const { data: resolved, error } = resolveEnv(env)
const { data: resolved, error } = resolveEnv(options?.env)
if (error) throw error

const keyName = options?.auth?.keyName
const supabaseOptions = options?.supabaseOptions

const name = keyName ?? 'default'
const keys = resolved.secretKeys
const secretKey =
Expand All @@ -42,11 +41,25 @@ export function createAdminClient<Database = unknown>(
: Errors[MissingSecretKeyError](name)
}

return createClient(resolved.url, secretKey, {
// Sanitize auth headers — only the service-role key controls Authorization and apikey.
const safeHeaders = { ...supabaseOptions?.global?.headers }
delete safeHeaders.Authorization
delete safeHeaders.apikey

// supabaseOptions uses `string` for schema; createClient<Database> expects a narrower type.
return createClient<Database>(resolved.url, secretKey, {
...supabaseOptions,
// Stripped — token injection is managed via the service-role key.
accessToken: undefined,
global: {
...supabaseOptions?.global,
Comment thread
tomaspozo marked this conversation as resolved.
headers: safeHeaders,
},
auth: {
...supabaseOptions?.auth,
persistSession: false,
autoRefreshToken: false,
detectSessionInUrl: false,
},
})
} as Parameters<typeof createClient<Database>>[2])

@kallebysantos kallebysantos Mar 26, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe this ensures TS sync with database main type with the scoped selected schema 🤔

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Update: It doesn't sync types

}
93 changes: 71 additions & 22 deletions src/core/create-context-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,37 +16,49 @@ const validEnv = {

describe('createContextClient', () => {
it('creates client with valid env', () => {
const client = createContextClient('test-token', validEnv)
const client = createContextClient({
auth: { token: 'test-token' },
env: validEnv,
})
expect(client).toBeDefined()
})

it('throws EnvError when SUPABASE_URL is missing', () => {
expect(() =>
createContextClient('test-token', {
url: '',
publishableKeys: { default: 'sb_publishable_xyz' },
secretKeys: { default: 'sb_secret_xyz' },
jwks: null,
createContextClient({
auth: { token: 'test-token' },
env: {
url: '',
publishableKeys: { default: 'sb_publishable_xyz' },
secretKeys: { default: 'sb_secret_xyz' },
jwks: null,
},
}),
).toThrow(EnvError)
})

it('throws EnvError when publishable keys are empty', () => {
expect(() =>
createContextClient('test-token', {
url: 'https://test.supabase.co',
publishableKeys: {},
secretKeys: { default: 'sb_secret_xyz' },
jwks: null,
createContextClient({
auth: { token: 'test-token' },
env: {
url: 'https://test.supabase.co',
publishableKeys: {},
secretKeys: { default: 'sb_secret_xyz' },
jwks: null,
},
}),
).toThrow(EnvError)

try {
createContextClient('test-token', {
url: 'https://test.supabase.co',
publishableKeys: {},
secretKeys: { default: 'sb_secret_xyz' },
jwks: null,
createContextClient({
auth: { token: 'test-token' },
env: {
url: 'https://test.supabase.co',
publishableKeys: {},
secretKeys: { default: 'sb_secret_xyz' },
jwks: null,
},
})
} catch (e) {
expect(e).toBeInstanceOf(EnvError)
Expand All @@ -65,17 +77,26 @@ describe('createContextClient', () => {
secretKeys: { default: 'sb_secret_xyz' },
jwks: null,
}
const client = createContextClient('test-token', env, 'web')
const client = createContextClient({
auth: { token: 'test-token', keyName: 'web' },
env,
})
expect(client).toBeDefined()
})

it('throws when named key does not exist', () => {
expect(() =>
createContextClient('test-token', validEnv, 'nonexistent'),
createContextClient({
auth: { token: 'test-token', keyName: 'nonexistent' },
env: validEnv,
}),
).toThrow(EnvError)

try {
createContextClient('test-token', validEnv, 'nonexistent')
createContextClient({
auth: { token: 'test-token', keyName: 'nonexistent' },
env: validEnv,
})
} catch (e) {
expect(e).toBeInstanceOf(EnvError)
expect((e as EnvError).code).toBe(MissingPublishableKeyError)
Expand All @@ -92,7 +113,10 @@ describe('createContextClient', () => {
secretKeys: { default: 'sb_secret_xyz' },
jwks: null,
}
const client = createContextClient('test-token', env, null)
const client = createContextClient({
auth: { token: 'test-token', keyName: null },
env,
})
expect(client).toBeDefined()
})

Expand All @@ -106,7 +130,10 @@ describe('createContextClient', () => {
secretKeys: { default: 'sb_secret_xyz' },
jwks: null,
}
const client = createContextClient('test-token', env, null)
const client = createContextClient({
auth: { token: 'test-token', keyName: null },
env,
})
expect(client).toBeDefined()
})

Expand All @@ -117,6 +144,28 @@ describe('createContextClient', () => {
secretKeys: { default: 'sb_secret_xyz' },
jwks: null,
}
expect(() => createContextClient('test-token', env, null)).toThrow(EnvError)
expect(() =>
createContextClient({
auth: { token: 'test-token', keyName: null },
env,
}),
).toThrow(EnvError)
})

it('creates client with custom supabaseOptions', () => {
const client = createContextClient({
auth: { token: 'test-token' },
env: validEnv,
supabaseOptions: { db: { schema: 'api' } },
})
expect(client).toBeDefined()
})

it('creates client with supabaseOptions without token', () => {
const client = createContextClient({
env: validEnv,
supabaseOptions: { db: { schema: 'api' } },
})
expect(client).toBeDefined()
})
Comment thread
tomaspozo marked this conversation as resolved.
})
Loading
Loading