O que vamos fazer
- Uma tabela
mensagens, com RLS: só quem é da sala lê e escreve. - Uma função
e_membro, que a regra usa. É ela que faz a regra valer também no realtime. - O realtime ligado em
mensagens. - No app, a conexão com o token da sessão e um hook React que mantém a sala ao vivo.
Como o Realtime decide quem recebe
O realtime segue o RLS da tabela. Cada mudança vai só para quem leria aquela linha pela API. O token de realtime carrega o usuário da sessão, e a regra de leitura é conferida linha a linha, para cada inscrito. Um token pedido com a anon key recebe só o que um visitante sem login leria.
Uma diferença em relação à API: o realtime confere a regra com um papel próprio, que não lê as outras tabelas do projeto. Por isso a regra de mensagens não consulta membros direto. Ela chama uma função security definer, que roda com a permissão do dono do projeto. Se consultar direto, a regra funciona na API mas não vale no realtime: ninguém recebe nada, e o painel avisa quando você liga a tabela.
Duas coisas ficam de fora do RLS. Um DELETE chega a quem está inscrito na tabela só com o id da linha apagada, sem as outras colunas. Presence e broadcast (quem está online, "digitando…") são canais, não tabelas, e não passam pelo RLS. Para eles, use canal privado com o nome do projeto: rt.channel('proj_meuapp:sala-1', { config: { private: true } }) — o servidor só deixa entrar quem tem token do mesmo projeto. Canal público é compartilhado entre todos os projetos da plataforma: o "sala-1" público de outro projeto é o mesmo canal. Por isso o código abaixo abre o canal privado já na inscrição da tabela, e não só quando o chat ganhar presence: é o mesmo canal.
Passo 1: as tabelas
Rode no SQL Editor do painel. Pode rodar de novo sem medo.
-- Mensagens: o conteúdo, com realtime. Quem recebe cada uma é o RLS que decide.
create table if not exists mensagens (
id uuid primary key default gen_random_uuid(),
sala_id uuid not null,
user_id uuid not null default auth.uid(),
texto text not null check (length(texto) between 1 and 4000),
criada_em timestamptz not null default now()
);
create index if not exists mensagens_por_sala on mensagens (sala_id, criada_em desc);
-- Quem está em cada sala.
create table if not exists membros (
sala_id uuid not null,
user_id uuid not null,
primary key (sala_id, user_id)
);
alter table mensagens enable row level security;
alter table membros enable row level security;
-- "Quem está perguntando é desta sala?" SECURITY DEFINER: roda com a permissão
-- do dono do projeto, e por isso vale também no realtime, que confere cada
-- mensagem com um papel sem acesso a membros.
create or replace function e_membro(sala uuid) returns boolean
language sql stable security definer set search_path from current as $$
select exists (select 1 from membros where sala_id = sala and user_id = auth.uid())
$$;
drop policy if exists membros_veem_as_proprias on membros;
create policy membros_veem_as_proprias on membros
for select to authenticated using (user_id = auth.uid());
drop policy if exists mensagens_ler on mensagens;
create policy mensagens_ler on mensagens
for select to authenticated using (e_membro(sala_id));
drop policy if exists mensagens_enviar on mensagens;
create policy mensagens_enviar on mensagens
for insert to authenticated
with check (user_id = auth.uid() and e_membro(sala_id));
revoke all on mensagens, membros from anon, authenticated;
grant select, insert on mensagens to authenticated;
grant select on membros to authenticated;
- A função não é detalhe. Com
exists (select 1 from membros …)direto na regra, a API funciona e o realtime fica mudo. - Não revogue o
executeda função. O realtime precisa chamá-la. Ela só responde se quem pergunta é da sala, e para a anon key a resposta é sempre não.
Passo 2: ligar o realtime
No painel, abra o projeto e vá em Realtime. Ligue a tabela mensagens. O painel recusa tabela sem RLS e avisa se alguma regra dela não vale no realtime.
Passo 3: a conexão
O db.channel() do SDK ainda não funciona: ele lança not_implemented. A conexão usa o @supabase/realtime-js (npm install @supabase/realtime-js) com um token de realtime, pedido com a sessão do usuário. O token vale 1 hora:
import { RealtimeClient } from '@supabase/realtime-js'
import { db } from './superdb'
const AUTH = import.meta.env.VITE_SUPERDB_URL // https://auth.superdb.com.br
export const SCHEMA = `proj_${import.meta.env.VITE_SUPERDB_PROJECT}`
// O token leva o usuário da SESSÃO: é por ele que o RLS decide o que chega.
async function sessao() {
const token = await db.auth.getDataPlaneToken()
if (!token) throw new Error('realtime: entre na conta antes de abrir a sala')
return token
}
async function tokenDeRealtime() {
const pedir = async () =>
fetch(`${AUTH}/rt/v1/token`, {
method: 'POST',
headers: { Authorization: `Bearer ${await sessao()}` },
})
let r = await pedir()
// 401: a sessão venceu (vale 1 hora; só o SDK 0.3+ renova sozinho).
if (r.status === 401) {
await db.auth.refreshSession()
r = await pedir()
}
if (!r.ok) throw new Error(`realtime: ${r.status}`)
return (await r.json()) as { token: string; url: string; expires_in: number }
}
export async function conectar() {
const { token, url, expires_in } = await tokenDeRealtime()
const rt = new RealtimeClient(url, { params: { apikey: token } })
// O token vence em 1 hora: troque por um novo 5 minutos antes.
const renovar = setInterval(async () => {
rt.setAuth((await tokenDeRealtime()).token)
}, (expires_in - 300) * 1000)
return { rt, fechar: () => { clearInterval(renovar); rt.disconnect() } }
}
Ao sair da conta, feche a conexão: o token é de quem saiu.
Passo 4: a sala
O hook carrega as últimas mensagens pelo SDK e acrescenta as que chegam pelo realtime:
import { useCallback, useEffect, useState } from 'react'
import { db } from './lib/superdb'
import { SCHEMA, conectar } from './lib/realtime'
type Mensagem = { id: string; sala_id: string; user_id: string; texto: string; criada_em: string }
export function useSala(salaId: string) {
const [mensagens, setMensagens] = useState<Mensagem[]>([])
// A lista inicial vem pelo REST; o realtime traz o que chega depois.
const carregar = useCallback(async () => {
const { data, error } = await db
.from('mensagens')
.select('id,sala_id,user_id,texto,criada_em')
.eq('sala_id', salaId)
.order('criada_em', { ascending: false })
.limit(50)
if (!error && data) setMensagens((data as Mensagem[]).reverse())
}, [salaId])
useEffect(() => {
let ativo = true
let fechar = () => {}
carregar()
conectar().then((conexao) => {
if (!ativo) return conexao.fechar()
fechar = conexao.fechar
conexao.rt
.channel(`${SCHEMA}:sala-${salaId}`, { config: { private: true } })
.on('postgres_changes',
{ event: 'INSERT', schema: SCHEMA, table: 'mensagens', filter: `sala_id=eq.${salaId}` },
({ new: m }) => {
// O RLS já conferiu: só chega mensagem de sala da qual o usuário é membro.
const nova = m as Mensagem
setMensagens((atual) => (atual.some((x) => x.id === nova.id) ? atual : [...atual, nova]))
})
.subscribe()
})
return () => { ativo = false; fechar() }
}, [salaId, carregar])
const enviar = (texto: string) => db.from('mensagens').insert({ sala_id: salaId, texto })
return { mensagens, enviar }
}
Quem manda a mensagem também a recebe pelo realtime; o some evita duplicar. O filtro do canal (sala_id=eq.…) só economiza tráfego. Quem protege é o RLS: mesmo sem filtro, o usuário recebe só mensagens das salas dele.
Quem entra em cada sala
É regra do seu produto, e por isso fica no seu servidor: confira a permissão e grave em membros com a service key, que ignora o RLS. Ela nunca vai para o navegador.
import { createClient } from '@superdb/client'
const admin = createClient(process.env.SUPERDB_URL!, process.env.SUPERDB_SERVICE_ROLE_KEY!, {
project: process.env.SUPERDB_PROJECT!,
})
export async function adicionarNaSala(salaId: string, userId: string) {
// antes: confira que quem pediu pode convidar para esta sala
const { error } = await admin.from('membros').insert({ sala_id: salaId, user_id: userId })
if (error && error.code !== '23505') throw new Error(error.message) // 23505: já era membro
}
Erros comuns
A mensagem não chega pelo realtime
Confira, nesta ordem: a tabela mensagens está ligada em Realtime; o schema do canal é proj_ mais o slug do projeto; o token foi pedido com a sessão, e não com a anon key (com ela, a regra to authenticated não deixa nada passar); a regra usa e_membro, e não uma consulta direta a membros; e o token foi renovado (sem o setAuth, a conexão para de receber depois de 1 hora).
Chega para uns e não para outros
Quem não recebe não está em membros daquela sala, e o RLS não entrega a ele, nem pelo REST nem pelo realtime. É o comportamento certo para quem não é da sala.
Cada mensagem aparece duas vezes
Há duas conexões abertas: o efeito rodou de novo sem fechar a anterior. Mantenha o return do useEffect, que fecha a conexão; no modo de desenvolvimento do React, a primeira montagem é desfeita de propósito.
401 em /rt/v1/token
A sessão venceu: ela vale 1 hora, e só o SDK 0.3 em diante renova sozinho. O tokenDeRealtime acima renova e tenta de novo; se continuar em 401, o usuário precisa entrar de novo. Com a anon key, API key revogada ou inválida quer dizer que as chaves do projeto foram regeneradas: copie a nova em API Keys, no painel.