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
68 changes: 34 additions & 34 deletions README.md
Comment thread
tomaspozo marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,12 @@ Server-side utilities for Supabase. Handles auth, client creation, and context i
```ts
import { withSupabase } from '@supabase/server'

Deno.serve(
withSupabase({ allow: 'user' }, async (req, ctx) => {
export default {
fetch: withSupabase({ allow: 'user' }, async (_req, ctx) => {
const { data } = await ctx.supabase.from('todos').select()
return Response.json(data)
}),
)
}
```

One import. One line of config. Auth is validated, clients are scoped, CORS is handled. Your handler only runs on successful auth.
Expand All @@ -34,10 +34,8 @@ pnpm add @supabase/server
### Authenticated endpoint

```ts
import { withSupabase } from '@supabase/server'

Deno.serve(
withSupabase({ allow: 'user' }, async (req, ctx) => {
export default {
fetch: withSupabase({ allow: 'user' }, async (_req, ctx) => {
// ctx.supabase — RLS-scoped to the authenticated user
// ctx.supabaseAdmin — bypasses RLS (service role)
// ctx.userClaims — user identity from JWT (id, email, role)
Expand All @@ -47,43 +45,43 @@ Deno.serve(
const { data } = await ctx.supabase.from('todos').select()
return Response.json(data)
}),
)
}
```

### Public endpoint (no auth)

```ts
Deno.serve(
withSupabase({ allow: 'always' }, async (req, ctx) => {
export default {
fetch: withSupabase({ allow: 'always' }, async (_req, _ctx) => {
return Response.json({ status: 'ok' })
}),
)
}
```

### API key protected

```ts
Deno.serve(
withSupabase({ allow: 'secret' }, async (req, ctx) => {
export default {
fetch: withSupabase({ allow: 'secret' }, async (_req, ctx) => {
const { data } = await ctx.supabaseAdmin.from('config').select()
return Response.json(data)
}),
)
}
```

### Dual auth (user or service)

```ts
Deno.serve(
withSupabase({ allow: ['user', 'secret'] }, async (req, ctx) => {
export default {
fetch: withSupabase({ allow: ['user', 'secret'] }, async (req, ctx) => {
const userId = ctx.userClaims?.id ?? (await req.json()).user_id
const { data } = await ctx.supabaseAdmin
.from('reports')
.select()
.eq('user_id', userId)
return Response.json(data)
}),
)
}
```

