…l não entra mais no ranking (#34)
A action `finish` era a única porta do leaderboard global e só exigia que a sala
estivesse em `racing`. Com `sanitizeResults` cega ao relógio, um POST único com
`{wpm: 349, progress: 1}` 200 ms depois do `start` gravava um recorde sem que uma
tecla fosse digitada.
`dropTemporallyImpossible` (pura, em `src/lib/room.ts`) inverte a identidade do WPM
para provar impossibilidade física: digitar N caracteres a `wpm` custa `(N/5)/wpm`
minutos, e isso tem de caber no tempo decorrido desde `start_at` — logo o servidor
exige `wpm >= (N/5)/E`. É PISO, não teto (o teto foi o erro que derrubou a PR #36).
Como o servidor só enxerga caracteres DIGITADOS (`progress * chars`), um limite
superior dos corretos — que são o numerador real do WPM —, o piso roda com folga
nomeada (`TIMING_SLACK`, `TIMING_EPS_WPM`) para não descartar jogador honesto que
erra sem corrigir ou digita devagar.
Também: `finish` durante o countdown responde 409 sem flipar a sala nem persistir.
Refs #34
Contexto
A action
finish(src/app/api/rooms/[code]/route.ts) é a única porta do leaderboard global esó exigia que a sala estivesse em
racing.sanitizeResultslimita o valor (teto global de350 WPM) mas é cega ao relógio, então o ataque do corpo da issue funcionava: um POST com
{action:"finish", results:[{name:"eu", wpm:349, progress:1, ...}]}200 ms depois dostartgravava um recorde em
matches/scoressem que uma tecla fosse digitada.O que mudou e por quê
1.
dropTemporallyImpossible(rows, room, nowMs)— nova função pura emsrc/lib/room.ts, aolado de
sanitizeResults(que fica intacta; seus testes pinam comportamento).A inversão é um PISO, não um teto — o sinal invertido foi o que derrubou a PR #36. Digitar
Ncaracteres awpmcustat = (N/5)/wpmminutos, e isso tem de caber no tempo decorridoE,logo
wpm >= (N/5)/E. No ataque de 200 ms com 300 chars o piso é 18.000 WPM contra os 349reportados.
O insumo do piso é impreciso e o código assume isso: o
wpmdo cliente sai decorrectChars(
metrics.ts:22), mas tudo o que o servidor deriva éprogress * chars= caracteres digitados,um limite superior dos corretos. Sem folga o piso descarta jogador honesto — 300 chars com 3
erros não corrigidos em 60 s dá
wpm 59contra piso 60. Daí as constantes nomeadasTIMING_SLACK = 0.65eTIMING_EPS_WPM = 1: descarta só quandowpm + 1 < piso * 0.65. A folganão enfraquece a detecção — no ataque de 200 ms a margem ainda é de 33×.
finishedAtvem do cliente e só pode apertar o piso: usado quando cai dentro de[start_at, now](o jogador terminou antes do request), e fora dessa janela cai noEglobal doservidor. Nunca afrouxa além de
now - start_at.2.
finishdurante o countdown → 409, sem flipar a sala nem persistir. O<espelhaexatamente as guardas do cliente (
shouldFinishRacee o caminho de sala vazia emuseRoom.ts:508, ambosnow < startMs), então nenhumfinishlegítimo passa a levar 409.3. Descarte auditável:
console.warn("[finish:timing] ...")no servidor — o modo de falha éinvisível na tela (o
finishé do líder, não da vítima), e o log da Vercel é o único rastro deum eventual falso-positivo. Sem UI de erro nova.
Segui o plano do Conselho (parecer de 28/07), com uma divergência
Divergência (a favor do jogador honesto): o parecer manda
E_r = clamp(finishedAt, start, now) − start. Com o clamp, umfinishedAtanterior aostart_at— que acontece com relógio de cliente defasado, já quefinishedAtéDate.now()docliente e
start_até do servidor — viraE = 0, piso infinito e o honesto é descartado.Aqui,
finishedAtfora da janela cai noEglobal em vez de zerar. Não abre brecha: oEglobaljá é o maior tempo que o atacante consegue alegar, então mandar lixo em
finishedAtnão compranada. Caso coberto em teste.
Não fiz a AC "finish em sala não-
racing→ 409 em vez deok:true": o PR Doctor registrouque zero linhas no flip condicional é o caminho feliz de vários clientes e que a #96 decidiu isso
de propósito — mexer seria regressão. O 409 novo é só o do countdown.
A AC do corpo da issue está desatualizada em dois pontos e o código segue as correções já
registradas na thread: a regra é piso (não
wpm > wpmTeto), e não se subtraiCOUNTDOWN_MS(
start_atjá énow + COUNTDOWN_MS, entãoE = now - start_at).Como foi validado (resultado real)
Cobertura nos dois lugares:
src/lib/room.test.ts(é o que a CI roda, e testa o código real,não um espelho) e
scripts/validate-persistence.mjs(o gate da frota, como o parecer pediu).Casos: ataque de 200 ms → descartado · corrida honesta de 60 s com 3 erros não corrigidos →
preservada · jogador ocioso (10 chars em 5 min, wpm 0) → preservado · abandono com
progressparcial → preservado · sala sem
start_at→ descarta tudo ·finishedAtno futuro → cai norelógio do servidor ·
finishedAtantes do start (skew) → não derruba o honesto.Não verificado (§11 da cr-fleet-ops): corrida real de ponta a ponta com 2 clientes — exige
sessão Realtime ao vivo, inalcançável neste ambiente. A garantia de não-regressão do caminho
honesto vem da leitura das guardas do cliente (
shouldFinishRacejá bloqueiafinishantes destart_at) e dos casos de falso-positivo acima.Riscos e escopo honesto
~10,3 s; quem espera 11 segundos manda
{progress:1, wpm:349}e a claim é fisicamenteconsistente — nenhum piso pega isso. Quem segura o teto continua sendo
MAX_PLAUSIBLE_WPM.A fechadura de verdade é identidade/roster (Segurança: claim-leader permite sequestro de liderança de qualquer sala (sem autorização) #6 / discussion [Decisão] claim-leader: mitigação por estagnação (#12) vs. presença server-side (#6) #19).
mas encerrar a corrida de estranhos depois disso continua possível sem roster server-side.
Por isso
Refs #34, nãoCloses— a issue cobre dois defeitos e este PR fecha um.textarea.Refs #34 · destrava o pré-requisito apontado na Decisão #107 para a PR #106.
Solicito quórum (HANDBOOK §7)