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
95 changes: 85 additions & 10 deletions apps/docs/content/guides/realtime/settings.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -23,13 +23,88 @@ width={4600}
height={2600}
/>

You can set the following settings using the Realtime Settings screen in your Dashboard:

- Enable Realtime service: Determines if the Realtime service is enabled or disabled for your project.
- Channel Restrictions: You can toggle this settings to set Realtime to allow public channels or set it to use only private channels with [Realtime Authorization](/docs/guides/realtime/authorization).
- Database connection pool size: Determines the number of connections used for Realtime Authorization RLS checking
{/* supa-mdx-lint-disable-next-line Rule004ExcludeWords */}
- Max concurrent clients: Determines the maximum number of clients that can be connected
- Max events per second: Determines the maximum number of events per second that can be sent
- Max presence events per second: Determines the maximum number of presence events per second that can be sent
- Max payload size in KB: Determines the maximum number of payload size in KB that can be sent
You can set the following settings using the Realtime Settings screen in your Dashboard. For the ceilings your plan allows, see [Realtime Limits](/docs/guides/realtime/limits); the rate and payload limits are only editable while your organization's spend cap is disabled. For the errors below, see [Operational Error Codes](/docs/guides/realtime/error_codes).

### Enable Realtime service

**Type:** Toggle · **Options:** Enabled, Disabled · **Default:** Enabled

Determines if the Realtime service is enabled or disabled for your project.

- **Enabled**: normal operation.
- **Disabled**: connected clients are disconnected, new connections are rejected with `403` and `Realtime was disabled for this tenant`, joins receive `RealtimeDisabledForTenant`, and Broadcast REST requests are rejected with `403`. Realtime also releases the database connections and Postgres Changes replication slot it holds for your project, and reopens them on the first connection after you enable it again.

### Allow public access to channels

**Type:** Toggle · **Options:** Enabled, Disabled · **Default:** Enabled

Determines whether Realtime allows public channels, or restricts your project to private channels with [Realtime Authorization](/docs/guides/realtime/authorization).

- **Enabled**: no policy check runs, but anyone holding your project's anon key can subscribe to and broadcast on any public channel.
- **Disabled**: every join is checked against the Row Level Security policies on `realtime.messages`, so each join costs one authorization query. Clients that don't set `config.private` to `true` are rejected with `PrivateOnly`. With no policies, clients connect but receive no messages.

### Database connection pool size

**Type:** Number of connections · **Range:** 1 to your database's `max_connections` · **Default:** varies by compute size

Determines the number of connections used for Realtime Authorization RLS checking. Results are cached per client, so the pool is used on each private channel join, each `access_token` refresh, and each private Broadcast REST request.

- **Too low**: checks queue and time out. Clients receive `IncreaseConnectionPool`, broadcasts are dropped, and presence calls fail. Once timeouts in a 30-second window reach the pool size, later checks fail immediately without reaching the database.
- **Too high**: the pool competes with your application for your database's `max_connections`. If Realtime's total requirement doesn't fit, it refuses to start with `DatabaseLackOfConnections`.