## Auth Modes
Expand Down Expand Up @@ -165,7 +163,7 @@ app.get('/todos', withSupabase({ allow: 'user' }), async (c) => {

app.get('/health', (c) => c.json({ status: 'ok' }))

Deno.serve(app.fetch)
export default { fetch: app.fetch }
```

The adapter does not handle CORS — use `hono/cors` for that. Per-route auth works naturally by applying the middleware to specific routes.
Expand Down Expand Up @@ -237,25 +235,27 @@ const { data: env, error } = resolveEnv({
```ts
import { verifyAuth, createContextClient } from '@supabase/server/core'

Deno.serve(async (req) => {
const url = new URL(req.url)
export default {
fetch: async (req) => {
const url = new URL(req.url)

if (url.pathname === '/health') {
return Response.json({ status: 'ok' })
}
if (url.pathname === '/health') {
return Response.json({ status: 'ok' })
}

if (url.pathname === '/todos') {
const { data: auth, error } = await verifyAuth(req, { allow: 'user' })
if (error)
return Response.json({ error: error.message }, { status: error.status })
if (url.pathname === '/todos') {
const { data: auth, error } = await verifyAuth(req, { allow: 'user' })
if (error)
return Response.json({ error: error.message }, { status: error.status })

const supabase = createContextClient(auth.token)
const { data } = await supabase.from('todos').select()
return Response.json(data)
}
const supabase = createContextClient(auth.token)
const { data } = await supabase.from('todos').select()
return Response.json(data)
}

return new Response('Not found', { status: 404 })
})
return new Response('Not found', { status: 404 })
},
}
```

## Environment Variables
Expand All @@ -267,7 +267,7 @@ Automatically available in Supabase Edge Functions:
| `SUPABASE_URL` | `https://<ref>.supabase.co` | Your project URL |
| `SUPABASE_PUBLISHABLE_KEYS` | `{"default":"sb_publishable_...","web":"sb_publishable_..."}` | Publishable API keys (named) |
| `SUPABASE_SECRET_KEYS` | `{"default":"sb_secret_...","web":"sb_secret_..."}` | Secret API keys (named) |
| `SUPABASE_JWKS` | `{"keys":[...]}` | JSON Web Key Set for JWT verification |
| `SUPABASE_JWKS` | `{"keys":[...]}` or `[...]` | JSON Web Key Set for JWT verification |

Also supported (for local dev, self-hosted, or other runtimes):

Expand Down
66 changes: 65 additions & 1 deletion src/core/create-admin-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ describe('createAdminClient', () => {
).toThrow(EnvError)
})

it('throws EnvError when default secret key is missing', () => {
it('throws EnvError when secret keys are empty', () => {
expect(() =>
createAdminClient({
url: 'https://test.supabase.co',
Expand All @@ -49,4 +49,68 @@ describe('createAdminClient', () => {
expect((e as EnvError).code).toBe('MISSING_SECRET_KEY')
}
})

it('uses the named key when keyName is provided', () => {
const env = {
url: 'https://test.supabase.co',
publishableKeys: { default: 'sb_publishable_xyz' },
secretKeys: {
default: 'sb_secret_default',
web: 'sb_secret_web',
mobile: 'sb_secret_mobile',
},
jwks: null,
}
const client = createAdminClient(env, 'web')
expect(client).toBeDefined()
})

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

try {
createAdminClient(validEnv, 'nonexistent')
} catch (e) {
expect(e).toBeInstanceOf(EnvError)
expect((e as EnvError).code).toBe('MISSING_SECRET_KEY')
}
})

it('falls back to default key when keyName is null', () => {
const env = {
url: 'https://test.supabase.co',
publishableKeys: { default: 'sb_publishable_xyz' },
secretKeys: {
default: 'sb_secret_default',
web: 'sb_secret_web',
},
jwks: null,
}
const client = createAdminClient(env, null)
expect(client).toBeDefined()
})

it('falls back to first available key when no default exists and keyName is null', () => {
const env = {
url: 'https://test.supabase.co',
publishableKeys: { default: 'sb_publishable_xyz' },
secretKeys: {
web: 'sb_secret_web',
mobile: 'sb_secret_mobile',
},
jwks: null,
}
const client = createAdminClient(env, null)
expect(client).toBeDefined()
})

it('throws when keyName is null and secret keys are empty', () => {
const env = {
url: 'https://test.supabase.co',
publishableKeys: { default: 'sb_publishable_xyz' },
secretKeys: {},
jwks: null,
}
expect(() => createAdminClient(env, null)).toThrow(EnvError)
})
})
19 changes: 13 additions & 6 deletions src/core/create-admin-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,23 @@ import { EnvError } from '../errors.js'
import type { SupabaseEnv } from '../types.js'
import { resolveEnv } from './resolve-env.js'

export function createAdminClient(env?: Partial<SupabaseEnv>): SupabaseClient {
export function createAdminClient(
env?: Partial<SupabaseEnv>,
keyName?: string | null,
): SupabaseClient {
const { data: resolved, error } = resolveEnv(env)
if (error) throw error

const secretKey = resolved.secretKeys['default']
const name = keyName ?? 'default'
const keys = resolved.secretKeys
const secretKey =
keys[name] ?? (keyName == null ? Object.values(keys)[0] : undefined)
if (!secretKey) {
throw new EnvError(
'No default secret key found. Set SUPABASE_SECRET_KEY or include a "default" entry in SUPABASE_SECRET_KEYS.',
'MISSING_SECRET_KEY',
)
const msg =
name === 'default'
? 'No default secret key found. Set SUPABASE_SECRET_KEY or include a "default" entry in SUPABASE_SECRET_KEYS.'
: `No "${name}" secret key found. Include a "${name}" entry in SUPABASE_SECRET_KEYS.`
throw new EnvError(msg, 'MISSING_SECRET_KEY')
}

return createClient(resolved.url, secretKey, {
Expand Down
68 changes: 67 additions & 1 deletion src/core/create-context-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ describe('createContextClient', () => {
).toThrow(EnvError)
})

it('throws EnvError when default publishable key is missing', () => {
it('throws EnvError when publishable keys are empty', () => {
expect(() =>
createContextClient('test-token', {
url: 'https://test.supabase.co',
Expand All @@ -49,4 +49,70 @@ describe('createContextClient', () => {
expect((e as EnvError).code).toBe('MISSING_PUBLISHABLE_KEY')
}
})

it('uses the named key when keyName is provided', () => {
const env = {
url: 'https://test.supabase.co',
publishableKeys: {
default: 'sb_publishable_default',
web: 'sb_publishable_web',
mobile: 'sb_publishable_mobile',
},
secretKeys: { default: 'sb_secret_xyz' },
jwks: null,
}
const client = createContextClient('test-token', env, 'web')
expect(client).toBeDefined()
})

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

try {
createContextClient('test-token', validEnv, 'nonexistent')
} catch (e) {
expect(e).toBeInstanceOf(EnvError)
expect((e as EnvError).code).toBe('MISSING_PUBLISHABLE_KEY')
}
})

it('falls back to default key when keyName is null', () => {
const env = {
url: 'https://test.supabase.co',
publishableKeys: {
default: 'sb_publishable_default',
web: 'sb_publishable_web',
},
secretKeys: { default: 'sb_secret_xyz' },
jwks: null,
}
const client = createContextClient('test-token', env, null)
expect(client).toBeDefined()
})

it('falls back to first available key when no default exists and keyName is null', () => {
const env = {
url: 'https://test.supabase.co',
publishableKeys: {
web: 'sb_publishable_web',
mobile: 'sb_publishable_mobile',
},
secretKeys: { default: 'sb_secret_xyz' },
jwks: null,
}
const client = createContextClient('test-token', env, null)
expect(client).toBeDefined()
})

it('throws when keyName is null and publishable keys are empty', () => {
const env = {
url: 'https://test.supabase.co',
publishableKeys: {},
secretKeys: { default: 'sb_secret_xyz' },
jwks: null,
}
expect(() => createContextClient('test-token', env, null)).toThrow(EnvError)
})
})
15 changes: 10 additions & 5 deletions src/core/create-context-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,21 @@ import { resolveEnv } from './resolve-env.js'
export function createContextClient(
token?: string | null,
env?: Partial<SupabaseEnv>,
keyName?: string | null,
): SupabaseClient {
const { data: resolved, error } = resolveEnv(env)
if (error) throw error

const anonKey = resolved.publishableKeys['default']
const name = keyName ?? 'default'
const keys = resolved.publishableKeys
const anonKey =
keys[name] ?? (keyName == null ? Object.values(keys)[0] : undefined)
if (!anonKey) {
throw new EnvError(
'No default publishable key found. Set SUPABASE_PUBLISHABLE_KEY or include a "default" entry in SUPABASE_PUBLISHABLE_KEYS.',
'MISSING_PUBLISHABLE_KEY',
)
const msg =
name === 'default'
? 'No default publishable key found. Set SUPABASE_PUBLISHABLE_KEY or include a "default" entry in SUPABASE_PUBLISHABLE_KEYS.'
: `No "${name}" publishable key found. Include a "${name}" entry in SUPABASE_PUBLISHABLE_KEYS.`
throw new EnvError(msg, 'MISSING_PUBLISHABLE_KEY')
}

return createClient(resolved.url, anonKey, {
Expand Down
Loading
Loading