Skip to content

Commit 297925f

Browse files
mandariniclaude
andauthored
feat: explicit cors config shape ('default' | 'disabled' | { headers }) (#102)
* feat: explicit cors config shape ('default' | 'none' | { headers }) Keeps boolean/Record forms accepted but deprecated. * refactor: rename cors 'none' to 'disabled' Reads more clearly as an on/off switch and matches the SDK-1149 proposal. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 09a6750 commit 297925f

7 files changed

Lines changed: 193 additions & 33 deletions

File tree

README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -236,22 +236,24 @@ interface SupabaseContext {
236236
withSupabase(
237237
{
238238
auth: 'user', // who can call this function
239-
cors: false, // disable CORS (default: supabase-js CORS headers)
239+
cors: 'disabled', // disable CORS (default: supabase-js CORS headers)
240240
env: { url: '...' }, // env overrides (optional)
241241
},
242242
handler,
243243
)
244244
```
245245
246-
`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).
246+
`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.
247247
248248
```ts
249249
withSupabase(
250250
{
251251
auth: 'user',
252252
cors: {
253-
'Access-Control-Allow-Origin': 'https://myapp.com',
254-
'Access-Control-Allow-Headers': 'authorization, content-type',
253+
headers: {
254+
'Access-Control-Allow-Origin': 'https://myapp.com',
255+
'Access-Control-Allow-Headers': 'authorization, content-type',
256+
},
255257
},
256258
},
257259
handler,

docs/getting-started.md

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -126,17 +126,23 @@ withSupabase(
126126
{
127127
auth: 'user',
128128
cors: {
129-
'Access-Control-Allow-Origin': 'https://myapp.com',
130-
'Access-Control-Allow-Headers': 'authorization, content-type',
129+
headers: {
130+
'Access-Control-Allow-Origin': 'https://myapp.com',
131+
'Access-Control-Allow-Headers': 'authorization, content-type',
132+
},
131133
},
132134
},
133135
handler,
134136
)
135137
136138
// Disable CORS (e.g., when a framework handles it)
137-
withSupabase({ auth: 'user', cors: false }, handler)
139+
withSupabase({ auth: 'user', cors: 'disabled' }, handler)
138140
```
139141
142+
`cors` accepts `'default'` (standard supabase-js headers), `'disabled'`, or
143+
`{ headers }` for custom headers. The boolean (`true`/`false`) and bare
144+
`Record<string, string>` forms are deprecated but still accepted.
145+
140146
## Runtimes
141147
142148
`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.).

src/cors.test.ts

Lines changed: 58 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
import { describe, expect, it } from 'vitest'
22

3-
import { addCorsHeaders, buildCorsHeaders } from './cors.js'
3+
import { addCorsHeaders, buildCorsHeaders, isCorsDisabled } from './cors.js'
44

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

18-
it('returns empty object when config is false', () => {
18+
it("returns empty object when config is 'disabled'", () => {
19+
expect(buildCorsHeaders('disabled')).toEqual({})
20+
})
21+
22+
it('returns the inner headers from the { headers } shape', () => {
23+
const headers = {
24+
'Access-Control-Allow-Origin': 'https://example.com',
25+
'Access-Control-Allow-Headers': 'X-Custom',
26+
}
27+
expect(buildCorsHeaders({ headers })).toBe(headers)
28+
})
29+
30+
// Deprecated forms — still supported for backward compatibility.
31+
it('returns supabase-js defaults when config is true (deprecated)', () => {
32+
const headers = buildCorsHeaders(true)
33+
expect(headers['Access-Control-Allow-Origin']).toBe('*')
34+
expect(headers['Access-Control-Allow-Methods']).toContain('GET')
35+
expect(headers['Access-Control-Allow-Headers']).toContain('authorization')
36+
})
37+
38+
it('returns empty object when config is false (deprecated)', () => {
1939
expect(buildCorsHeaders(false)).toEqual({})
2040
})
2141

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

51+
describe('isCorsDisabled', () => {
52+
it("is true for 'disabled' and the deprecated false", () => {
53+
expect(isCorsDisabled('disabled')).toBe(true)
54+
expect(isCorsDisabled(false)).toBe(true)
55+
})
56+
57+
it('is false for enabled configurations', () => {
58+
expect(isCorsDisabled('default')).toBe(false)
59+
expect(isCorsDisabled(true)).toBe(false)
60+
expect(isCorsDisabled()).toBe(false)
61+
expect(isCorsDisabled({ headers: { 'X-Custom': '1' } })).toBe(false)
62+
expect(isCorsDisabled({ 'Access-Control-Allow-Origin': '*' })).toBe(false)
63+
})
64+
})
65+
3166
describe('addCorsHeaders', () => {
3267
it('adds default CORS headers to response', () => {
3368
const response = new Response('ok')
3469
const result = addCorsHeaders(response, true)
3570
expect(result.headers.get('Access-Control-Allow-Origin')).toBe('*')
3671
})
3772

38-
it('returns response unchanged when config is false', () => {
73+
it("returns response unchanged when config is 'disabled'", () => {
74+
const response = new Response('ok')
75+
const result = addCorsHeaders(response, 'disabled')
76+
expect(result.headers.get('Access-Control-Allow-Origin')).toBeNull()
77+
})
78+
79+
it('returns response unchanged when config is false (deprecated)', () => {
3980
const response = new Response('ok')
4081
const result = addCorsHeaders(response, false)
4182
expect(result.headers.get('Access-Control-Allow-Origin')).toBeNull()
4283
})
4384

44-
it('adds custom headers to response', () => {
85+
it('adds custom headers from the { headers } shape to response', () => {
86+
const response = new Response('ok')
87+
const result = addCorsHeaders(response, {
88+
headers: { 'Access-Control-Allow-Origin': 'https://example.com' },
89+
})
90+
expect(result.headers.get('Access-Control-Allow-Origin')).toBe(
91+
'https://example.com',
92+
)
93+
})
94+
95+
it('adds a bare custom headers record to response (deprecated)', () => {
4596
const response = new Response('ok')
4697
const result = addCorsHeaders(response, {
4798
'Access-Control-Allow-Origin': 'https://example.com',

src/cors.ts

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -3,13 +3,35 @@ import { corsHeaders as defaultCorsHeaders } from '@supabase/supabase-js/cors'
33
/**
44
* CORS configuration for {@link withSupabase}.
55
*
6-
* - `true` — uses `@supabase/supabase-js` default CORS headers.
7-
* - `false` — disables CORS handling entirely.
8-
* - `Record<string, string>` — custom CORS headers to use.
6+
* - `'default'` — uses `@supabase/supabase-js` default CORS headers.
7+
* - `'disabled'` — disables CORS handling entirely.
8+
* - `{ headers }` — custom CORS headers to use.
9+
*
10+
* The boolean (`true`/`false`) and bare `Record<string, string>` forms are
11+
* deprecated but still accepted for backward compatibility.
12+
*
13+
* @internal
14+
*/
15+
type CorsConfig =
16+
| 'default'
17+
| 'disabled'
18+
| { headers: Record<string, string> }
19+
/** @deprecated Use `'default'` | `'disabled'` | `{ headers }` instead. */
20+
| boolean
21+
/** @deprecated Use `{ headers }` instead. */
22+
| Record<string, string>
23+
24+
/**
25+
* Whether the given CORS configuration disables CORS handling.
26+
*
27+
* @param config - The CORS configuration.
28+
* @returns `true` for `'disabled'` or the deprecated `false`, otherwise `false`.
929
*
1030
* @internal
1131
*/
12-
type CorsConfig = boolean | Record<string, string>
32+
export function isCorsDisabled(config?: CorsConfig): boolean {
33+
return config === false || config === 'disabled'
34+
}
1335

1436
/**
1537
* Builds the CORS headers object based on the given configuration.
@@ -20,16 +42,22 @@ type CorsConfig = boolean | Record<string, string>
2042
* @internal
2143
*/
2244
export function buildCorsHeaders(config?: CorsConfig): Record<string, string> {
23-
if (config === false) return {}
24-
if (typeof config === 'object') return config
45+
if (isCorsDisabled(config)) return {}
46+
if (typeof config === 'object') {
47+
// New `{ headers }` shape vs the deprecated bare `Record<string, string>`.
48+
if ('headers' in config && typeof config.headers === 'object') {
49+
return config.headers
50+
}
51+
return config as Record<string, string>
52+
}
2553
return defaultCorsHeaders
2654
}
2755

2856
/**
2957
* Returns a new `Response` with CORS headers appended.
3058
*
3159
* Creates a clone of the original response and sets each CORS header on it.
32-
* If CORS is disabled (`config === false`), returns the original response unchanged.
60+
* If CORS is disabled (`'disabled'` or the deprecated `false`), returns the original response unchanged.
3361
*
3462
* @param response - The original response to augment.
3563
* @param config - The CORS configuration.
@@ -41,7 +69,7 @@ export function addCorsHeaders(
4169
response: Response,
4270
config?: CorsConfig,
4371
): Response {
44-
if (config === false) return response
72+
if (isCorsDisabled(config)) return response
4573

4674
const corsHeaders = buildCorsHeaders(config)
4775
const newResponse = new Response(response.body, response)

src/types.ts

Lines changed: 18 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -239,7 +239,7 @@ export interface UserClaims {
239239
* }
240240
*
241241
* // No auth required, CORS disabled
242-
* const config: WithSupabaseConfig = { auth: 'none', cors: false }
242+
* const config: WithSupabaseConfig = { auth: 'none', cors: 'disabled' }
243243
* ```
244244
*
245245
* @category Types
@@ -270,16 +270,27 @@ export interface WithSupabaseConfig {
270270
/**
271271
* CORS configuration for the `withSupabase` wrapper.
272272
*
273-
* - `true` (default) — uses `@supabase/supabase-js` default CORS headers.
274-
* - `false` — disables CORS handling entirely.
275-
* - `Record<string, string>` — custom CORS headers.
273+
* - `'default'` — uses `@supabase/supabase-js` default CORS headers.
274+
* - `'disabled'` — disables CORS handling entirely.
275+
* - `{ headers }` — custom CORS headers.
276+
*
277+
* The boolean (`true`/`false`) and bare `Record<string, string>` forms are
278+
* deprecated but still accepted for backward compatibility.
276279
*
277280
* @remarks Only applies to the top-level {@link withSupabase} wrapper.
278-
* The Hono adapter handles CORS separately via Hono's own middleware.
281+
* The adapters (Hono, H3, Elysia, NestJS) handle CORS separately via each
282+
* framework's own middleware.
279283
*
280-
* @defaultValue `true`
284+
* @defaultValue `'default'`
281285
*/
282-
cors?: boolean | Record<string, string>
286+
cors?:
287+
| 'default'
288+
| 'disabled'
289+
| { headers: Record<string, string> }
290+
/** @deprecated Use `'default'` | `'disabled'` | `{ headers }` instead. */
291+
| boolean
292+
/** @deprecated Use `{ headers }` instead. */
293+
| Record<string, string>
283294

284295
/**
285296
* Options forwarded to both internal `createClient()` calls.

src/with-supabase.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,66 @@ describe('withSupabase', () => {
9898
expect(res.headers.get('Access-Control-Allow-Origin')).toBeNull()
9999
})
100100

101+
describe('explicit cors shape', () => {
102+
it("skips OPTIONS handling when cors is 'disabled'", async () => {
103+
const handler = withSupabase(
104+
{ auth: 'none', env: baseEnv, cors: 'disabled' },
105+
async () => Response.json({ ok: true }),
106+
)
107+
108+
const req = new Request('http://localhost', { method: 'OPTIONS' })
109+
const res = await handler(req)
110+
// When CORS disabled, OPTIONS goes through normal flow
111+
expect(res.status).toBe(200)
112+
})
113+
114+
it("does not add CORS headers when cors is 'disabled'", async () => {
115+
const handler = withSupabase(
116+
{ auth: 'none', env: baseEnv, cors: 'disabled' },
117+
async () => Response.json({ ok: true }),
118+
)
119+
120+
const req = new Request('http://localhost')
121+
const res = await handler(req)
122+
expect(res.headers.get('Access-Control-Allow-Origin')).toBeNull()
123+
})
124+
125+
it('applies custom { headers } to the success response', async () => {
126+
const handler = withSupabase(
127+
{
128+
auth: 'none',
129+
env: baseEnv,
130+
cors: { headers: { 'Access-Control-Allow-Origin': 'https://a.com' } },
131+
},
132+
async () => Response.json({ ok: true }),
133+
)
134+
135+
const req = new Request('http://localhost')
136+
const res = await handler(req)
137+
expect(res.headers.get('Access-Control-Allow-Origin')).toBe(
138+
'https://a.com',
139+
)
140+
})
141+
142+
it('applies custom { headers } to the error response', async () => {
143+
const handler = withSupabase(
144+
{
145+
auth: 'user',
146+
env: baseEnv,
147+
cors: { headers: { 'Access-Control-Allow-Origin': 'https://a.com' } },
148+
},
149+
async () => Response.json({ ok: true }),
150+
)
151+
152+
const req = new Request('http://localhost')
153+
const res = await handler(req)
154+
expect(res.status).toBe(401)
155+
expect(res.headers.get('Access-Control-Allow-Origin')).toBe(
156+
'https://a.com',
157+
)
158+
})
159+
})
160+
101161
describe('allow → auth deprecation', () => {
102162
beforeEach(() => {
103163
_resetAllowDeprecationWarned()

src/with-supabase.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { addCorsHeaders, buildCorsHeaders } from './cors.js'
1+
import { addCorsHeaders, buildCorsHeaders, isCorsDisabled } from './cors.js'
22
import { createSupabaseContext } from './create-supabase-context.js'
33
import type { SupabaseContext, WithSupabaseConfig } from './types.js'
44

@@ -32,7 +32,7 @@ export function withSupabase<Database = unknown>(
3232
handler: (req: Request, ctx: SupabaseContext<Database>) => Promise<Response>,
3333
): (req: Request) => Promise<Response> {
3434
return async (req: Request) => {
35-
if (config.cors !== false && req.method === 'OPTIONS') {
35+
if (!isCorsDisabled(config.cors) && req.method === 'OPTIONS') {
3636
return new Response(null, {
3737
status: 204,
3838
headers: buildCorsHeaders(config.cors),
@@ -48,14 +48,16 @@ export function withSupabase<Database = unknown>(
4848
{ message: error.message, code: error.code },
4949
{
5050
status: error.status,
51-
headers: config.cors !== false ? buildCorsHeaders(config.cors) : {},
51+
headers: !isCorsDisabled(config.cors)
52+
? buildCorsHeaders(config.cors)
53+
: {},
5254
},
5355
)
5456
}
5557

5658
const response = await handler(req, ctx)
5759

58-
if (config.cors !== false) {
60+
if (!isCorsDisabled(config.cors)) {
5961
return addCorsHeaders(response, config.cors)
6062
}
6163
return response

0 commit comments

Comments
 (0)