See [Database connections](/docs/guides/realtime/concepts#database-connections) for the defaults per compute size.

### Postgres Changes connection pool size

**Type:** Number of connections · **Range:** 1 to 20 · **Default:** 2

Determines the number of connections used to create [Postgres Changes](/docs/guides/realtime/postgres-changes) subscriptions when clients subscribe. It's only used while subscriptions are created; streaming the changes uses a separate connection.

- **Too low**: subscription creation times out during bursts. Clients receive a `postgres_changes` system error with `Too many database timeouts` and retry after 5 to 10 seconds.
- **Too high**: it counts toward the same connection budget as every other Realtime pool.

Raise this value if many clients subscribe at the same time, such as after a deploy or a mass reconnect.

{/* supa-mdx-lint-disable-next-line Rule004ExcludeWords */}

### Max concurrent clients

**Type:** Number of clients · **Range:** 1 to your plan's [concurrent connections](/docs/guides/realtime/limits#limits-by-plan) limit · **Default:** your plan's limit

Determines the maximum number of clients that can be connected. A client is one WebSocket connection, no matter how many channels it joins.

- **Too low**: new connections are rejected with `429` and `Too many connected users`. Existing clients are unaffected.
- **Too high**: each connection consumes memory on the Realtime nodes, so this setting acts as a capacity and cost control.

### Max events per second

**Type:** Number of events per second · **Range:** 1 to your plan's [messages per second](/docs/guides/realtime/limits#limits-by-plan) limit · **Default:** your plan's limit

Determines the maximum number of events per second that can be sent, measured as a rolling average over the previous minute. An event is a message sent by a client or delivered to one, so one broadcast to 100 subscribers counts as 100 events.

- **Too low**: channels that exceed the average are closed with `Too many messages per second`, which `supabase-js` recovers from by rejoining. Broadcast REST requests are rejected with `429`; those responses carry `x-rate-limit` and `x-rate-limit-remaining` headers you can use to slow down first.
- **Too high**: Realtime stops throttling broadcast fan-out, which removes the protection against a runaway loop or a mass reconnect, and raises the ceiling on your Realtime spend.

### Max presence events per second

**Type:** Number of events per second · **Range:** 1 to your plan's [presence messages per second](/docs/guides/realtime/limits#limits-by-plan) limit · **Default:** your plan's limit

Determines the maximum number of presence events per second that can be sent, using the same rolling average as [Max events per second](#max-events-per-second) but checked before the event is sent rather than after delivery.

- **Too low**: presence tracking and syncing fail and the channel is closed with `Too many presence messages per second`.
- **Too high**: rapid `track` and `untrack` cycles can generate presence storms, because each change is broadcast to every client on the channel and also counts toward [Max events per second](#max-events-per-second).

<Admonition type="note">

A separate per-client limit also applies to presence, independent of this project-wide setting. A client that exceeds it is closed with `Client presence rate limit exceeded`. See [Realtime Limits](/docs/guides/realtime/limits) for the value on your plan.

</Admonition>

### Max payload size in KB

**Type:** Size in KB · **Range:** 1 to your plan's [broadcast payload size](/docs/guides/realtime/limits#limits-by-plan) limit · **Default:** your plan's limit

Determines the maximum payload size in KB that can be sent.

- **Too low**: oversized broadcasts are dropped, and the sender only learns about it if it set `ack_broadcast` to `true`. Broadcast REST requests are rejected with `422`, and an oversized presence `track` closes the channel with `Track message size exceeded`.
- **Too high**: large messages increase memory and bandwidth usage on every subscriber, since each message is delivered to all clients on the channel. Postgres Changes payloads have a [separate limit](/docs/guides/realtime/limits#postgres-changes-payload-limit).
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { describe, expect, test } from 'vitest'

import {
getCatalogEntry,
PERMISSION_CATALOG,
PERMISSION_CATALOG_BY_CATEGORY,
type PermissionSelection,
} from './AccessToken.permissions'
import {
applyPreset,
getActivePresetId,
getFullAccessDescription,
getPreset,
PERMISSION_PRESETS,
} from './AccessToken.presets'

const NONE = getPreset('none')!
const READ = getPreset('read')!
const FULL = getPreset('full')!

describe('PERMISSION_PRESETS', () => {
test('offers no access, read-only and full access in that order', () => {
expect(PERMISSION_PRESETS.map((preset) => preset.id)).toEqual(['none', 'read', 'full'])
})

test('resolves every catalog entry to a mode its row can render', () => {
for (const entry of PERMISSION_CATALOG) {
expect(NONE.resolve(entry)).toBe('none')
expect(READ.resolve(entry)).toBe('read')
expect(FULL.resolve(entry)).toBe(entry.writable ? 'readwrite' : 'read')
}
})

test('caps full access at read for resources with no write scopes', () => {
const readOnlyEntries = PERMISSION_CATALOG.filter((entry) => !entry.writable)
expect(readOnlyEntries.length).toBeGreaterThan(0)
for (const entry of readOnlyEntries) {
expect(FULL.resolve(entry)).toBe('read')
}
})

test('only marks full access as risky, and only it carries a description', () => {
expect(
PERMISSION_PRESETS.filter((preset) => preset.isRisky).map((preset) => preset.id)
).toEqual(['full'])
expect(
PERMISSION_PRESETS.filter((preset) => preset.description !== undefined).map(
(preset) => preset.id
)
).toEqual(['full'])
})
})

describe('getFullAccessDescription', () => {
test('names high-risk resources that exist in the catalog', () => {
const description = getFullAccessDescription()
expect(description).toBe(
'Grants the highest access each resource offers, including write access to your database, API keys, and organization members.'
)
for (const key of ['project:database', 'project:api_gateway_keys', 'organization:members']) {
expect(getCatalogEntry(key)).toBeDefined()
expect(getCatalogEntry(key)!.risk).toBe('high')
// The copy claims write access on these specifically, so they have to be writable
expect(getCatalogEntry(key)!.writable).toBe(true)
}
})
})

describe('applyPreset', () => {
test('sets every catalog entry by default', () => {
const selection = applyPreset(READ, {})
expect(Object.keys(selection)).toHaveLength(PERMISSION_CATALOG.length)
expect(Object.values(selection).every((mode) => mode === 'read')).toBe(true)
})

test('overwrites existing manual choices', () => {
const selection = applyPreset(NONE, { 'project:database': 'readwrite' })
expect(selection['project:database']).toBe('none')
})

test('leaves entries outside the given subset untouched', () => {
const database = PERMISSION_CATALOG_BY_CATEGORY.find((category) => category.key === 'database')!
const before: PermissionSelection = { 'project:advisors': 'read' }
const selection = applyPreset(FULL, before, database.entries)

expect(selection['project:advisors']).toBe('read')
expect(selection['project:database']).toBe('readwrite')
expect(Object.keys(selection)).toHaveLength(database.entries.length + 1)
})
})

describe('getActivePresetId', () => {
test('reads an empty selection as no access', () => {
expect(getActivePresetId({})).toBe('none')
})

test('identifies a selection produced by each preset', () => {
for (const preset of PERMISSION_PRESETS) {
expect(getActivePresetId(applyPreset(preset, {}))).toBe(preset.id)
}
})

test('returns null once a single row diverges', () => {
const selection = applyPreset(READ, {})
selection['project:storage'] = 'readwrite'
expect(getActivePresetId(selection)).toBeNull()
})

test('ignores rows outside the given subset', () => {
const database = PERMISSION_CATALOG_BY_CATEGORY.find((category) => category.key === 'database')!
const selection = applyPreset(READ, {}, database.entries)
selection['project:storage'] = 'readwrite'

expect(getActivePresetId(selection)).toBeNull()
expect(getActivePresetId(selection, database.entries)).toBe('read')
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import {
getCatalogEntry,
PERMISSION_CATALOG,
type PermissionCatalogEntry,
type PermissionMode,
type PermissionSelection,
} from './AccessToken.permissions'

/**
* Bulk presets for the permission list. Setting 42 rows one at a time is the main reason people
* abandon the scoped token flow, so a preset applies a mode to every row at once and the user
* fine-tunes from there.
*
* A preset is data, not JSX: `resolve` maps a single catalog entry to a mode, and every helper
* below takes an `entries` subset (defaulting to the whole catalog) so the same presets can later
* back per-category "set all" controls, or move server-side.
*/

export type PermissionPresetId = 'none' | 'read' | 'full'

export interface PermissionPreset {
id: PermissionPresetId
label: string
/** Optional subtext, shown beneath the label in the menu. */
description?: string
/** Surfaces the description as an inline warning once applied. */
isRisky?: boolean
/** Announced in a live region once the preset is applied. */
announcement: string
resolve: (entry: PermissionCatalogEntry) => PermissionMode
}

/**
* High-risk resources named in the Full access warning. Keyed by catalog key so the copy can only
* name scopes that actually exist — AccessToken.presets.test.ts asserts every key resolves.
*/
const FULL_ACCESS_HIGH_RISK: { key: string; noun: string }[] = [
{ key: 'project:database', noun: 'your database' },
{ key: 'project:api_gateway_keys', noun: 'API keys' },
{ key: 'organization:members', noun: 'organization members' },
]

export const getFullAccessDescription = (): string => {
const nouns = FULL_ACCESS_HIGH_RISK.filter(({ key }) => getCatalogEntry(key) !== undefined).map(
({ noun }) => noun
)
if (nouns.length === 0) return 'Grants the highest access each resource offers.'
const listed =
nouns.length === 1
? nouns[0]
: `${nouns.slice(0, -1).join(', ')}, and ${nouns[nouns.length - 1]}`
return `Grants the highest access each resource offers, including write access to ${listed}.`
}

export const PERMISSION_PRESETS: PermissionPreset[] = [
{
id: 'none',
label: 'No access',
announcement: 'All permissions set to none',
resolve: () => 'none',
},
{
id: 'read',
label: 'Read-only',
announcement: 'All permissions set to read',
resolve: () => 'read',
},
{
id: 'full',
label: 'Full access',
description: getFullAccessDescription(),
isRisky: true,
announcement: 'All permissions set to read-write',
// Five resources expose no write scopes, so read is their highest level — resolving them to
// 'readwrite' would store a mode their Select can't render.
resolve: (entry) => (entry.writable ? 'readwrite' : 'read'),
},
]

export const getPreset = (id: PermissionPresetId): PermissionPreset | undefined =>
PERMISSION_PRESETS.find((preset) => preset.id === id)

/** Applies a preset over `entries`, leaving any selection outside that subset untouched. */
export const applyPreset = (
preset: PermissionPreset,
selection: PermissionSelection,
entries: PermissionCatalogEntry[] = PERMISSION_CATALOG
): PermissionSelection => {
const next = { ...selection }
for (const entry of entries) {
next[entry.key] = preset.resolve(entry)
}
return next
}

/**
* The preset `selection` currently matches across `entries`, or null when it matches none of them
* — the "Custom" state, which is displayed but never selectable.
*/
export const getActivePresetId = (
selection: PermissionSelection,
entries: PermissionCatalogEntry[] = PERMISSION_CATALOG
): PermissionPresetId | null => getActivePreset(selection, entries)?.id ?? null

export const getActivePreset = (
selection: PermissionSelection,
entries: PermissionCatalogEntry[] = PERMISSION_CATALOG
): PermissionPreset | undefined =>
PERMISSION_PRESETS.find((preset) =>
entries.every((entry) => (selection[entry.key] ?? 'none') === preset.resolve(entry))
)
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { Admonition } from 'ui-patterns/Admonition'

import { CLASSIC_TOKEN_WARNING } from '../../AccessToken.constants'
import { countConfigured, PermissionMode } from '../../AccessToken.permissions'
import { applyPreset, type PermissionPreset } from '../../AccessToken.presets'
import { useTokenAccessEvaluation } from '../../hooks/useTokenAccessEvaluation'
import { DEFAULT_EXPIRY, TokenFormSchema, TokenFormValues } from './NewScopedTokenForm.utils'
import { NewScopedTokenFormReview } from './NewScopedTokenFormReview'
Expand Down Expand Up @@ -122,6 +123,12 @@ export const NewScopedTokenForm = ({
if (mode !== 'none') setShowMissingPermissionsWarning(false)
}

const handleApplyPreset = (preset: PermissionPreset) => {
const next = applyPreset(preset, selection)
form.setValue('permissions', next)
if (countConfigured(next) > 0) setShowMissingPermissionsWarning(false)
}

return (
<>
{/* Radix wraps viewport children in an inline-styled display:table div that grows to fit
Expand Down Expand Up @@ -170,6 +177,7 @@ export const NewScopedTokenForm = ({
<PermissionsAccordion
selection={selection}
onChange={handlePermissionChange}
onApplyPreset={handleApplyPreset}
access={access}
/>
{showMissingPermissionsWarning && (
Expand Down
Loading
Loading