diff --git a/packages/core/supabase-js/src/SupabaseClient.ts b/packages/core/supabase-js/src/SupabaseClient.ts index 511d999c7..e8257d844 100644 --- a/packages/core/supabase-js/src/SupabaseClient.ts +++ b/packages/core/supabase-js/src/SupabaseClient.ts @@ -369,10 +369,29 @@ export default class SupabaseClient< }) } - private _initRealtimeClient(options: RealtimeClientOptions) { + private _initRealtimeClient( + options: RealtimeClientOptions & { includeHeadersInParams?: string[] } + ) { + const { includeHeadersInParams, ...realtimeOptions } = options + + // Copy specified headers to params for WebSocket handshake + const paramsFromHeaders: Record = {} + if (includeHeadersInParams && Array.isArray(includeHeadersInParams)) { + includeHeadersInParams.forEach((headerName) => { + const headerValue = this.headers[headerName] + if (headerValue !== undefined) { + paramsFromHeaders[headerName] = headerValue + } + }) + } + return new RealtimeClient(this.realtimeUrl.href, { - ...options, - params: { ...{ apikey: this.supabaseKey }, ...options?.params }, + ...realtimeOptions, + params: { + apikey: this.supabaseKey, + ...paramsFromHeaders, + ...realtimeOptions?.params, // User params can override + }, }) } diff --git a/packages/core/supabase-js/src/lib/types.ts b/packages/core/supabase-js/src/lib/types.ts index 3cf8c3895..4a3c0e068 100644 --- a/packages/core/supabase-js/src/lib/types.ts +++ b/packages/core/supabase-js/src/lib/types.ts @@ -82,7 +82,23 @@ export type SupabaseClientOptions = { /** * Options passed to the realtime-js instance */ - realtime?: RealtimeClientOptions + realtime?: RealtimeClientOptions & { + /** + * Array of header names from `global.headers` to include as WebSocket query parameters. + * This enables RLS policies that check `request.headers` to work with realtime subscriptions. + * + * @example + * ```typescript + * createClient(url, key, { + * global: { headers: { 'x-tenant-id': 'tenant-123' } }, + * realtime: { includeHeadersInParams: ['x-tenant-id'] } + * }) + * ``` + * + * Security: Only explicitly listed headers are included. Avoid including sensitive data. + */ + includeHeadersInParams?: string[] + } storage?: StorageClientOptions global?: { /** diff --git a/packages/core/supabase-js/test/unit/SupabaseClient.test.ts b/packages/core/supabase-js/test/unit/SupabaseClient.test.ts index 6f7304114..497016141 100644 --- a/packages/core/supabase-js/test/unit/SupabaseClient.test.ts +++ b/packages/core/supabase-js/test/unit/SupabaseClient.test.ts @@ -188,6 +188,113 @@ describe('SupabaseClient', () => { }) }) + describe('Realtime Headers in Params', () => { + test('should copy specified headers to realtime params', () => { + const client = createClient(URL, KEY, { + global: { + headers: { + 'x-tenant-id': 'tenant-123', + 'x-other-header': 'other-value', + }, + }, + realtime: { + includeHeadersInParams: ['x-tenant-id'], + }, + }) + + // @ts-ignore - accessing private property + expect(client.realtime.params['x-tenant-id']).toBe('tenant-123') + // @ts-ignore - accessing private property + expect(client.realtime.params['x-other-header']).toBeUndefined() + }) + + test('should not copy headers if includeHeadersInParams is not specified', () => { + const client = createClient(URL, KEY, { + global: { + headers: { + 'x-tenant-id': 'tenant-123', + }, + }, + }) + + // @ts-ignore - accessing private property + expect(client.realtime.params['x-tenant-id']).toBeUndefined() + }) + + test('should allow explicit realtime params to override headers', () => { + const client = createClient(URL, KEY, { + global: { + headers: { + 'x-tenant-id': 'tenant-from-header', + }, + }, + realtime: { + includeHeadersInParams: ['x-tenant-id'], + params: { + 'x-tenant-id': 'tenant-override', + }, + }, + }) + + // @ts-ignore - accessing private property + expect(client.realtime.params['x-tenant-id']).toBe('tenant-override') + }) + + test('should handle missing headers gracefully', () => { + const client = createClient(URL, KEY, { + global: { + headers: { + 'x-existing': 'value', + }, + }, + realtime: { + includeHeadersInParams: ['x-non-existent', 'x-existing'], + }, + }) + + // @ts-ignore - accessing private property + expect(client.realtime.params['x-non-existent']).toBeUndefined() + // @ts-ignore - accessing private property + expect(client.realtime.params['x-existing']).toBe('value') + }) + + test('should handle empty includeHeadersInParams array', () => { + const client = createClient(URL, KEY, { + global: { + headers: { + 'x-tenant-id': 'tenant-123', + }, + }, + realtime: { + includeHeadersInParams: [], + }, + }) + + // @ts-ignore - accessing private property + expect(client.realtime.params['x-tenant-id']).toBeUndefined() + }) + + test('should maintain backward compatibility when no includeHeadersInParams', () => { + const client = createClient(URL, KEY, { + global: { + headers: { + 'x-tenant-id': 'tenant-123', + }, + }, + realtime: { + params: { + 'custom-param': 'custom-value', + }, + }, + }) + + // @ts-ignore - accessing private property + expect(client.realtime.params['custom-param']).toBe('custom-value') + // @ts-ignore - accessing private property + expect(client.realtime.params['x-tenant-id']).toBeUndefined() + }) + }) + describe('Schema Management', () => { test('should switch schema', () => { const client = createClient(URL, KEY)