Skip to content

Segurança/RLS fatia 1 (#103): broadcast autoritativo de sala em paralelo ao postgres_changes — sem revoke, prepara o corte da leitura anônima #109

Closed

Description

@caioross

Fatia 1 de 3 da epic #103, exatamente como o 🏛️ Parecer do Conselho de 28/07 fatiou (direção E). A #103 é epic e por isso inelegível para claim (§5) — esta issue existe para que o Resolvedor possa pegar o trabalho hoje.

Reverificado contra origin/main @ 82f7817 antes de abrir: git diff c40f0fc..origin/main -- src/lib/useRoom.ts src/app/api/rooms supabase/migrations é vazio. As premissas e todos os números de linha do parecer continuam válidos.

Por que esta fatia não tem revoke

A epic quer matar a enumeração de salas privadas (using (true) + grant select to anon). O caminho é trocar a fonte do sync do cliente antes de fechar a porta.

Apertar a policy no mesmo deploy que troca o cliente congela toda sala privada viva — o jogador vê o lobby parar de atualizar, sem erro. E merge na main é deploy. Então: fatia 1 liga a fonte nova em paralelo (risco ~zero, nada quebra nem se o broadcast falhar), fatia 2 desliga a antiga e aplica drop policy + revoke.

Esta issue não fecha o furo de segurança — ela é o pré-requisito que torna o fechamento seguro. A #103 continua aberta até a fatia 2.

Por que é barato (a medição que mudou tudo)

O cliente nunca faz SELECT em rooms. Estado inicial e resync vêm da API server-side com service-role (useRoom.ts:91 e :150). A única coisa que o grant select to anon sustenta é a subscription postgres_changes de useRoom.ts:248-281 — e o corpo daquele handler já é uma função pura de linha nova → setRoom, reaproveitável inteira.

O trabalho

Servidor — src/app/api/rooms/[code]/route.ts: após cada update bem-sucedido (settings, start, finish, kick, reset), emitir broadcast event: "room" no canal que os membros já assinam: coderacer:room:${code} (useRoom.ts:173). Mande a linha inteira, para o handler do cliente não precisar mudar. (Enxugar o payload é otimização de depois — o parecer pede explicitamente para não fazer agora.)

Cliente — src/lib/useRoom.ts: adicionar channel.on("broadcast", { event: "room" }, …) chamando o mesmo handler de :251-280 (extraia-o para uma função nomeada e use nos dois), mantendo o postgres_changes ligado. Dedupe por updated_at para não processar o mesmo estado duas vezes — cuidado com o efeito colateral do handler: announcedKicksRef (:286-296) empurra mensagem de sistema no chat, e processar o mesmo update duas vezes não pode duplicar o aviso de expulsão.

Acrescentar um fetch de resync no SUBSCRIBED (:283-293) — broadcast é at-most-once, sem replay, e isso fecha a janela entre assinar e o primeiro evento.

Acceptance criteria

  • Toda mutação de sala na API emite broadcast event:"room" com a linha nova, depois do update ter dado certo (não antes, não em paralelo).
  • O cliente processa broadcast e postgres_changes no mesmo handler; as duas fontes ligadas ao mesmo tempo.
  • Dedupe por updated_at: o mesmo estado chegando pelas duas fontes é processado uma vez. Provado por teste da função pura de dedupe — em especial que não duplica a mensagem de "foi removido da sala pelo líder".
  • SUBSCRIBED dispara um fetch de resync.
  • Nada quebra se o broadcast falhar ou não chegar: com a fonte nova desligada à força, o fluxo lobby→countdown→racing→finished continua funcionando pelo postgres_changes.
  • Nenhuma mudança em migration, policy ou grant nesta PR. Se aparecer revoke ou drop policy no diff, a fatia está errada.
  • Gate: pnpm typecheck + pnpm build + vitest + node scripts/validate-persistence.mjs.

Classificação

§7.2 — área de quórum (toca src/app/api/rooms/**). PR non-draft contendo a linha exata Solicito quórum (HANDBOOK §7). Não é §7.1: nada aqui afrouxa RLS — a direção endurece, e esta fatia sequer toca em policy.

Residual honesto (não prometa mais que isto)

Nem esta fatia nem a 2 transformam "entrar na sala" em autorização de verdade: quem souber o código continua entrando e assinando o canal, porque broadcast não é autorizado por RLS. O modelo de ameaça sai de "listo todas as salas e entro onde quiser" para "preciso adivinhar um código de 6 chars em alfabeto de 32" (~1,07×10⁹). Autorização real é a fatia 3 (signInAnonymously + Realtime Authorization), que depende de config de projeto do dono e tem casa aberta na discussion #19.

Activity

  1. added
    P1Alto valor — próximo da fila
    area:multiplayerSalas, Realtime, presença, leaderboard, persistência
    area:securityRLS, service_role, validação de entrada, segredos
    em-resolucaoClaim: um Resolvedor está trabalhando nisto
    and removed
    em-resolucaoClaim: um Resolvedor está trabalhando nisto
    on Jul 29, 2026
  2. caioross commented on Jul 29, 2026

    @caioross
    OwnerAuthor

    🔧 Resolvedor · 2026-07-29 — PR #110

    Fatia 1 implementada seguindo o plano do Conselho sem divergir: broadcast event:"room" com a linha inteira depois de cada update confirmado em api/rooms/[code] (incluindo claim-leader), cliente com as duas fontes ligadas no mesmo handler, dedupe por updated_at e resync no SUBSCRIBED. Zero mudança de migration/policy/grant — nenhum revoke no diff.

    O núcleo do handler virou função pura (reduceRoomSync em lib/room.ts), então o dedupe e o não-duplicar do aviso de expulsão estão cobertos por teste de verdade, não por espelho. Gate verde: typecheck ✅ · vitest ✅ 158 testes (16 novos) · build ✅ · validate-persistence ✅ 72 · validate-metrics ✅ 45. Lint = N/A (sem config ESLint no repo).

    Honestidade sobre verificação: o caminho novo exige Supabase real + 2 clientes e não foi provado end-to-end aqui. A rede de segurança é de desenho — a fonte antiga segue ligada. Por isso: não abram a fatia 2 antes de alguém confirmar em produção que o broadcast chega, já que é exatamente a fonte que ela vai desligar.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    P1Alto valor — próximo da filaarea:multiplayerSalas, Realtime, presença, leaderboard, persistênciaarea:securityRLS, service_role, validação de entrada, segredos

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions