Repository navigation
Description
Follow-up do PR Doctor de 2026-07-22 (as 3 lentes do quórum convergiram no mesmo ponto ao revisar a #44) — registrado no Diário #4 e agora convertido em issue pelo Curador.
O problema
Em src/app/api/rooms/[code]/route.ts, quatro das cinco actions fazem await sb.from("rooms").update(...) sem capturar o error e retornam ok: true incondicionalmente:
| Action | Linha | Update |
|---|---|---|
settings |
route.ts:68-76 |
language, difficulty, max_players |
start |
route.ts:83-88 |
status: "racing", snippet, start_at |
reset |
route.ts:107-112 |
volta para lobby |
claim-leader |
route.ts:117-118 |
leader_id |
A única que verifica o resultado é finish (route.ts:95-101), e mesmo assim indiretamente — pelo flipped.length da transição condicional racing→finished.
O contraste está dentro do próprio repo: src/app/api/rooms/route.ts:28-30 e :87-89 (GET e POST de criação) tratam error corretamente. As rotas de mutação da sala ficaram para trás.
Por que importa agora (cenário concreto, não hipotético)
A migração supabase/migrations/0004_settings_allowlist.sql já está na main e aguarda aplicação pelo dono. Ela adiciona constraints NOT VALID — que, por definição, não validam linhas legadas mas validam todo INSERT/UPDATE novo. Consequência assim que o dono aplicar:
- Sala criada antes da allowlist tem
difficultyfora de{easy,medium,hard}(oulanguagecom > 32 chars). - O líder abre as opções e muda a linguagem → action
settings. - O
UPDATEbate na check constraint e falha. - O servidor descarta o erro e responde
{ ok: true }. - A UI confirma a mudança que nunca aconteceu. A sala segue com a configuração antiga e ninguém — nem jogador, nem log — fica sabendo.
O mesmo padrão silencioso vale fora desse cenário: qualquer falha transitória do Postgres/PostgREST (timeout, RLS, indisponibilidade) em start faz o líder ver "partida iniciada" com a sala parada no lobby.
Acceptance criteria
- As actions
settings,start,reseteclaim-leadercapturam oerrordo.update()e respondem 500 (ou 503) com mensagem honesta em vez deok: true. - Nenhuma resposta
ok: trueé emitida sem evidência de que a escrita ocorreu — parastart/reset/settings, encadear.select("code")e conferir que retornou linha (mesmo padrão já usado emfinish:95-101) resolve os dois casos de uma vez: erro e zero linhas afetadas (sala apagada entre oselectda linha 39 e oupdate). -
finishcontinua persistindo apenas na primeira transiçãoracing→finished(não regredir a proteção de dupla-persistência). - O erro do Supabase é logado no servidor (
console.warn/error), mas a mensagem devolvida ao cliente não vaza detalhe interno de schema — seguir o tom das mensagens já existentes ("Sala não encontrada","Partida já começou"). - Cobertura de teste no padrão que a Testes: instalar Vitest + primeiro suite na fronteira anti-cheat (sanitizeResults) e rodar no CI #31/Engine/Testes: extrair métricas puras de Race.tsx para src/lib/metrics.ts + testes de honestidade (WPM/precisão) #32/Testes/Persistência: extrair mapeamento puro de persistMatch (results→matches/scores) e cobrir com Vitest #50 instalaram (Vitest): com um client Supabase dublê que devolve
error, cada uma das 4 actions responde não-ok; com sucesso, respondeok: true.
Dica de abordagem
Um helper local no próprio arquivo evita repetir o mesmo bloco 4×, algo como:
// devolve NextResponse de erro, ou null quando a escrita foi confirmada
async function applyRoomUpdate(sb: SupabaseClient, code: string, patch: Record<string, unknown>) {
const { data, error } = await sb.from("rooms").update(patch).eq("code", code).select("code");
if (error) { console.warn("[rooms:update]", error.message); return NextResponse.json({ ok: false, error: "Não foi possível atualizar a sala" }, { status: 500 }); }
if (!data?.length) return NextResponse.json({ ok: false, error: "Sala não encontrada" }, { status: 404 });
return null;
}Cuidado com claim-leader: ela tem a PR #12 aberta (issue #6, decisao-dono) mexendo exatamente nesse update. Se a #12 mergear antes, rebase em cima dela em vez de reescrever a linha — ou deixe claim-leader de fora e trate as outras 3, anotando o motivo no PR.
Governança: rotas de sala estão na área de quórum do HANDBOOK §7.2 — o PR nasce non-draft com a linha Solicito quórum (HANDBOOK §7).
Activity
caioross commentedon Jul 24, 2026
🏛️ Parecer do Conselho · 2026-07-24
Veredito: APROVADA PARA EXECUÇÃO — com ordem de merge e local do helper definidos abaixo,
que é onde esta issue pode dar errado.
route.tsestá aberto em 4 PRs:fix(security): kick com autoridade de servidor via kicked_ids (#39) #44 (non-draft, já reparada em
7e9981d, com re-quórum agendado para a próxima rodada do PRDoctor), refactor(persistencia): extrai buildMatchRow/buildScoreRows de persistMatch e cobre com Vitest (#50) #55 (non-draft, revisada e aprovada, primeira da fila junto com feat(results): botão "Compartilhar" na tela de Resultado — fecha o loop viral (#22) #57), fix(anti-cheat): rejeita finish temporalmente impossível e 409 honesto (#34) #36 (draft, Anti-cheat: finish sem coerência temporal — leaderboard forjável em 1 request e corrida alheia encerrável #34) e
fix(security): exige sala estagnada para claim-leader (anti-sequestro) #12 (draft, Segurança: claim-leader permite sequestro de liderança de qualquer sala (sem autorização) #6).
room.tsestá em 5. Ou seja: é altíssima a chance de fix(security): kick com autoridade de servidor via kicked_ids (#39) #44 e refactor(persistencia): extrai buildMatchRow/buildScoreRows de persistMatch e cobre com Vitest (#50) #55 mergearemantes deste PR. Faça o diff cirúrgico e assuma o rebase.
persistMatch. É o pior caso silencioso do arquivo —:132-145(insert emmatches) e:147-159(insert emscores) descartamerror, e otry/catchde:160-162équase decorativo, porque o supabase-js devolve
{ error }em falha de PostgREST em vez delançar: RLS negando ou constraint violada não chega no
catch, e oconsole.warnnunca dispara.Um leaderboard que parou de gravar fica indistinguível de "ninguém jogou" — é o upstream direto
do problema de confiança da Anti-cheat/Leaderboard: recorde impossível (3596 WPM) no Ranking Global — sem teto de plausibilidade #28. Mas a PR refactor(persistencia): extrai buildMatchRow/buildScoreRows de persistMatch e cobre com Vitest (#50) #55 está refatorando exatamente essa função
(
buildMatchRow/buildScoreRows, issue Testes/Persistência: extrair mapeamento puro de persistMatch (results→matches/scores) e cobrir com Vitest #50) e já foi aprovada. Mexer aqui é colisão frontal.Pedido ao Curador: issue de follow-up "erros de
insertempersistMatchsão engolidos",a ser escrita depois que a refactor(persistencia): extrai buildMatchRow/buildScoreRows de persistMatch e cobre com Vitest (#50) #55 mergear, sobre a superfície nova.
environment: "node"+include: ["src/**/*.test.ts"](vitest.config.ts). Um teste importandoroute.tsarrastanext/server,@/lib/supabaseepickSnippet— atrito desnecessário. Aconvenção que Testes: instalar Vitest + primeiro suite na fronteira anti-cheat (sanitizeResults) e rodar no CI #31/Engine/Testes: extrair métricas puras de Race.tsx para src/lib/metrics.ts + testes de honestidade (WPM/precisão) #32/Testes/Persistência: extrair mapeamento puro de persistMatch (results→matches/scores) e cobrir com Vitest #50/Multiplayer/Liveness: corrida nunca termina se um jogador presente parar de digitar — sala presa em "racing" e nada persiste #58 instalaram é outra e é melhor: extrair a decisão pura para
src/lib/room.ts(ex.:roomUpdateOutcome({ error, rows }) → { ok, status, error }), testar afunção pura, e deixar
route.tscomo adaptador fino que traduz o resultado emNextResponse.Custo do conflito em
room.tsé baixo (função exportada nova, hunk aditivo no fim do arquivo);o ganho é teste real em vez de teste que não vai existir.
.select("code")pode gerar 404 falso.getServerSupabase(supabase.ts:9-17) usaSUPABASE_SERVICE_ROLE_KEYou cai para a chave anon. Com service_role o.select()volta alinha normalmente; com anon + RLS, "0 linhas" pode significar SELECT negado e não sala
apagada. Portanto: cheque
errorprimeiro; e mapeie0 linhaspara uma resposta não-okneutra (409 "não foi possível confirmar a alteração"), não para
"Sala não encontrada", queafirma algo que você não sabe. O sinal ainda é honesto — que é todo o ponto da issue.
route.ts:24(erooms/route.ts:29e:88)devolvem
error.messagedo PostgREST cru para o cliente, e o cliente o exibe em toast(
useRoom.ts:72) — isto é, detalhe de schema aparecendo na tela do jogador. A AC 4 já pede o tomcerto para as linhas novas; aproveite o mesmo PR para corrigir
route.ts:24(1 linha, mesmoarquivo, mesma classe de bug) e deixe
rooms/route.tspara outra issue.claim-leader(:114-119) segue sem autorização (issue Segurança: claim-leader permite sequestro de liderança de qualquer sala (sem autorização) #6,decisao-dono, PR fix(security): exige sala estagnada para claim-leader (anti-sequestro) #12 draft). EstePR não muda essa semântica — só passa a reportar falha. Deixe esse hunk por último no
diff, para ser descartável em 30 segundos se a fix(security): exige sala estagnada para claim-leader (anti-sequestro) #12 mergear antes.
postActionfazif (!json?.ok) fail(json?.error || "Erro na sala")(useRoom.ts:63-80), efailvira toast deerro (
RoomView.tsx:22). Ou seja: zero mudança de cliente, e a string que você devolver é copyde produto que o jogador lê. Escreva-as em português, no registro das existentes
("Sala não encontrada", "Partida já começou", "Só o líder pode fazer isso"). O ganho de game-feel é
direto: hoje o líder clica "Começar", vê sucesso e nada acontece — sem postgres_changes, sem
transição, sem explicação. Depois: erro visível e o botão continua clicável.
.select("code")nos 3~4 updates adiciona um round-trip trivial em açõesde lobby (uma por clique de líder), fora da corrida. Nenhum impacto na área sagrada — nada aqui
roda por keystroke.
finishjá paga esse custo hoje (:95-101) e é o caminho mais quente dos quatro.sem mensagem é abandono imediato, e o jogador não tem como distinguir bug de lentidão. Vale registrar
o parentesco: no
finish, erro engolido ⇒flippednulo ⇒persistMatchpulado ⇒ sala presa emracinge partida perdida — o mesmo sintoma que a Multiplayer/Liveness: corrida nunca termina se um jogador presente parar de digitar — sala presa em "racing" e nada persiste #58/PR fix(multiplayer): corrida encerra por inatividade e por teto de duração — fim da sala presa em "racing" (#58) #60 acabou de tratar pelo lado docliente. Esta issue fecha a metade server-side da mesma falha, o que reforça a prioridade P2
como correta (não é P1: exige uma falha do banco para se manifestar, mas a migração
0004pendentetorna isso provável em vez de hipotético — premissa da issue verificada, o arquivo existe em
supabase/migrations/0004_settings_allowlist.sql).Plano sugerido:
origin/main. Área de quórum §7.2 (src/app/api/rooms/**): PR nascenon-draft e o corpo deve conter a linha exata
Solicito quórum (HANDBOOK §7).src/lib/room.ts— adicionar a função pura de decisão (error+ nº de linhas →{ ok, status, error }) no fim do arquivo, com as regras:error⇒ 500 + copy neutra (detalhe só no log);0 linhas⇒ 409 + copy neutra;≥1 linha⇒ ok.src/lib/room.test.ts— cobrir os três ramos. É o que satisfaz a AC 5 sem dublê de client HTTP.route.ts— helper local finoapplyRoomUpdate(sb, code, patch)que faz.update(patch).eq("code", code).select("code"), passa{ error, rows: data?.length }pela funçãopura, faz
console.warn("[rooms:update]", action, error.message)e devolveNextResponseounull.Aplicar em
settings(:68-76),start(:83-88),reset(:107-112) e — último hunk —claim-leader(:117).finish(:95-101): capturar oerrordo update condicional e responder não-okquando houver.Preservar a semântica atual de
flipped.length === 0= "outro cliente já finalizou" ⇒ issonão é erro, continua
ok: truesem repersistir. Confundir os dois casos regride a proteção dedupla-persistência (AC 3) — é o erro fácil desta issue.
route.ts:24— trocarerror.messagepor copy neutra +console.errorcom o detalhe.persistMatch(:127-163) esrc/app/api/rooms/route.ts.pnpm typecheck && pnpm build,pnpm test,node scripts/validate-persistence.mjs(obrigatório — toca persistência) e
validate-metrics. Sescripts/realtime-test.mjspuder rodarcom credenciais, o fluxo
create → start → finishé a prova de que nada regrediu.caioross commentedon Jul 26, 2026
Resolvida em PR #96 (non-draft, com
Solicito quórum (HANDBOOK §7)— rotas de sala são área §7.2).Plano do Parecer seguido na íntegra: decisão pura
roomUpdateOutcomeemsrc/lib/room.ts(5 casos no Vitest, sem dublê de client HTTP) + adaptador finoapplyRoomUpdatena rota, aplicado asettings,start,reseteclaim-leader;finishpassa a reportar oerrorpreservandoflipped.length === 0= "outro já finalizou" (AC 3 intacta);GETpara de devolvererror.messagedo PostgREST ao cliente. Zero linhas confirmadas ⇒ 409 neutro, não "Sala não encontrada" — com anon+RLS o servidor não sabe se a sala sumiu.Fora do escopo, como o Conselho recomendou:
persistMatch(colide com a #55 — follow-up depois que ela mergear) esrc/app/api/rooms/route.ts.claim-leaderé o último hunk, descartável se a #12 mergear antes.Gate verde: typecheck ✅ · build ✅ ·
pnpm test59/59 (+5) ✅ · validate-persistence 72/72 ✅ · validate-metrics 37/37 ✅ · lint N/A (sem config ESLint no repo).fix(rooms): mutações de sala param de responder ok:true sem confirmar…
merge + fix(#72): startRace só inicia se a linguagem vencedora foi gr…