Repository navigation
Description
O problema — a #131 fechou duas das cinco portas
A #131 estabeleceu a doutrina certa para as
transições de sala: a guarda vai dentro do UPDATE, não numa leitura anterior — quem decide se
a transição vale é o banco. O padrão ficou pronto e testado (RoomUpdateSpec + o parâmetro
guard de applyRoomUpdate), mas foi aplicado a duas actions. Levantando a tabela inteira
em origin/main@eee4b62:
| action | colunas escritas | guarda na escrita | checagem só lida |
|---|---|---|---|
settings |
language, difficulty, max_players |
❌ nenhuma (route.ts:142-146) |
room.status !== "lobby" (route.ts:123-124) |
start |
status, snippet, start_at, results |
✅ .match({status:"lobby"}) |
— |
finish |
status, results |
✅ .eq("status","racing") |
— |
kick |
kicked_ids |
❌ nenhuma (route.ts:226-235) |
— |
reset |
status, start_at, results |
✅ .match({status:"finished"}) |
— |
claim-leader |
leader_id |
❌ (é a #6, decisao-dono — não tocar) |
— |
Sobraram exatamente as duas que dá para fechar sem depender de decisão do dono.
(a) settings — TOCTOU real, e o produto atravessa essa janela sozinho
route.ts:113 faz o SELECT, route.ts:123 decide com base nele, e route.ts:142 escreve
sem levar a condição junto. Entre a leitura e a escrita a sala pode ter virado racing.
Isso não é um vetor só de curl. O caminho legítimo do produto já dispara os dois POSTs em
sequência: a votação de linguagem (#72) faz settings e depois start como duas requisições
separadas (useRoom.ts:659-672). E o PR Doctor registrou no Diário (05/08, PR #133) que nem
Lobby nem Results têm lock de in-flight — duplo clique em "Iniciar partida" dispara duas
sequências settings→start concorrentes. Basta o segundo settings chegar depois do primeiro
start para escrever language numa sala já em racing.
Por que isso corrompe o registro histórico: o snippet é sorteado no start
(route.ts:158), mas buildMatchRow e buildScoreRows (room.ts:398-428) gravam
room.language / room.difficulty lidos no finish — não os do start. Uma sala que
correu Python/easy e sofreu um settings no meio entra em matches/scores como Rust/hard.
O jogador digitou um snippet fácil e o recorde dele entra no bucket difícil do ranking — que é
precisamente o eixo que a #92 vai transformar
em filtro de primeira classe.
(b) kick — escrita não confirmada (a classe do #56 que o applyRoomUpdate fechou)
route.ts:226-235 é o único UPDATE da rota que não passa por applyRoomUpdate: confere
kickErr, mas nunca rows. Zero linhas afetadas (sala apagada entre route.ts:113 e o UPDATE)
devolve { ok: true } — o líder vê "expulso" e ninguém foi expulso. É exatamente o "0 linhas
afetadas" que a doc do applyRoomUpdate (route.ts:26-37) diz cobrir, e do qual o kick ficou
de fora.
Acceptance criteria
-
settingsescreve sob guarda na própria escrita (status = lobbydentro do UPDATE),
viaRoomUpdateSpec/applyRoomUpdate— no mesmo desenho destartRaceSpec
(room.ts:669-675) eresetToLobbySpec(room.ts:694-700). A regra é pura e vive em
src/lib/room.ts, testada; a rota só faz I/O. - A checagem lida de
route.ts:123-124pode continuar (falha rápido e com copy melhor), mas
não é mais a única defesa. O 409 continua sendo o mesmo contrato ("Partida já começou"). -
kickpassa porapplyRoomUpdate: zero linhas confirmadas vira falha honesta em vez de
ok: true. Manter os dois erros específicos que já existem (400alvo inválido,409
lista cheia,503migração pendente) — o novo desfecho é só o "não confirmado". - Teste do ponto de imposição (buraco apontado pelas 3 lentes do quórum da PR fix(security):
startsó transiciona a partir delobby— POST não reinicia corrida em andamento (#131) #133 e
registrado no Diário em 05/08): hoje trocar oif (guard) q = q.match(guard.match)de
route.ts:47-48por no-op desfaz as guardas destarteresetcom o suíte 100% verde
etsclimpo. Um teste deve falhar nessa mutação. É a AC que dá valor real ao resto —
sem ela a guarda nova nasce igualmente indefesa. -
pnpm typecheck+pnpm test+pnpm build+node scripts/validate-persistence.mjs
verdes (pnpm testnão é opcional: a CI o roda e o HANDBOOK §6 ainda não o lista).
Dica de abordagem
Duas specs novas em src/lib/room.ts no molde das existentes — algo como
settingsUpdateSpec(patch) (match: { status: "lobby" }, conflito "Partida já começou") e
kickUpdateSpec(kicked) (sem match, só para ganhar a confirmação de linhas). O conflict de
roomUpdateOutcome (room.ts:623-641) já distingue "guarda não bateu" de "não confirmado",
então a copy não precisa de invenção nova.
Para o teste do ponto de imposição, o alvo é o q.match(...) da rota, não a spec pura: um dublê
de sb que registre se o .match() foi chamado com o objeto da spec (o padrão do dublê já
existe desde a #112, em persistMatch).
Escopo e classificação
- §7.2 — área de quórum (
src/app/api/rooms/**): PR non-draft com a linha exata
Solicito quórum (HANDBOOK §7). - Fora de escopo:
claim-leader(é a Segurança: claim-leader permite sequestro de liderança de qualquer sala (sem autorização) #6,decisao-dono); o lock de in-flight nos botões do
Lobby/Results(é UI e merece issue própria — esta fecha o lado do servidor, que é o que
tem autoridade); e a Leaderboard/Integridade: ranking sem filtros premia o bucket mais fácil — filtros por linguagem/dificuldade/período (§III.8) #92, que só amplifica a consequência.
Verificado em origin/main@eee4b62. Sem duplicata (gh issue list --search por
settings/TOCTOU/guarda/status e por bucket de leaderboard: só a #92, que é outro assunto).
Activity