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
10 changes: 6 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -236,22 +236,24 @@ interface SupabaseContext {
withSupabase(
{
auth: 'user', // who can call this function
cors: false, // disable CORS (default: supabase-js CORS headers)
cors: 'disabled', // disable CORS (default: supabase-js CORS headers)
env: { url: '...' }, // env overrides (optional)
},
handler,
)
```

`cors` defaults to the standard [supabase-js CORS headers](https://supabase.com/docs/guides/functions/cors). Pass a `Record<string, string>` to set custom headers, or `false` to disable CORS handling (e.g. when using a framework that handles CORS separately).
`cors` accepts `'default'` (the standard [supabase-js CORS headers](https://supabase.com/docs/guides/functions/cors), also the default), `'disabled'` to disable CORS handling (e.g. when using a framework that handles CORS separately), or `{ headers }` to set custom headers. The boolean (`true`/`false`) and bare `Record<string, string>` forms are deprecated but still accepted.

```ts
withSupabase(
{
auth: 'user',
cors: {
'Access-Control-Allow-Origin': 'https://myapp.com',
'Access-Control-Allow-Headers': 'authorization, content-type',
headers: {
'Access-Control-Allow-Origin': 'https://myapp.com',
'Access-Control-Allow-Headers': 'authorization, content-type',
},
},
},
handler,
Expand Down
12 changes: 9 additions & 3 deletions docs/getting-started.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,17 +126,23 @@ withSupabase(
{
auth: 'user',
cors: {
'Access-Control-Allow-Origin': 'https://myapp.com',
'Access-Control-Allow-Headers': 'authorization, content-type',
headers: {
'Access-Control-Allow-Origin': 'https://myapp.com',
'Access-Control-Allow-Headers': 'authorization, content-type',
},
},
},
handler,
)

// Disable CORS (e.g., when a framework handles it)
withSupabase({ auth: 'user', cors: false }, handler)
withSupabase({ auth: 'user', cors: 'disabled' }, handler)
```

`cors` accepts `'default'` (standard supabase-js headers), `'disabled'`, or
`{ headers }` for custom headers. The boolean (`true`/`false`) and bare
`Record<string, string>` forms are deprecated but still accepted.

## Runtimes

`withSupabase` and `createSupabaseContext` work with any runtime that supports the Web API `Request`/`Response` standard. The [core primitives](core-primitives.md) go further — they work in any environment where you can extract headers, regardless of the request/response model (Express, Fastify, etc.).
Expand Down
65 changes: 58 additions & 7 deletions src/cors.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
import { describe, expect, it } from 'vitest'

import { addCorsHeaders, buildCorsHeaders } from './cors.js'
import { addCorsHeaders, buildCorsHeaders, isCorsDisabled } from './cors.js'

describe('buildCorsHeaders', () => {
it('returns supabase-js defaults when config is true', () => {
const headers = buildCorsHeaders(true)
it("returns supabase-js defaults when config is 'default'", () => {
const headers = buildCorsHeaders('default')
expect(headers['Access-Control-Allow-Origin']).toBe('*')
expect(headers['Access-Control-Allow-Methods']).toContain('GET')
expect(headers['Access-Control-Allow-Headers']).toContain('authorization')
Expand All @@ -15,11 +15,31 @@ describe('buildCorsHeaders', () => {
expect(headers['Access-Control-Allow-Origin']).toBe('*')
})

it('returns empty object when config is false', () => {
it("returns empty object when config is 'disabled'", () => {
expect(buildCorsHeaders('disabled')).toEqual({})
})

it('returns the inner headers from the { headers } shape', () => {
const headers = {
'Access-Control-Allow-Origin': 'https://example.com',
'Access-Control-Allow-Headers': 'X-Custom',
}
expect(buildCorsHeaders({ headers })).toBe(headers)
})

// Deprecated forms — still supported for backward compatibility.
it('returns supabase-js defaults when config is true (deprecated)', () => {
const headers = buildCorsHeaders(true)
expect(headers['Access-Control-Allow-Origin']).toBe('*')
expect(headers['Access-Control-Allow-Methods']).toContain('GET')
expect(headers['Access-Control-Allow-Headers']).toContain('authorization')
})

it('returns empty object when config is false (deprecated)', () => {
expect(buildCorsHeaders(false)).toEqual({})
})

it('returns custom headers as-is', () => {
it('returns a bare custom headers record as-is (deprecated)', () => {
const custom = {
'Access-Control-Allow-Origin': 'https://example.com',
'Access-Control-Allow-Headers': 'X-Custom',
Expand All @@ -28,20 +48,51 @@ describe('buildCorsHeaders', () => {
})
})

describe('isCorsDisabled', () => {
it("is true for 'disabled' and the deprecated false", () => {
expect(isCorsDisabled('disabled')).toBe(true)
expect(isCorsDisabled(false)).toBe(true)
})

it('is false for enabled configurations', () => {
expect(isCorsDisabled('default')).toBe(false)
expect(isCorsDisabled(true)).toBe(false)
expect(isCorsDisabled()).toBe(false)
expect(isCorsDisabled({ headers: { 'X-Custom': '1' } })).toBe(false)
expect(isCorsDisabled({ 'Access-Control-Allow-Origin': '*' })).toBe(false)
})
})

describe('addCorsHeaders', () => {
it('adds default CORS headers to response', () => {
const response = new Response('ok')
const result = addCorsHeaders(response, true)
expect(result.headers.get('Access-Control-Allow-Origin')).toBe('*')
})

it('returns response unchanged when config is false', () => {
it("returns response unchanged when config is 'disabled'", () => {
const response = new Response('ok')
const result = addCorsHeaders(response, 'disabled')
expect(result.headers.get('Access-Control-Allow-Origin')).toBeNull()
})

it('returns response unchanged when config is false (deprecated)', () => {
const response = new Response('ok')
const result = addCorsHeaders(response, false)
expect(result.headers.get('Access-Control-Allow-Origin')).toBeNull()
})

it('adds custom headers to response', () => {
it('adds custom headers from the { headers } shape to response', () => {
const response = new Response('ok')
const result = addCorsHeaders(response, {
headers: { 'Access-Control-Allow-Origin': 'https://example.com' },
})
expect(result.headers.get('Access-Control-Allow-Origin')).toBe(
'https://example.com',
)
})

it('adds a bare custom headers record to response (deprecated)', () => {
const response = new Response('ok')
const result = addCorsHeaders(response, {
'Access-Control-Allow-Origin': 'https://example.com',
Expand Down
44 changes: 36 additions & 8 deletions src/cors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,35 @@ import { corsHeaders as defaultCorsHeaders } from '@supabase/supabase-js/cors'
/**
* CORS configuration for {@link withSupabase}.
*
* - `true` — uses `@supabase/supabase-js` default CORS headers.
* - `false` — disables CORS handling entirely.
* - `Record<string, string>` — custom CORS headers to use.
* - `'default'` — uses `@supabase/supabase-js` default CORS headers.
* - `'disabled'` — disables CORS handling entirely.
* - `{ headers }` — custom CORS headers to use.
*
* The boolean (`true`/`false`) and bare `Record<string, string>` forms are
* deprecated but still accepted for backward compatibility.
*
* @internal
*/
type CorsConfig =
| 'default'
| 'disabled'
| { headers: Record<string, string> }
/** @deprecated Use `'default'` | `'disabled'` | `{ headers }` instead. */
| boolean
/** @deprecated Use `{ headers }` instead. */
| Record<string, string>

/**
* Whether the given CORS configuration disables CORS handling.
*
* @param config - The CORS configuration.
* @returns `true` for `'disabled'` or the deprecated `false`, otherwise `false`.
*
* @internal
*/
type CorsConfig = boolean | Record<string, string>
export function isCorsDisabled(config?: CorsConfig): boolean {
return config === false || config === 'disabled'
}

/**
* Builds the CORS headers object based on the given configuration.
Expand All @@ -20,16 +42,22 @@ type CorsConfig = boolean | Record<string, string>
* @internal
*/
export function buildCorsHeaders(config?: CorsConfig): Record<string, string> {
if (config === false) return {}
if (typeof config === 'object') return config
if (isCorsDisabled(config)) return {}
if (typeof config === 'object') {
// New `{ headers }` shape vs the deprecated bare `Record<string, string>`.
if ('headers' in config && typeof config.headers === 'object') {
return config.headers
}
return config as Record<string, string>
}
return defaultCorsHeaders
}

/**
* Returns a new `Response` with CORS headers appended.
*
* Creates a clone of the original response and sets each CORS header on it.
* If CORS is disabled (`config === false`), returns the original response unchanged.
* If CORS is disabled (`'disabled'` or the deprecated `false`), returns the original response unchanged.
*
* @param response - The original response to augment.
* @param config - The CORS configuration.
Expand All @@ -41,7 +69,7 @@ export function addCorsHeaders(
response: Response,
config?: CorsConfig,
): Response {
if (config === false) return response
if (isCorsDisabled(config)) return response

const corsHeaders = buildCorsHeaders(config)
const newResponse = new Response(response.body, response)
Expand Down
25 changes: 18 additions & 7 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -239,7 +239,7 @@ export interface UserClaims {
* }
*
* // No auth required, CORS disabled
* const config: WithSupabaseConfig = { auth: 'none', cors: false }
* const config: WithSupabaseConfig = { auth: 'none', cors: 'disabled' }
* ```
*
* @category Types
Expand Down Expand Up @@ -270,16 +270,27 @@ export interface WithSupabaseConfig {
/**
* CORS configuration for the `withSupabase` wrapper.
*
* - `true` (default) — uses `@supabase/supabase-js` default CORS headers.
* - `false` — disables CORS handling entirely.
* - `Record<string, string>` — custom CORS headers.
* - `'default'` — uses `@supabase/supabase-js` default CORS headers.
* - `'disabled'` — disables CORS handling entirely.
* - `{ headers }` — custom CORS headers.
*
* The boolean (`true`/`false`) and bare `Record<string, string>` forms are
* deprecated but still accepted for backward compatibility.
*
* @remarks Only applies to the top-level {@link withSupabase} wrapper.
* The Hono adapter handles CORS separately via Hono's own middleware.
* The adapters (Hono, H3, Elysia, NestJS) handle CORS separately via each
* framework's own middleware.
*
* @defaultValue `true`
* @defaultValue `'default'`
*/
cors?: boolean | Record<string, string>
cors?:
| 'default'
| 'disabled'
| { headers: Record<string, string> }
/** @deprecated Use `'default'` | `'disabled'` | `{ headers }` instead. */
| boolean
/** @deprecated Use `{ headers }` instead. */
| Record<string, string>

/**
* Options forwarded to both internal `createClient()` calls.
Expand Down
60 changes: 60 additions & 0 deletions src/with-supabase.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,66 @@ describe('withSupabase', () => {
expect(res.headers.get('Access-Control-Allow-Origin')).toBeNull()
})

describe('explicit cors shape', () => {
it("skips OPTIONS handling when cors is 'disabled'", async () => {
const handler = withSupabase(
{ auth: 'none', env: baseEnv, cors: 'disabled' },
async () => Response.json({ ok: true }),
)

const req = new Request('http://localhost', { method: 'OPTIONS' })
const res = await handler(req)
// When CORS disabled, OPTIONS goes through normal flow
expect(res.status).toBe(200)
})

it("does not add CORS headers when cors is 'disabled'", async () => {
const handler = withSupabase(
{ auth: 'none', env: baseEnv, cors: 'disabled' },
async () => Response.json({ ok: true }),
)

const req = new Request('http://localhost')
const res = await handler(req)
expect(res.headers.get('Access-Control-Allow-Origin')).toBeNull()
})

it('applies custom { headers } to the success response', async () => {
const handler = withSupabase(
{
auth: 'none',
env: baseEnv,
cors: { headers: { 'Access-Control-Allow-Origin': 'https://a.com' } },
},
async () => Response.json({ ok: true }),
)

const req = new Request('http://localhost')
const res = await handler(req)
expect(res.headers.get('Access-Control-Allow-Origin')).toBe(
'https://a.com',
)
})

it('applies custom { headers } to the error response', async () => {
const handler = withSupabase(
{
auth: 'user',
env: baseEnv,
cors: { headers: { 'Access-Control-Allow-Origin': 'https://a.com' } },
},
async () => Response.json({ ok: true }),
)

const req = new Request('http://localhost')
const res = await handler(req)
expect(res.status).toBe(401)
expect(res.headers.get('Access-Control-Allow-Origin')).toBe(
'https://a.com',
)
})
})

describe('allow → auth deprecation', () => {
beforeEach(() => {
_resetAllowDeprecationWarned()
Expand Down
10 changes: 6 additions & 4 deletions src/with-supabase.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { addCorsHeaders, buildCorsHeaders } from './cors.js'
import { addCorsHeaders, buildCorsHeaders, isCorsDisabled } from './cors.js'
import { createSupabaseContext } from './create-supabase-context.js'
import type { SupabaseContext, WithSupabaseConfig } from './types.js'

Expand Down Expand Up @@ -32,7 +32,7 @@ export function withSupabase<Database = unknown>(
handler: (req: Request, ctx: SupabaseContext<Database>) => Promise<Response>,
): (req: Request) => Promise<Response> {
return async (req: Request) => {
if (config.cors !== false && req.method === 'OPTIONS') {
if (!isCorsDisabled(config.cors) && req.method === 'OPTIONS') {
return new Response(null, {
status: 204,
headers: buildCorsHeaders(config.cors),
Expand All @@ -48,14 +48,16 @@ export function withSupabase<Database = unknown>(
{ message: error.message, code: error.code },
{
status: error.status,
headers: config.cors !== false ? buildCorsHeaders(config.cors) : {},
headers: !isCorsDisabled(config.cors)
? buildCorsHeaders(config.cors)
: {},
},
)
}

const response = await handler(req, ctx)

if (config.cors !== false) {
if (!isCorsDisabled(config.cors)) {
return addCorsHeaders(response, config.cors)
}
return response
Expand Down
Loading