Pular para o conteúdo
📖 COOKBOOK

Chat em tempo real, sem vazar mensagem.

Mensagens ao vivo, e cada pessoa recebe só as das salas dela. O Realtime do SuperDB segue o RLS da tabela: o token de realtime leva o usuário da sessão, e a regra de leitura é conferida para cada inscrito.

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.

superdb/chat.sql
-- 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 execute da 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:

src/lib/realtime.ts
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:

src/useSala.ts
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.

servidor — adicionar alguém a uma sala
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.

Essa página ajudou?