From 77023db99d1b404150cf376ea6900328eb2001c2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Francisco=20Ign=C3=A1cio=20Fontoura=20Solla?= Date: Tue, 28 Jul 2026 19:49:23 -0300 Subject: [PATCH] B34+: scale and DRY after municipality chip lists. Memoize access reads per req, cache portfolio index, trim chip revalidates, and shrink RSC payload via catalog-backed labels. --- .cursor/rules/projects/nucleos-eleitorais.mdc | 1 + AGENTS.md | 2 +- docs/TECH-DEBT.md | 4 +- docs/plans/escala-dry-pos-b34.md | 18 ++ docs/roadmap.md | 2 +- .../campanha/(app)/assessores/page.tsx | 2 +- .../campanha/(app)/dobradinhas/page.tsx | 2 +- .../campanha/(app)/liderancas/page.tsx | 9 +- .../(campaign)/campanha/actions/advisor.ts | 22 ++- .../(campaign)/campanha/actions/leadership.ts | 102 +++++++--- .../campanha/actions/stateDeputy.ts | 7 +- .../campaign/advisor/AdvisorsTable.tsx | 5 +- .../campaign/shared/RelationChipCell.tsx | 20 +- src/lib/municipalityPortfolio.ts | 86 ++++++--- src/utilities/access/campaignUsers.ts | 89 +++++---- src/utilities/access/contacts.ts | 133 +++++++------ src/utilities/access/leaderships.ts | 70 ++++--- src/utilities/access/municipalities.ts | 83 ++++----- src/utilities/access/shared.ts | 82 ++++++--- src/utilities/advisorData.ts | 100 +++++++--- src/utilities/municipalityPortfolioIndex.ts | 40 ++-- src/utilities/municipalityRevalidation.ts | 10 + src/utilities/revalidateRequest.ts | 2 + tests/e2e/campaignLeaderships.e2e.spec.ts | 59 +----- tests/e2e/campaignMunicipalities.e2e.spec.ts | 9 +- tests/e2e/fixtures/campaignE2EFixtures.ts | 67 ++++++- tests/helpers/campaignFixtures.ts | 112 +++++++---- .../int/campaignAccessRequestMemo.int.spec.ts | 174 ++++++++++++++++++ tests/int/campaignActivity.int.spec.ts | 17 +- .../int/campaignAdvisorManagement.int.spec.ts | 6 +- .../campaignAllocationDecision.int.spec.ts | 6 +- tests/int/campaignEntityListData.int.spec.ts | 5 +- ...adershipMunicipalityMembership.int.spec.ts | 5 +- ...eadershipStateDeputyMembership.int.spec.ts | 5 +- .../campaignMunicipalityAccess.int.spec.ts | 37 +++- ...nMunicipalityAdvisorMembership.int.spec.ts | 22 +-- ...teDeputyMunicipalityMembership.int.spec.ts | 6 +- tests/int/campaignSupporter.int.spec.ts | 4 +- .../unit/campaignCellEditOverlay.unit.spec.ts | 4 +- tests/unit/municipalityPortfolio.unit.spec.ts | 12 +- .../municipalityPortfolioCell.unit.spec.ts | 40 ++-- tests/unit/revalidateRequest.unit.spec.ts | 5 + 42 files changed, 978 insertions(+), 508 deletions(-) create mode 100644 tests/int/campaignAccessRequestMemo.int.spec.ts diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index 74c5c77d..54a5d013 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -16,6 +16,7 @@ alwaysApply: false - **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 `postJson` sobrevive como casca de **uma linha** sobre `postCampaignJson` que só descarta o `ok`, porque o discriminante do envelope é que decide. **A tradução de `SyntaxError` que a primeira versão dessa casca tinha caiu no `/simplify` do fechamento, e o motivo generaliza para qualquer ilha destas três telas:** ela escrevia "Resposta inválida do servidor." num `Error` cru, e as três ilhas leem `message` **só** de um `CampaignWebAuthnError` — a frase era redigida para a pessoa e jogada no canal que descarta mensagens. Deletar em vez de re-tipar, porque o fallback de cada ilha ("…Use sua senha.") é a copy melhor para quem está em campo; nada muda na tela, e o comentário agora registra por que o ramo está ausente. 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` + `