Skip to content

feat(multiplayer): sinal de sala por broadcast + releitura server-side (#109) - #110

Merged
caioross merged 3 commits into
mainfrom
auto/issue-109-room-broadcast
Jul 30, 2026
Merged

feat(multiplayer): sinal de sala por broadcast + releitura server-side (#109)#110
caioross merged 3 commits into
mainfrom
auto/issue-109-room-broadcast

Conversation

@caioross

Copy link
Copy Markdown
Owner

Fatia 1 de 3 da #103 — exatamente a direção E do 🏛️ Parecer do Conselho de 28/07, sem divergências. Segui inclusive o que o parecer pede para NÃO fazer: nada de enxugar o payload, nada de revoke.

Contexto

A única coisa que o grant select on rooms to anon sustenta hoje é a subscription postgres_changes do cliente (useRoom.ts:248) — estado inicial e resync já vêm da API server-side. Esta PR liga a fonte NOVA (broadcast autoritativo do servidor) em paralelo à antiga. Apertar a policy no mesmo deploy que troca o cliente congelaria toda sala privada viva, e merge na main é deploy; por isso a fatia 1 não fecha nada — ela torna o fechamento (fatia 2) seguro.

O que mudou

Servidorsrc/app/api/rooms/[code]/route.ts

  • Depois de cada update confirmado (settings, start, finish, kick, reset, claim-leader), publica a linha nova como event: "room" no canal que os membros já assinam (coderacer:room:<CODE>). Nunca antes nem em paralelo à escrita: um broadcast que se adiantasse a um update que falhou espalharia estado inexistente.
  • applyRoomUpdate passa a devolver a linha (select("*") no lugar de select("code")). É a mesma linha que o postgres_changes já entrega a todo assinante (replica identity full), então não expõe nada novo. Em finish, só o cliente que efetivamente flipou racing→finished anuncia.
  • broadcastRealtime (src/lib/supabase.ts): POST /realtime/v1/api/broadcast com timeout de 3s e retorno boolean. Escolhi o endpoint REST em vez de abrir canal/socket na rota serverless — sem handshake, sem unsubscribe no caminho quente da resposta e sem acumular um canal por código de sala no cliente singleton. Falhar aqui é inofensivo por construção.

Clientesrc/lib/useRoom.ts

  • Broadcast e postgres_changes caem no mesmo applyRoomRow; a subscription antiga continua ligada.
  • Dedupe por updated_at (trigger rooms_touch da 0002 renova o carimbo a cada UPDATE): o mesmo estado vindo pelas duas fontes é aplicado uma vez, e uma linha atrasada não reverte estado nem reanuncia expulsão.
  • SUBSCRIBED dispara um resync pela API — broadcast é at-most-once e sem replay, isso fecha a janela entre assinar e o primeiro evento. Inerte quando nada mudou (passa pelo mesmo dedupe).
  • O seed inicial passa a registrar o carimbo além dos kicks já anunciados.

Núcleo purosrc/lib/room.ts: reduceRoomSync (dedupe + minha expulsão + o que anunciar) e isNewerRoomState. O handler do cliente ficou só com efeitos. É o mesmo comportamento de antes, agora testável sem sala real.

Gate (resultado real, neste worktree)

Passo Resultado
pnpm install --frozen-lockfile
pnpm typecheck
pnpm test (vitest) 158 testes, 6 arquivos — 16 novos em room.test.ts
pnpm build
node scripts/validate-persistence.mjs ✅ 72/72
validate-metrics.mjs ✅ 45/45

Lint: N/A — não há config de ESLint no repo (a CI roda typecheck + test + build) e não vou adicionar uma só para satisfazer o gate.

Os 16 testes novos cobrem: carimbo igual/anterior/posterior, formatos de timestamp diferentes entre as duas fontes, fail-open com carimbo ilegível, mesmo estado pelas duas fontes aplicado uma vez, aviso de expulsão não duplicado com a linha chegando duas vezes, expulsão anunciada só uma vez apesar de kicked_ids ser durável, linha atrasada que não reverte, minha própria expulsão, reset liberando anúncios da próxima rodada, payload malformado e kicked_ids ausente (0005 não aplicada).

Acceptance criteria

  • Broadcast em toda mutação, sempre depois do update confirmado
  • Broadcast e postgres_changes no mesmo handler, as duas fontes ligadas
  • Dedupe por updated_at provado por teste da função pura, incluindo o não-duplicar do aviso de expulsão
  • SUBSCRIBED dispara resync
  • Nada quebra se o broadcast falhar: a fonte antiga continua ligada e entra pelo mesmo funil (as duas passam por reduceRoomSync, que é agnóstico de fonte)
  • Zero mudanças em migration, policy ou grant — nenhum revoke/drop policy no diff

Riscos e o que NÃO foi provado aqui

  • Não verifiquei end-to-end. O caminho novo exige projeto Supabase real + 2 clientes na mesma sala (cr-fleet-ops §11); a preview do agente não prova isso. A mitigação é de desenho, não de teste: a fonte antiga continua ligada, então mesmo que o broadcast nunca chegue em produção o comportamento é o de hoje. A fatia 2 não deve ser aberta antes de alguém confirmar em produção que o broadcast chega — é justamente o que ela vai desligar.
  • Dedupe por updated_at assume carimbos crescentes por UPDATE (garantido pelo trigger, transações separadas por request). Dois updates no mesmo microssegundo empatariam o carimbo e o segundo seria descartado; com round-trip HTTP entre eles, é implausível.
  • latency da corrida: intocada. Progresso por tecla continua em broadcast puro e nada disso roda durante a digitação — as mutações são raras (settings/start/finish/kick/reset).
  • Residual de segurança (do parecer, repetido aqui de propósito): isto não transforma "entrar na sala" em autorização. Quem sabe o código continua entrando e assinando o canal. A fatia 1+2 muda o modelo de ameaça de "listo todas as salas" para "adivinho um código de 6 chars em alfabeto de 32"; autorização real é a fatia 3 (discussion #19).

Solicito quórum (HANDBOOK §7)

Closes #109
Refs #103

…gres_changes (#109)

Fatia 1 de 3 da #103 (parecer do Conselho, direção E): liga a fonte NOVA de
estado da sala sem desligar a antiga e sem tocar em policy/grant, para que o
corte da leitura anônima (fatia 2) não congele nenhuma sala viva no deploy.

- Servidor (api/rooms/[code]): depois de cada `update` CONFIRMADO (settings,
  start, finish, kick, reset, claim-leader), publica a linha nova no canal que
  os membros já assinam (`coderacer:room:<CODE>`), evento `room`.
  `applyRoomUpdate` passa a devolver a linha (`select("*")` no lugar de
  `select("code")`) — a mesma linha que o `postgres_changes` já entrega.
- `broadcastRealtime` (lib/supabase): POST no endpoint REST do Realtime, com
  timeout e best-effort — sem websocket na rota serverless.
- Cliente (useRoom): as duas fontes caem no MESMO handler, com dedupe por
  `updated_at`; `SUBSCRIBED` dispara um resync (broadcast é at-most-once).
- `reduceRoomSync`/`isNewerRoomState` (lib/room): núcleo puro do sync — dedupe,
  minha expulsão e quais expulsões anunciar. 16 casos novos em room.test.ts,
  incluindo o aviso de expulsão que não pode duplicar com duas fontes.

Nenhuma mudança de migration, policy ou grant.

Closes #109
Refs #103

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 29, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
code-racer Ready Ready Preview Jul 30, 2026 3:38am

@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

…109)

O canal `coderacer:room:<CODE>` é público (sem `private: true` e sem RLS em
`realtime.messages`): qualquer cliente com a anon key emite `event:"room"` nele.
Aplicar o payload como linha da sala reabria exatamente o que a migration 0005
fechou no #39 ("expulsão nunca por broadcast") — um jogador forjaria
`kicked_ids` para derrubar qualquer um (inclusive o líder), forjaria
`status`/`snippet`/`results`/`leader_id`, ou congelaria a sala para todos com um
`updated_at` no futuro, que o dedupe passaria a tratar como "mais novo".

- `useRoom.ts`: o handler de `event:"room"` ignora o payload e só agenda um
  resync pela API server-side, coalescido (`RESYNC_MIN_INTERVAL_MS`) para que um
  flood barato de mensagens não vire flood de GETs vezes o nº de membros.
- `route.ts`: `broadcastRoom` publica só o sinal (`code` + `updated_at`), nunca
  a linha.
- `applyRoomRow` passa a ser chamado apenas com linha do `postgres_changes` ou
  da API — o dedupe por `updated_at` segue valendo entre essas duas fontes.

O objetivo da fatia 1 é preservado: quando a fatia 2 cortar a leitura anônima,
o par sinal + releitura server-side continua entregando o estado.

Gate: typecheck ✅ · test ✅ 158 · build ✅ · validate-persistence 72/72 ✅ ·
validate-metrics 45/45 ✅

Refs #109
@caioross

Copy link
Copy Markdown
Owner Author

🩺 Parecer do PR Doctor — quórum §7.2 em duas rodadas

1ª rodada (head 527fd72) — 2 VETOS com vetor concreto.

O canal coderacer:room:<CODE> é público: useRoom.ts:181 cria o channel sem private: true, e não existe migration habilitando Realtime Authorization (grep realtime.messages supabase/ → zero). Qualquer cliente com a NEXT_PUBLIC_SUPABASE_ANON_KEY — que está no bundle — emite event:"room" nesse tópico. Aplicar esse payload como linha da sala (useRoom.ts:259applyRoomRow) dava autoridade de servidor a uma mensagem de peer:

  1. Regressão do Anti-cheat/Multiplayer: kick é broadcast sem autoridade — qualquer jogador expulsa qualquer um (inclusive o líder) #39. {code, updated_at: <futuro>, kicked_ids:["<id-da-vítima>"]} derrubava qualquer jogador (os ids estão na presence), inclusive o líder, contornando o isLeader da rota. É textualmente o que supabase/migrations/0005_room_kicked_ids.sql:4-9 fechou: "a vítima descobre a remoção pela subscription postgres_changes — nunca por broadcast".
  2. Estado forjado. status/snippet/start_at/results/leader_id arbitrários no editor de todo mundo.
  3. DoS permanente da sala. updated_at: "2999-01-01" envenenava roomSyncRef.stamp; a partir daí isNewerRoomState (room.ts:498) descartava toda linha legítima — inclusive o resyncRoom. Um pacote matava a sala até o reload.

A lente de Domínio havia aprovado (trigger rooms_touch cobre todo UPDATE, seed e finish corretos, área sagrada intocada) — o veto foi de autenticidade, não de corretude.

Reparo aplicado pelo PR Doctor (b763289 + 9c2966a), sem mudar o objetivo da fatia 1:

  • useRoom.ts — o handler de event:"room" ignora o payload e só agenda uma releitura server-side (GET /api/rooms/<code>), coalescida por RESYNC_MIN_INTERVAL_MS = 800 para que um flood barato de mensagens não vire flood de GETs × nº de membros. applyRoomRow passa a ter exatamente dois chamadores: postgres_changes e o resync.
  • route.tsbroadcastRoom publica só o sinal (code + updated_at), nunca a linha.

A fatia 2 continua viável: cortada a leitura anônima, o par sinal + releitura server-side entrega o mesmo estado (o GET já faz select("*") da mesma linha). O start não sofre com os 800ms — a largada é derivada do relógio absoluto start_at (useRoom.ts:412), com COUNTDOWN_MS = 4000 de margem.

2ª rodada (head 9c2966a) — 3× APROVA.

Lente Veredito Evidência
AppSec APROVA Vetor fechado na raiz: nenhum caminho leva dado do canal a setRoom/kicked_ids/leader_id. src/lib/supabase.ts importado só por código server (2 rotas + leaderboard/page.tsx, RSC) → SUPABASE_SERVICE_ROLE_KEY não cruza para o bundle; sem SSRF no broadcastRealtime; {code, updated_at} não expõe nada num canal público; respostas POST seguem {ok:true}.
Ofensiva APROVA Os 3 vetores fechados. Throttle segura: lastResyncAt é gravado antes do await (useRoom.ts:351), sem recursão e sem vazamento de timer (clearTimeout no cleanup). Amplificação ≤1 GET/800ms por cliente, abaixo do que o atacante já consegue batendo na rota direto. Carimbo futuro não tem mais como ser plantado.
Domínio APROVA Nenhum sinal se perde de forma permanente: um sinal que chega durante o fetch reagenda (resyncTimer já é null), e o servidor só sinaliza depois do update confirmado, então o fetch pendente lê a mutação. reset (2 UPDATEs) resolve no estado final. Gate real: typecheck ✅ · 158/158 testes ✅ · build ✅ · validate-persistence 72/72 ✅ · validate-metrics 45/45 ✅.

Ressalvas registradas, não bloqueantes desta PR:

  • Pré-existente e sério (issue separada): o broadcast event:"progress" é forjável com o id de terceiros (useRoom.ts:236) e alimenta o maybeFinish do líder — dá para encerrar corrida alheia. Não é regressão da feat(multiplayer): sinal de sala por broadcast + releitura server-side (#109) #110; nasceu com o design de progresso e não muda aqui.
  • Fatia 2: sem postgres_changes, o "X saiu" adiado em 400ms (useRoom.ts:228) pode duplicar com o "removido" que chega em até 800ms; e um sinal perdido com conexão viva fica sem rede de segurança (nenhum poll periódico). Ambos são trabalho da fatia 2.

O corpo original descreve o desenho da 1ª rodada ("broadcast autoritativo" aplicado no cliente) — o reparo o supera nos pontos acima; o restante (contexto, gate, critérios) segue válido. Título ajustado para refletir o mecanismo final.

Mergeando (3× APROVA, §7.2).

@caioross caioross changed the title feat(multiplayer): broadcast autoritativo de sala em paralelo ao postgres_changes (#109) feat(multiplayer): sinal de sala por broadcast + releitura server-side (#109) Jul 30, 2026
@caioross
caioross merged commit 02b0f45 into main Jul 30, 2026
3 checks passed
@caioross
caioross deleted the auto/issue-109-room-broadcast branch July 30, 2026 03:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

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

1 participant