From 4d56a32ed20f4fb24596f97b6060dac4cbd20ceb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Tue, 28 Jul 2026 11:33:22 -0300 Subject: [PATCH] Adopt B32+'s JSON route shell and fetch helper in the WebAuthn paths. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rebase brought a convention guard that refuses a route.ts exporting POST outside campaignJsonMutationRoute, and it named all four WebAuthn routes. They now come from the shell, so the same-origin check is structural rather than copied, and postJson delegates to postCampaignJson — keeping only what the ceremonies add on top: the envelope's discriminant decides instead of the status, and a non-JSON response reads as one sentence while a network failure still propagates untouched. The two *-options routes have no body, hence campaignWebAuthnNoBodySchema. --- .cursor/rules/projects/nucleos-eleitorais.mdc | 2 +- .../campanha/webauthn/login-options/route.ts | 26 +++++------- .../campanha/webauthn/login/route.ts | 42 +++++++------------ .../webauthn/register-options/route.ts | 26 +++++------- .../campanha/webauthn/register/route.ts | 34 +++++---------- src/lib/campaignWebAuthnClient.ts | 27 ++++++------ src/lib/schemas/campaignWebAuthn.ts | 9 ++++ 7 files changed, 71 insertions(+), 95 deletions(-) diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index afd455e0..5927c673 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -7,7 +7,7 @@ alwaysApply: false ## Status -- **B40 entregue em código (2026-07-28):** login com biometria (WebAuthn) em `/campanha` — enrollment opt-in depois do login com senha (toast dismissível no shell, no molde do `InstallPwaToast`, + seção permanente "Acesso por biometria" em `/campanha/perfil`), botão de platform authenticator na tela de login e revogação por dispositivo. Vale para **todos os papéis, liderança inclusive** (é quem tem mais atrito: celular + senha), e **não abre chave de `Consent`** — o SO nunca libera a digital, guardamos só uma chave pública, então o art. 11 não entra e o lote da Onda 0 não reabre. Collection `campaignWebAuthnCredential` (migration `20260728_041958_add_campaign_webauthn_credentials`), 4 rotas JSON em `campanha/webauthn/` **fora de `(app)`** (duas são anônimas; o path `/campanha` do cookie já as cobre), `@simplewebauthn/server@13` no servidor e `browser@13` no cliente. **A lacuna que a auditoria abriu e que era o coração da entrega:** o Payload 3.82.0 **não tem caminho password-less** (`loginOperation` exige `password`), então `issueCampaignWebAuthnSession` forja a sessão só com exports públicos — `payload.db.findOne` → `checkLoginPermission` → `addSessionToUser` (`payload/shared`) → `getFieldsToSign` + `jwtSign` → o `setCampaignAuthCookie` do **B39 ✓**, com TTL longo porque cadastrar o aparelho _é_ o opt-in de confiança. **A mina, confirmada em código:** `addSessionToUser` faz read-modify-write do documento **inteiro** do usuário, e carregar com `payload.findByID` passaria pelo `afterRead` `removePrivateAuthFields` — gravar isso de volta apagaria `sessions`/`email` (derrubando **todas as outras sessões**) e expandiria `avatar` em objeto; daí `db.findOne` cru com o comentário nomeando o perigo, `user.updatedAt = null` como o próprio login do Payload faz, lock `campaign-webauthn-session:{id}` numa transação (reescrever o array inteiro faz dois logins simultâneos perderem sessão) e int spec provando que a sessão pré-existente sobrevive ao mint. **Duas armadilhas que a auditoria não previu, e as duas se escondem atrás de um caminho que "funciona":** (1) `getCampaignWebAuthnRelyingParty` derivava o RP da origem canônica e **tinha sucesso** num preview da Vercel — o botão aparecia e o browser estourava `SecurityError`, porque quem serve a página é outro host; agora a função compara o host do request (`origin`, ou `x-forwarded-host`/`host`) com o host derivado e devolve `null` quando divergem, que é o "a UI se esconde" que o plano prometia; (2) o challenge do cookie HMAC precisa ser **os mesmos bytes** que o `@simplewebauthn/server` gerou — a primeira versão gerava o seu ao lado do dele e **todo `register` respondia 400**, achado só pelo e2e. Também: deletar um `campaignUser` quebrava, porque a FK que o Payload emite para a relação obrigatória é `ON DELETE SET NULL` sobre coluna `NOT NULL` → `beforeDelete` em `CampaignUser` apagando as credenciais na mesma transação (cascade explícito, sem editar a migration à mão); `checkLoginPermission` lança o erro genérico do Payload, capturado e relançado em pt-BR para que a conta trancada saiba que é bloqueio e não falha de biometria; e o erro do botão vivia no `FieldError` **e** na região `aria-live`, anunciado duas vezes pelo leitor de tela (o `sr-only` ficou só com o estado pendente). **Medido:** `@simplewebauthn/browser` custa **3,0 kB** gzip contra o orçamento de 10 kB do plano, e o `/simplify` o tirou do First Load JS de vez — a metade sempre carregada (probe de capacidade + tipo de erro) virou `src/lib/campaignWebAuthnSupport.ts`, que não importa nada, e as três ilhas chegam à cerimônia por `import()` dentro do handler: `/campanha/login` caiu de 130 para **126 kB** e `/campanha`, onde o toast de enrollment monta em toda navegação autenticada, ficou **byte-idêntico** aos 268 kB de antes do item. e2e com **autenticador virtual por CDP** (`WebAuthn.addVirtualAuthenticator`, `transport: 'internal'`, que também faz `isUserVerifyingPlatformAuthenticatorAvailable()` responder `true`) cobrindo cadastrar → sair → entrar com biometria → revogar; para o caso da credencial revogada o fixture ganhou `expectedRequestFailurePaths`, porque o teste provoca um 4xx de propósito e o guard global tratava o `console.error` do fetch como erro não tratado. Aikido 0 findings. Plano as-built: [`login-biometrico-campanha.md`](../../../docs/plans/login-biometrico-campanha.md). **O `/simplify` cobrou uma simplificação minha de volta, e essa é a lição transferível:** tirar a anotação `Promise` da action pareceu redundância removida e o `tsc` provou o contrário — `runCampaignFormAction` tipa o ramo de falha **sem a chave `status`**, então só o tipo anotado (onde ela é opcional) permite ao chamador estreitar por `status === "success"`; voltou com o motivo escrito ao lado. O resto do passe: `canWriteWebAuthnCredentials` virou `payloadAdminOnly` em vez de reescrever o predicado; a sonda de disponibilidade (duas cópias com o mesmo guard de cancelamento) virou `useCampaignBiometricsAvailable`; o card passou a usar `FieldError` como o botão — e por isso perdeu a mensagem de erro da região `sr-only`, pelo mesmo motivo de anúncio duplo já corrigido no botão; as três frases declaradas dos dois lados foram para `campaignAuthCopy.ts`, e a que importava era "Este aparelho já está cadastrado.", que o servidor trata como entrada do allowlist `safeMessages` por **string exata** enquanto o cliente a reproduzia à mão; `expectedRequestFailurePaths` virou opção do Playwright (`test.use` no topo em vez de `push` no meio do teste); e o int que dizia "a senha ainda funciona" passou a **logar com a senha**, em vez de reafirmar o e-mail que a asserção acima já cobria. Dois débitos ficaram de fora com motivo: o núcleo HMAC é ~35 linhas iguais às de `supporterImportToken.ts` (2 call sites, e extrair mexeria no caminho do token de import) e `postJson` é a 6ª cópia do mesmo fetch — **e essa segunda o `main` resolveu antes**, no B32+ ✓, então adotar `campaignJsonMutationRoute`/`postCampaignJson` nas 4 rotas e no cliente do WebAuthn é trabalho de pós-rebase, não débito. +- **B40 entregue em código (2026-07-28):** login com biometria (WebAuthn) em `/campanha` — enrollment opt-in depois do login com senha (toast dismissível no shell, no molde do `InstallPwaToast`, + seção permanente "Acesso por biometria" em `/campanha/perfil`), botão de platform authenticator na tela de login e revogação por dispositivo. Vale para **todos os papéis, liderança inclusive** (é quem tem mais atrito: celular + senha), e **não abre chave de `Consent`** — o SO nunca libera a digital, guardamos só uma chave pública, então o art. 11 não entra e o lote da Onda 0 não reabre. Collection `campaignWebAuthnCredential` (migration `20260728_041958_add_campaign_webauthn_credentials`), 4 rotas JSON em `campanha/webauthn/` **fora de `(app)`** (duas são anônimas; o path `/campanha` do cookie já as cobre), `@simplewebauthn/server@13` no servidor e `browser@13` no cliente. **A lacuna que a auditoria abriu e que era o coração da entrega:** o Payload 3.82.0 **não tem caminho password-less** (`loginOperation` exige `password`), então `issueCampaignWebAuthnSession` forja a sessão só com exports públicos — `payload.db.findOne` → `checkLoginPermission` → `addSessionToUser` (`payload/shared`) → `getFieldsToSign` + `jwtSign` → o `setCampaignAuthCookie` do **B39 ✓**, com TTL longo porque cadastrar o aparelho _é_ o opt-in de confiança. **A mina, confirmada em código:** `addSessionToUser` faz read-modify-write do documento **inteiro** do usuário, e carregar com `payload.findByID` passaria pelo `afterRead` `removePrivateAuthFields` — gravar isso de volta apagaria `sessions`/`email` (derrubando **todas as outras sessões**) e expandiria `avatar` em objeto; daí `db.findOne` cru com o comentário nomeando o perigo, `user.updatedAt = null` como o próprio login do Payload faz, lock `campaign-webauthn-session:{id}` numa transação (reescrever o array inteiro faz dois logins simultâneos perderem sessão) e int spec provando que a sessão pré-existente sobrevive ao mint. **Duas armadilhas que a auditoria não previu, e as duas se escondem atrás de um caminho que "funciona":** (1) `getCampaignWebAuthnRelyingParty` derivava o RP da origem canônica e **tinha sucesso** num preview da Vercel — o botão aparecia e o browser estourava `SecurityError`, porque quem serve a página é outro host; agora a função compara o host do request (`origin`, ou `x-forwarded-host`/`host`) com o host derivado e devolve `null` quando divergem, que é o "a UI se esconde" que o plano prometia; (2) o challenge do cookie HMAC precisa ser **os mesmos bytes** que o `@simplewebauthn/server` gerou — a primeira versão gerava o seu ao lado do dele e **todo `register` respondia 400**, achado só pelo e2e. Também: deletar um `campaignUser` quebrava, porque a FK que o Payload emite para a relação obrigatória é `ON DELETE SET NULL` sobre coluna `NOT NULL` → `beforeDelete` em `CampaignUser` apagando as credenciais na mesma transação (cascade explícito, sem editar a migration à mão); `checkLoginPermission` lança o erro genérico do Payload, capturado e relançado em pt-BR para que a conta trancada saiba que é bloqueio e não falha de biometria; e o erro do botão vivia no `FieldError` **e** na região `aria-live`, anunciado duas vezes pelo leitor de tela (o `sr-only` ficou só com o estado pendente). **Medido:** `@simplewebauthn/browser` custa **3,0 kB** gzip contra o orçamento de 10 kB do plano, e o `/simplify` o tirou do First Load JS de vez — a metade sempre carregada (probe de capacidade + tipo de erro) virou `src/lib/campaignWebAuthnSupport.ts`, que não importa nada, e as três ilhas chegam à cerimônia por `import()` dentro do handler: `/campanha/login` caiu de 130 para **126 kB** e `/campanha`, onde o toast de enrollment monta em toda navegação autenticada, ficou **byte-idêntico** aos 268 kB de antes do item. e2e com **autenticador virtual por CDP** (`WebAuthn.addVirtualAuthenticator`, `transport: 'internal'`, que também faz `isUserVerifyingPlatformAuthenticatorAvailable()` responder `true`) cobrindo cadastrar → sair → entrar com biometria → revogar; para o caso da credencial revogada o fixture ganhou `expectedRequestFailurePaths`, porque o teste provoca um 4xx de propósito e o guard global tratava o `console.error` do fetch como erro não tratado. Aikido 0 findings. Plano as-built: [`login-biometrico-campanha.md`](../../../docs/plans/login-biometrico-campanha.md). **O `/simplify` cobrou uma simplificação minha de volta, e essa é a lição transferível:** tirar a anotação `Promise` da action pareceu redundância removida e o `tsc` provou o contrário — `runCampaignFormAction` tipa o ramo de falha **sem a chave `status`**, então só o tipo anotado (onde ela é opcional) permite ao chamador estreitar por `status === "success"`; voltou com o motivo escrito ao lado. O resto do passe: `canWriteWebAuthnCredentials` virou `payloadAdminOnly` em vez de reescrever o predicado; a sonda de disponibilidade (duas cópias com o mesmo guard de cancelamento) virou `useCampaignBiometricsAvailable`; o card passou a usar `FieldError` como o botão — e por isso perdeu a mensagem de erro da região `sr-only`, pelo mesmo motivo de anúncio duplo já corrigido no botão; as três frases declaradas dos dois lados foram para `campaignAuthCopy.ts`, e a que importava era "Este aparelho já está cadastrado.", que o servidor trata como entrada do allowlist `safeMessages` por **string exata** enquanto o cliente a reproduzia à mão; `expectedRequestFailurePaths` virou opção do Playwright (`test.use` no topo em vez de `push` no meio do teste); e o int que dizia "a senha ainda funciona" passou a **logar com a senha**, em vez de reafirmar o e-mail que a asserção acima já cobria. Dois débitos ficaram de fora com motivo: o núcleo HMAC é ~35 linhas iguais às de `supporterImportToken.ts` (2 call sites, e extrair mexeria no caminho do token de import) e `postJson` era a 6ª cópia do mesmo fetch. **Essa segunda o `main` resolveu enquanto o item estava aberto** (B32+ ✓), então o rebase não foi só um merge: as 4 rotas nasceram de novo sobre `campaignJsonMutationRoute` e o transporte passou a ser `postCampaignJson` — não por gosto, porque o B32+ trouxe um **guard de convenção** que reprova um `route.ts` exportando `POST` fora da casca, e as quatro estavam nomeadas nele. Duas coisas a saber de quem adotar a casca num caminho não-célula: ela **sempre** parseia um corpo, e as duas rotas `*-options` não têm nenhum — daí `campaignWebAuthnNoBodySchema`, com o cliente postando `{}`, que é o preço honesto de ter a verificação de origem por construção em vez de por disciplina; e `postCampaignJson` não trata resposta não-JSON, então o `postJson` sobrevive como casca de 6 linhas que descarta o `ok` (o discriminante do envelope é que decide) e traduz **só** `SyntaxError` — uma falha de rede continua subindo intacta, o que a versão anterior achatava. Verificado no servidor de produção local, sem browser: cross-origin 403, sem sessão 401, corpo não-JSON no 400 compartilhado, e o corpo `{}` chegando ao handler. - **B17 entregue em código (2026-07-28):** seletor de colunas, e **nas sete superfícies** do sistema de listas (municípios, lideranças, dobradinhas, organizações, demandas, apoiadores, territórios), não só em municípios — o picker nasceu como capacidade do `CampaignTable` (uma prop `columnVisibility`, devolvida inteira por `readCampaignColumnVisibility(listId)`, liga o controle e filtra), então o custo marginal por lista foi `label` nas colunas e uma prop. Fora: `/campanha/assessores`, cujo `AdvisorsTable` ainda é `ui/Table` cru com `table-fixed`. **Duas premissas do plano de 24/07 morreram.** (1) `localStorage` — e o motivo é de tipo, não de gosto: `CampaignTableColumn.cell` é **função**, o array de colunas não atravessa a fronteira server/client, e esconder no cliente exigiria `data-column` + `