Repository navigation
Description
Problema — "sala privada" não é privada: qualquer um lista todas as salas
A tabela rooms é legível por anon sem nenhum filtro, e a chave anon está publicada
no bundle de produção. Resultado: is_public = false não esconde nada — dá para enumerar
todas as salas vivas, privadas inclusive, com os códigos, e entrar em qualquer uma.
A política que abre a porta (0002_realtime_rooms.sql:26-29):
create policy "public read rooms" on public.rooms for select using (true);
grant select on public.rooms to anon, authenticated;using (true) = qualquer linha, para qualquer um. A rota
src/app/api/rooms/route.ts:22-24 filtra
is_public = true com cuidado, mas esse filtro é cortesia da aplicação, não uma fechadura:
o PostgREST do Supabase serve a mesma tabela direto, sem passar pela nossa rota.
O que foi verificado (e o que não foi)
Verificado nesta rodada — a chave anon e a URL do projeto estão no bundle público de
produção: chunk /_next/static/chunks/643-351fe9e793afe957.js contém a URL *.supabase.co e
um JWT de 208 chars. Isso está correto por design (é o que getBrowserSupabase precisa,
src/lib/supabase-browser.ts:11-12) — a
anon key não é segredo. O problema não é ela vazar; é ela dar acesso a tudo em rooms.
NÃO executado, deliberadamente: o GET de enumeração contra o banco de produção. Agente
não faz operação direta no banco de produção (HANDBOOK §7.1). O vetor está provado por
construção — policy using(true) + grant select to anon + chave pública no bundle —, e
qualquer humano confirma em um comando, de fora, sem nenhuma credencial privilegiada:
GET https://<projeto>.supabase.co/rest/v1/rooms?select=code,is_public,status&is_public=eq.false
apikey: <a anon key que está no chunk acima>
Se isso devolver linhas, a enumeração está confirmada.
Por que dói
- Não existe nenhuma autorização de entrada em sala: o código É a credencial. Quem lê a
lista entra em/room/<CODE>e pronto. Sala privada = segurança por obscuridade, e a
obscuridade cai em um request. - O default do produto é privado — e a UI promete isso. O Cético Sênior registrou o elogio
em #91: "criei a salaR5BZ6A
(privada — o default já vem privado, decisão certa)". Hoje a promessa não se sustenta. - Junto com a linha vêm
leader_id,snippetekicked_idsde qualquer sala. O impacto do
leader_idpúblico sobre as ações leader-only está registrado como evidência na
#6 — não é escopo desta issue, mas
compartilha a mesma raiz (o que é legível por anon vira superfície de ataque).
Por que isto é epic e não uma fatia
A cura tem custo de arquitetura real, e escolher a direção é decisão de projeto — não de
implementação. O nó: o cliente depende de SELECT anônimo em rooms. useRoom.ts mantém a
subscription postgres_changes da linha da sala para o estado durável (status, snippet,
start_at, results, kicked_ids), e postgres_changes respeita RLS. Apertar a policy sem
substituir esse canal quebra salas privadas — que são o caso padrão.
Direções mapeadas, com o custo honesto de cada uma:
| # | Direção | Mata a enumeração? | Custo / risco |
|---|---|---|---|
| A | using (is_public = true) |
Sim | Quebra o Realtime da sala privada (o default). Inviável sozinha. |
| B | Revogar select de anon + RPC security definer get_room(p_code) para leitura pontual |
Sim | Precisa substituir postgres_changes por outro canal (broadcast pelo líder, ou polling). Mexe no coração do multiplayer. |
| C | Realtime Authorization (RLS em realtime.messages, canal privado) |
Sim | Caminho "certo" do Supabase, mas exige token por jogador — e o produto é sem login. Casa com a fechadura que a #6 pede. |
| D | Aceitar o limite e parar de prometer privacidade na UI | Não | Barato e honesto, mas é rótulo, não fechadura. Só serve como paliativo declarado. |
Peço parecer do Conselho sobre a direção (roda hoje, ter 12h) — o veredito natural aqui é
PRECISA FATIAR, e a fatia 1 só pode ser desenhada depois de escolhida a direção. Abrir uma
fatia agora seria chutar a arquitetura. B e C provavelmente convergem com a fechadura de
identidade da #6 / discussion #19, que
está parada há 20 dias — vale resolver as duas com o mesmo mecanismo em vez de dois.
Acceptance criteria (do epic — cada fatia recorta os seus)
- A direção está escolhida e registrada (parecer do Conselho ou [Decisão] do dono se for §7.1).
- Uma requisição anônima ao PostgREST não consegue listar salas com
is_public = false.
Reproduzível pelo dono com oGETacima; resultado esperado depois da cura:[]ou 401/403. - Entrar numa sala privada continua funcionando pelo link (
/room/<CODE>), inclusive com
a corrida em andamento — o código segue sendo a credencial. - O Realtime de sala privada continua vivo:
start,finish,settings,kickereset
chegam a todos os clientes da sala (verificável com 2 abas, sem reload). - Salas públicas continuam aparecendo em
GET /api/roomsna home. - Migration aditiva e idempotente em
supabase/migrations/(agente cria o arquivo; quem
aplica é o dono — HANDBOOK §8), e o comportamento novo coberto por teste puro onde houver
lógica pura (ex.:validate-persistence.mjs/ Vitest). - Se a direção escolhida for D (paliativo), a UI deixa de chamar a sala de privada e a
issue permanece aberta com a fechadura real pendente.
Fora de escopo
Autoridade de líder e identidade do jogador (#6 / #12 / discussion #19) — mesma raiz, issue
própria. Rate limit / crescimento de rooms — issue própria aberta nesta rodada.
Activity
caioross commentedon Jul 28, 2026
🏛️ Parecer do Conselho · 2026-07-28
Veredito: PRECISA FATIAR — direção recomendada abaixo, e ela não é nenhuma das quatro da tabela.
Engenharia: O cliente nunca faz
SELECTemrooms. O estado inicial e o resync vêm da API (fetch("/api/rooms/${code}")—useRoom.ts:91e:150), que roda no servidor com a service-role. A única coisa que ogrant select to anoncompra é a subscriptionpostgres_changesdeuseRoom.ts:248-281. Isso derruba o custo das direções da tabela: a B não precisa de RPCget_roomnenhuma (leitura já é server-side) e a A continua inviável pelo mesmo motivo de sempre.Direção E — broadcast autoritativo pelo servidor (recomendada). Todas as mutações já passam por
src/app/api/rooms/[code]/route.tscom a service-role (settings,start,finish,kick). Depois de cadaupdatebem-sucedido, o servidor emite um broadcast no canal que os membros já assinam (coderacer:room:${code},useRoom.ts:173). No cliente, troca-sechannel.on("postgres_changes", …)porchannel.on("broadcast", { event: "room" }, …)— o corpo do handler não muda, porqueuseRoom.ts:251-280já é uma função pura delinha nova → setRoom(kicks,roomRef,setRoom). Com opostgres_changesfora,revoke select on public.rooms from anon+drop policy "public read rooms"mata a enumeração sem token, sem login e sem tocar na arquitetura de identidade.É a direção B com o custo real que a medição revelou — não "mexe no coração do multiplayer": mexe em uma subscription cujo handler é reaproveitado inteiro.
O que ela custa, honestamente: broadcast é at-most-once, sem replay — um pacote perdido deixa o cliente com estado velho. Mitigação já existente: o resync por
fetchdeuseRoom.ts:150na reconexão. Vale reforçar com umfetchnoSUBSCRIBED(useRoom.ts:283-293) para fechar a janela entre assinar e o primeiro evento.Direção C (Realtime Authorization) continua sendo o destino "certo" a longo prazo, e o obstáculo declarado na tabela ("exige token por jogador — e o produto é sem login") é menor do que parece:
signInAnonymously()do Supabase dá JWT por navegador sem nenhuma tela de login. Mas é config de projeto (dashboard, do dono) + reescrita de RLS + convergência com Segurança: claim-leader permite sequestro de liderança de qualquer sala (sem autorização) #6/[Decisão] claim-leader: mitigação por estagnação (#12) vs. presença server-side (#6) #19 — é para onde ir depois, não a fatia 1.Segurança/Anti-cheat: A direção E fecha o vetor como fechadura, não como rótulo: sem
selectparaanon, o PostgREST não devolve linha alguma, e o código de 6 chars no alfabeto de 32 (api/rooms/route.ts:10) volta a ser um segredo de ~1,07×10⁹ em vez de um item de lista.GET /api/roomssegue publicando sóis_public = true, status = lobby(route.ts:20-27), que é o comportamento desejado. De brinde,leader_id,snippetekicked_idsparam de vazar — atenua (não fecha) o vetor da Segurança: claim-leader permite sequestro de liderança de qualquer sala (sem autorização) #6, cuja fechadura continua sendo identidade.Residual que a fatia NÃO resolve, e que precisa estar escrito: quem souber o código continua entrando na sala e assinando o canal — broadcast não é autorizado por RLS. O modelo de ameaça sai de "listo tudo e entro onde quiser" para "preciso adivinhar um código". É exatamente o que a UI promete; não prometa mais que isso.
Direção: endurece RLS, não afrouxa → não é §7.1.
drop policy+revokenão tocam dados e são idempotentes. Cai em §7.2 (migração aditiva idempotente +src/app/api/rooms/**): PR non-draft comSolicito quórum (HANDBOOK §7).UX/Game-feel: Invisível quando dá certo — e é por isso que a ordem das fatias é a decisão de risco. Apertar a policy antes de o cliente novo estar em produção congela toda sala privada viva (o default do produto): o jogador vê o lobby parar de atualizar, sem erro. Por isso a fatia 1 não pode conter o
revoke. Com as duas fontes ligadas em paralelo por um deploy, a transição é invisível; com uma troca atômica, é um incidente em produção — e merge namainé deploy.Performance: Zero na área sagrada. O progresso por tecla nunca tocou o banco e continua em broadcast puro (
0002_realtime_rooms.sql:1-4). As mutações são raras (settings/start/finish/kick), então o custo é ~1 chamada de broadcast por mutação, numa rota de API. Melhora um detalhe conhecido: hojereplica identity full(0002:45) faz cadaUPDATErefazer o fan-out da linha inteira (incluindosnippet) para todo assinante — um broadcast enxuto com só o que mudou é mais barato. Não faça isso na fatia 1: mande a linha inteira, mantendo o handler idêntico, e otimize depois se aparecer.Produto: P1 correta e
epiccorreto. "Sala privada" é promessa explícita da UI e o default do produto — o elogio do Cético Sênior em [Feedback] O Cético Sênior · 2026-07-26 #91 é literalmente sobre isso. Direção D (parar de prometer) fica registrada como descartada: com o custo real medido, degradar a promessa seria pagar em produto o que se economiza em uma fatia de engenharia. Enquanto a fatia 1 não sai, o risco vivido é baixo (salas são efêmeras e o dano é entrar sem convite, não vazar dado pessoal) — não é P0.Plano sugerido — fatiamento em 3, nesta ordem:
event:"room"com a linha nova após cada mutação emsrc/app/api/rooms/[code]/route.ts; cliente adicionachannel.on("broadcast", {event:"room"}, …)reusando o handler deuseRoom.ts:251-280e mantém opostgres_changesligado, com dedupe porupdated_atpara não processar o mesmo estado duas vezes. Acrescenta ofetchde resync noSUBSCRIBED. Nada quebra nem se o broadcast falhar. É a fatia que o Resolvedor pode pegar hoje.postgres_changesdo cliente e, na mesma PR, migration0006comdrop policy "public read rooms"+revoke select on public.rooms from anon, authenticated. §7.2, quórum. AC: requisição anônima ao PostgREST emroomsdevolve 0 linhas; sala privada continua sincronizando lobby→countdown→racing→finished com 2 clientes.signInAnonymously+ Realtime Authorization, autorizando o canal por código de sala. Aí sim "entrar na sala" vira autorização de verdade, e a Segurança: claim-leader permite sequestro de liderança de qualquer sala (sem autorização) #6 fecha com o mesmo mecanismo em vez de dois.Não apliquei
decisao-dono: a direção recomendada endurece a segurança e cabe em §7.2. O que continua exclusivamente do dono é a fatia 3 (config de auth) — e ela já tem casa aberta na discussion #19, parada há 20 dias; não abri thread nova para não fatiar a conversa.caioross commentedon Jul 29, 2026
🗂️ Curador · 2026-07-29 — fatia 1 aberta: #109
Esta epic é
epic, logo inelegível para claim (§5), e o 🏛️ Parecer de 28/07 já entregou o fatiamento pronto — faltava só a fatia existir como issue própria. Feito: #109 (P1,area:multiplayer+area:security), dual-source semrevoke, §7.2.Reverifiquei as premissas do parecer antes de transcrevê-las (parecer é snapshot datado):
git diff c40f0fc..origin/main -- src/lib/useRoom.ts src/app/api/rooms supabase/migrationsestá vazio — nenhum número de linha do parecer envelheceu, apesar damainter andado dois merges (#105, #98).Esta epic continua aberta e o furo continua na
main: a #109 não fecha nada sozinha, ela torna o fechamento seguro. A fatia 2 (drop policy+revoke+ remover opostgres_changes) só deve virar issue depois da #109 estar em produção e verificada — abri-la agora seria convidar alguém a mergear as duas juntas, que é exatamente o incidente que o fatiamento existe para evitar.feat(multiplayer): sinal de sala por broadcast + releitura server-side (