diff --git a/.cursor/rules/projects/nucleos-eleitorais.mdc b/.cursor/rules/projects/nucleos-eleitorais.mdc index 8f2b5d2c..54d29e1f 100644 --- a/.cursor/rules/projects/nucleos-eleitorais.mdc +++ b/.cursor/rules/projects/nucleos-eleitorais.mdc @@ -11,6 +11,7 @@ alwaysApply: false - **B8+ entregue em código (2026-07-27):** os quatro débitos do `/simplify` do B8 F2, e o achado central mudou de dono. **F1 — a alavanca não era a que o plano dizia.** O plano tratava "escala de chão" como algo implementado via dígitos de quantização; medindo o re-snap do artefato commitado para grades mais grossas, quantização sozinha quase não corta: ~12 m → 8.990 pontos (99% do original), ~25 m → 8.164, ~50 m → 6.219, e **mesmo na grade da malha estadual (~103 m) → 3.849 pontos, ainda 203/feature** — ~9× o irmão. Quem manda é o **limiar absoluto de Visvalingam**, que o plano citava entre parênteses. Então a política nasceu invertida: o peso absoluto **em m² de chão** é a política, os dígitos são derivados do bbox de cada topologia. `scripts/lib/topology.mjs` (**primeiro módulo de `scripts/lib/` — o B5 F2 pousa nele, não abre um segundo**) expõe `SIMPLIFY_TOLERANCE_M2` por malha, `GROUND_GRID_METRES = 102.82`, `simplifyToGroundScale`, `quantizeToGroundGrid` e `describeGroundScale`; `SIMPLIFY_QUANTILE`/`QUANTIZE_DIGITS` morreram nos dois scripts. Resultado medido: as zonas caíram de **9.120 → 574 pontos de arco (480 → 30,2/feature) e 72.310 → 7.687 bytes**; `bahia-municipalities` saiu **byte-idêntico** (a política foi semeada no valor histórico dele); territórios mudou **1 byte** (a grade passou de 102,730 para 102,822 m ao ser derivada do bbox em vez de um `1e4` fixo). **Três fatos para ninguém re-derivar:** (1) tolerância é **por malha de propósito** — a municipal (1,78 km²) chega a 17,9 pontos/feature nas zonas, mas come **5% da área de terra de Salvador (306 → 291 km²)**, que é justamente a grandeza com que o int prova que nenhum bairro foi descartado nem duplicado; as zonas ficaram em 0,06 km² e os territórios em 13,6 km² (dissolve da malha municipal, já vêm generalizados); (2) **a ordem importa** — quantizar antes de simplificar colapsa vértices em duplicatas e o `merge` posterior produz anéis quebrados (medido: Salvador dissolvendo para 24 km² em vez de 307); (3) a área das zonas pós-corte é **306,3 km²** (era ~324), dentro da janela de 280–360 km² que o int já tinha. `MAX_TOPO_BYTES` ficou e ganhou ao lado **`MAX_POINTS_PER_FEATURE = 60`** — bytes só pegam malha que ninguém simplificou (as zonas viviam a 10% do teto de bytes com 22× a densidade do irmão — 26× é a razão do passo de grade, não da densidade). `zoneNumber` saiu do artefato e do tipo. **F2:** `featureMapKey` passou a rodar sobre features **reais** das três malhas, cada uma no laço que já a varre inteira — 417 `codarea`, 27 `code`, 19 `slug` (a zona responde slug mesmo carregando o `ibgeCode` da cidade); a primeira escrita asseria a cidade dentro do laço das zonas, e o `/simplify` mostrou que isso era **uma** feature municipal asserida 19 vezes (as 19 ZE compartilham o código de Salvador) enquanto 416 municípios e 27 TIs nunca passavam por `featureMapKey`; o caso de precedência do unit usava `{ municipalitySlug, codarea }`, forma que **nenhuma malha produz** — caiu, e o comentário passou a declarar o invariante real: as três propriedades de chave são **mutuamente exclusivas**, e `ibgeCode` na malha de zonas deliberadamente não é chave. **F3:** `.catch()` na metade das zonas — e a precisão que o plano deixava otimista: como o bundle é keyed por slug, **Salvador não volta a pintar agregada**, volta como polígono sem dado e sem interação (não está em `scopedKeys`); a memoização da rejeição fica (é o estado de falha acionável do B14) e `status === 'error'` segue reservado à malha municipal. A ressalva de aproximação saiu do gate `!comparisonActive` e virou `
` condicionado a `bundle.hasZoneMunicipalities` — campo novo do contrato, derivado de `kind === 'zona'` no loader. O `/simplify` rejeitou as duas alternativas: um `isZoneMapKey` no cliente (segunda codificação da regra de `mapKeyForMunicipality`, em espaço negativo) e `zoneBreakdown.length > 0` (recria o acoplamento que o fill-in da lista de zonas vai deletar); com o campo próprio, o fill-in só apaga o `
` e `BahiaMeshFeature`. **Um item do plano foi rejeitado por medição:** `city`/`zoneNumber` de `MunicipalityZoneNeighborhoodEntry` **ficam** — nenhum runtime os lê, mas os dois entram nas linhas hasheadas por `canonicalEvidenceRows` e são colunas do fixture de proveniência da RA 02/2017 (transcrição **independente** da resolução), então deletar enfraqueceria a verificação por ganho zero; só o `zoneNumber` do **artefato** saiu. Sem migration, collection, action ou `Consent`; e2e segue 436 paths / 435 interativos. Gate completo rodado bare contra o banco local (as falhas de e2e encontradas foram reproduzidas em árvore limpa — flakiness pré-existente já no ledger); Aikido **0 findings** nos arquivos editados (desta vez o scanner rodou; o exit code 2 do B8 F2 não se repetiu para o SAST). Plano as-built: [`escala-dry-pos-b8f2.md`](../../../docs/plans/escala-dry-pos-b8f2.md).
- **`users.roles` entregue em código (2026-07-27):** campo `roles` (`admin` | `editor`) em `users` — migration `20260727_032523_add_users_roles` (enum + tabela `users_roles` + backfill `admin` nas rows existentes). `isPayloadAdmin` exige papel `admin` (falha fechado se ausente/vazio); `hasPayloadPanelAccess` / `canManagePublishedContent` liberam editor em `/admin` + `post`/`tag`/`media`; `petition`/PII/campanha seguem admin-only; `canReadMunicipality` nega ator `users` sem admin (antes devolvia `Where` vazio e o editor via Municípios no sidebar). Unificação `users`+`campaignUser` **rejeitada** nesta sessão (25 FKs, isolamento estrutural→dado, janela de congelamento) — gatilho: Fase 2 white-label ou dor real de duas contas. Plano: [`roles-admin-payload.md`](../../../docs/plans/roles-admin-payload.md).
- **Build resiliente ao global `metadata` ausente entregue em código (2026-07-27):** site público. `resolveSiteMetadata` + `SITE_METADATA_DEFAULTS` em `src/utilities/seo.ts` — cadeia `global.URL` → `NEXT_PUBLIC_SITE_URL` → `siteUrl: null` (chamador omite canonical / OG url / JSON-LD url); defaults textuais (`siteName`/`title`/`description`/`twitterCreator`) reusam o que o layout já hardcodava. Consumidores: `layout.tsx`, `/abaixo-assinado/[id]`, `/[type]`, `/[type]/[category]`, `/[type]/[category]/[slug]`. Cópias locais de `stripTrailingSlash`/`truncate`/`toAbsoluteUrl` na página de abaixo-assinado removidas. Pins: unit `siteMetadata`, int `siteMetadataFallback` (mock `getCachedGlobal` → `{}`), guard em `codebaseConventions` contra `globalMetadata.openGraph.` / `.twitter.` / `.keywords.` / `stripTrailingSlash(globalMetadata.URL)` fora de `seo.ts`. Escopo ampliado na auditoria (roadmap citava só a rota de petição e só `URL`). Sem migration/collection/action. Gate: tsc/lint/format/cycles/unit 548/build verde; knip P3 pré-existente; Aikido sem achados novos (`dangerouslySetInnerHTML` Lexical/JSON-LD pré-existente). Plano: [`build-resiliente-metadata-ausente.md`](../../../docs/plans/build-resiliente-metadata-ausente.md).
+- **B39 entregue em código (2026-07-27):** login de `/campanha` ganhou “Lembrar de mim neste dispositivo”, default desmarcado. Contrato: **8 h** padrão / **14 dias** opt-in, definidos em `campaignSessionTtl.ts`; `CampaignUser.auth.tokenExpiration` usa o teto longo porque Payload não aceita TTL por login e `useSessions` poda pelo `expiresAt` armazenado; `setCampaignAuthCookie` re-assina por `jwtSign` os claims do token para 8 h quando necessário e mantém JWT/`maxAge` em lockstep. Convite e reset continuam no default curto. “Sair” autentica o token e delega a revogação da sessão corrente à operação transacional pública `logoutOperation` do Payload antes de apagar cookie/PWA; falha de DB nunca impede a limpeza local. Sem migration (`migrate:create __probe`: “No schema changes detected”). Impeccable critique/polish: copy outcome-first, descrição ligada por `aria-describedby`, alvo medido **44×44 px**, sem overflow em 390/768/1440; detector 0. E2E mede `campaign-token.expires` em ~14 dias.
- **B8 F2 entregue em código (2026-07-26):** as 19 ZE de Salvador ganharam polígono próprio e o coroplético deixou de ser keyed por `codarea`. Antes, Salvador era **um** polígono carregando os votos da cidade inteira, então o mapa não respondia "qual pedaço de Salvador está fraco / é meu" — a única visão por zona era a lista textual do `zoneBreakdown`. **Fase 0 (prep S1+S3):** `municipalityZoneNeighborhoods` passou a autorar só `{ municipalitySlug, source, neighborhoods }` e hidrata `city`/`zoneNumber` de `getMunicipalityCatalogEntry` no `entry()` (slug desconhecido ou não-`zona` falha no import); int novo de ordem de slugs vs catálogo. S2 (gerar TS do JSON) segue cortado — a curadoria ficou no script, não no catálogo. **Fase 1–2 (artefato):** `scripts/build-municipality-zone-geometries.mjs` (`pnpm build:municipality-zone-geometries`, `--report` só reconcilia) baixa/cacheia `BA_bairros_CD2022.zip` do geoftp do IBGE, parseia com **`shpjs`** (devDependency nova — reprojeta o SIRGAS 2000, o IBGE não publica GeoJSON de bairro), filtra `CD_MUN === '2927408'` (**170 polígonos**) e dissolve por zona pela RA 02/2017 do TRE-BA → `src/lib/geometries/bahia-municipality-zones.topo.json` (**72.310 bytes**, ~20 KB gzip, 19 features keyed por `municipalitySlug`), com submódulo `bahiaMunicipalityZoneGeometries.ts` + `loadMunicipalityZoneGeometryModule()` no padrão memoizado de B5 F1. **Reconciliação = 3 tabelas curadas no cabeçalho do script, não regex mais esperta:** `IBGE_TO_TRE_NEIGHBORHOOD` (7 aliases — compostos "Beiru/Tancredo Neves", CAB, ilhas), `TRE_NEIGHBORHOODS_MERGED_INTO` (Jardim Cruzeiro dentro de Vila Ruy Barbosa; o build **recusa** um merge que atravesse zonas) e `SPLIT_NEIGHBORHOOD_ZONES` (Periperi, que a resolução corta na Rua das Pedrinhas, vai **inteiro** para a ZE 17 — o bairro é o átomo geométrico). Atribuição em dois passes: nome da resolução, depois **maior fronteira compartilhada** para os 5 bairros que a RA nunca nomeia (Ilha Amarela, Dois de Julho, Chame-Chame, Horto Florestal, Mirantes de Periperi) — a regra foi **validada** contra os 4 que a resolução nomeia sob outro rótulo e reproduz a zona oficial nos 4. O build morre se sobrar polígono órfão ou bairro TRE sem polígono (hoje 170/170 → 19/19). **Fase 3 (re-keying):** map key = `kind === 'zona' ? slug : ibgeCode` (`mapKeyForMunicipality` + `buildMunicipalitiesByMapKey` em `municipalityMapNavigation.ts`); o bundle inteiro re-keyed (`territorialClassByMapKey`, `projectedValidVotesByMapKey`, `municipalitiesByMapKey`, `valuesByYear`, `values2026ByScenario`, `validVotesByYear`, `diffByYear`); os rollups pararam de acumular com `+=` porque cada map key agora é 1:1; `computeAggregateTerritorialClass` deu lugar a `computeMunicipalityTerritorialClass` (um slug por chave); `resolveMunicipalityMapNavigation` virou 1:1 e o ramo `kind: 'zones'` morreu junto com o scroll-anchor e a copy "toque de novo" do readout. **`competitiveRank` continua por cidade** (o artefato TSE só tem `federalRankByIbgeCode`): as 19 zonas dividem a posição da cidade, o que a legenda já afirmava — int novo pina que as 19 têm **uma** posição só. Per-ZE segue fora (gatilho: produto pedir posição dentro da zona). `buildMunicipalitiesByIbgeCode` sobrevive intacto — o href de lista do B14 ✓ é por cidade. **Fase 4 (mapa):** `BahiaMap` monta a camada de município a partir de **duas** malhas via `loadLayerFeatures`, e o polígono municipal de Salvador fica desenhado **por baixo** como base sem dado e sem pointer (`baseKeys`), para que um sliver de desacordo entre duas malhas simplificadas independentemente não abra buraco no contorno do estado; `keyPropertyForMode`/`featureKeyFromProperties` deram lugar a `featureMapKey` (`municipalitySlug ?? codarea ?? code` — as três nunca coexistem, então o argumento de modo sumiu); `fitMapToHighlights` passou a medir os **paths desenhados** (`pathByKeyRef`) em vez do módulo de geometria, que já não sabe sozinho de qual malha veio a chave, e `geometryModuleRef` foi apagado. Copy do painel: a linha morta "Zonas não têm polígono oficial — Salvador e **Camaçari** aparecem agregadas" (Camaçari saiu do modelo de zona na M1) virou o disclaimer de aproximação. **Dois fatos medidos, para ninguém re-derivar:** o polígono municipal de Salvador tem **~690 km²** e a soma das 19 zonas **~324 km²** — a diferença é a água da baía que os limites municipais encerram e a malha de bairros não desenha, então o int de área compara contra a área de **terra** (280–360 km²) e não contra o município; e centróide-dentro-de-outra-zona **não** é teste de sobreposição válido aqui, porque a ZE 16 é concava e envolve a ZE 14 (o centróide da 16 cai na 14) — a garantia de não-sobreposição é estrutural (cada bairro é atribuído a exatamente uma zona) mais a soma de áreas. Testes: int de contrato (19 features, slugs == catálogo, budget 600 KB, centróide de cada zona dentro do polígono municipal), unit de `featureMapKey`, e2e `campaignZoneMap.e2e.spec.ts` (436 paths / 435 interativos — o único não-interativo é a base de Salvador — readout "Salvador — ZE N" e clique abrindo `/campanha/municipios/salvador-ze-N`). **`/simplify`:** comentário de `NearbyMunicipalityResolution.zoneCity` que dizia "sem polígonos de zona (B8 F2)" corrigido (os polígonos passaram a existir nesta entrega; o card **deliberadamente** continua na lista filtrada — débito aberto, não efeito colateral); duas asserções do int de bairros que viraram tautologia com a hidratação da Fase 0 (comparavam `city`/`zoneNumber` com o catálogo de onde foram copiados) removidas, apontando para o teste de fixture que checa contra a transcrição independente; e o script parou de re-indexar geometrias simplificadas por **nome** de bairro (o irmão `build-bahia-geometries.mjs` usa `codarea`, único por construção) — hoje são 170 nomes distintos em 170 polígonos, mas um nome repetido em malha futura colapsaria dois polígonos silenciosamente num artefato commitado; passou a haver `id` próprio, e o rebuild saiu **byte-idêntico** (sha256 `1dd0d629…`). Sem migration, sem collection, sem action, sem Consent. **Gate:** tsc/lint/format/knip/`check:cycles`/unit+int/e2e/build rodados; **Aikido não fechou** — o scanner MCP responde Opengrep exit code 2 em **qualquer** entrada (inclusive um arquivo-probe trivial de uma linha), então os "4 findings" que ele reportou são artefato da execução quebrada e não achados atribuíveis; o feed do Aikido não tem nada deste repo. SCA do `shpjs` também pendente. **Relatórios do `/simplify` chegaram depois do merge** e o que sobrou virou o fill-in **B8+** ([`escala-dry-pos-b8f2.md`](../../../docs/plans/escala-dry-pos-b8f2.md)), aberto por uma medição que contradiz a intuição de "malha pequena, artefato pequeno": as 19 zonas têm **9.120** pontos de arco contra **9.139** da malha de **todos** os 417 municípios da Bahia (480 vs 22 pontos/feature; Salvador é um `Polygon` de **35** pontos na malha municipal). A causa não é a malha de bairros ser detalhada — é que `QUANTIZE_DIGITS = 1e4` e `SIMPLIFY_QUANTILE = 0.35` foram copiados verbatim do script irmão e **os dois são relativos à própria topologia**: a quantização é uma grade sobre o bbox do arquivo, e o de Salvador é ~26× mais estreito, o que dá **~3,9 m** de resolução contra ~103 m no estado, num mapa que para em `maxZoom: 10`. Consequência para quem for consertar: o que deve ficar compartilhado entre os dois scripts é a **escala de chão**, não a contagem de dígitos — extrair as constantes como estão (o que o revisor de reuse pediu, sem essa medição) cimentaria o defeito e lhe daria autoridade de política. Os outros três: nada roda `featureMapKey` sobre um feature **real** fora do e2e, então as três escritas da map key podem divergir e pintar cinza sem erro em lugar nenhum; o `Promise.all` das duas malhas somado à memoização da rejeição fez de um chunk de 19 polígonos um ponto único de falha do mapa inteiro (antes a malha municipal sozinha bastava); e a ressalva "é aproximado, não é o limite oficial do TSE" está pendurada na condição de render da **lista** (`!comparisonActive`), então desaparece no modo comparação enquanto a malha aproximada continua pintada. Plano: [`poligonos-pracas-zona.md`](../../../docs/plans/poligonos-pracas-zona.md).
- **B39–B42 registrados (2026-07-26, lote roadmap-items):** sessão "Lembrar de mim" (**B39**, ~0,5d, não cortável — default hoje 2 h); login biométrico WebAuthn (**B40**, soft B39, cortável); scroll horizontal + sticky coluna Município (**B41**, espelha TerritoryList/B21); polimento mobile lista municípios (**B42** — Drawer Tendência/Assessores no padrão B26 + card compacto). Planos: `lembrar-de-mim-sessao-campanha.md`, `login-biometrico-campanha.md`, `scroll-horizontal-lista-municipios.md`, `polimento-mobile-lista-municipios.md`. Dedup do lote: drawers + card mobile mesclados em B42; auth fatiado B39/B40.
- **E12 entregue em código (2026-07-26):** camada TI — rollups MAUP + benchmark intra-TI. Estende `territoryOverview.ts` (`computeTerritoryE12Rollup`: cobertura agregada E8 cenário `central`, captura regional como razão dos agregados, mediana/amplitude, município crítico e farol) e `loadTerritoryOverview.ts` (passa `user`, `loadMunicipalityGoalCoverageBundle`, `computeAggregateTerritorialClass` por TI e sub-linhas do Metropolitano). `/campanha/territorios`: colunas Cobertura/Captura/Classe, sorts e descrições B22 em `territoryListUrl.ts` / `territoryListLabels.ts` / `TerritoryListColumns.tsx`. Detalhe: `territoryIntraCaptureBenchmark.ts` + linha T4 no `MunicipalityGoalAccountCard`. Conceitos E18 `captura-regional` e `benchmark-intra-ti`. Cortes v1: sem agrupamento na lista de municípios, sem mapa TI. `sanityCheckSuggestedGoalsByTerritory` segue sem UI. Gate: tsc/lint/format/check:cycles/533 unit/406 int/build (com `PAYLOAD_SECRET` local); knip P3 pré-existente em `payload.config.ts`; Aikido 0 findings. Plano: [`camada-territorios-identidade.md`](../../../docs/plans/camada-territorios-identidade.md).
diff --git a/.impeccable/critique/2026-07-27T03-38-47Z__src-app-campaign-campanha-login-loginform-tsx.md b/.impeccable/critique/2026-07-27T03-38-47Z__src-app-campaign-campanha-login-loginform-tsx.md
new file mode 100644
index 00000000..d03cef7c
--- /dev/null
+++ b/.impeccable/critique/2026-07-27T03-38-47Z__src-app-campaign-campanha-login-loginform-tsx.md
@@ -0,0 +1,60 @@
+---
+target: src/app/(campaign)/campanha/login/LoginForm.tsx
+total_score: 30
+p0_count: 0
+p1_count: 2
+timestamp: 2026-07-27T03-38-47Z
+slug: src-app-campaign-campanha-login-loginform-tsx
+---
+
+Method: dual-agent (A: 40f4d719-1f16-4263-b89d-6aeacd953292 · B: a1f29395-5031-4607-8fe4-d2519097813b; parent completed detector/browser evidence after B's browser restriction)
+
+## Design Health Score
+
+| # | Heuristic | Score | Key issue |
+| --------- | ------------------------------- | --------: | ------------------------------------------------------ |
+| 1 | Visibility of system status | 3 | Pending and errors are announced clearly |
+| 2 | Match system / real world | 4 | Plain pt-BR copy |
+| 3 | User control and freedom | 3 | Recovery remains limited for phone-only accounts |
+| 4 | Consistency and standards | 4 | House Field and Checkbox patterns |
+| 5 | Error prevention | 2 | Combined phone/e-mail input has pre-existing ambiguity |
+| 6 | Recognition rather than recall | 3 | First-access help assumes the assessor is known |
+| 7 | Flexibility and efficiency | 3 | Native autocomplete and enter hints |
+| 8 | Aesthetic and minimalist design | 3 | Focused; initial TTL copy was dense |
+| 9 | Error recovery | 3 | Both credential fields are marked invalid together |
+| 10 | Help and documentation | 2 | Recovery help is not actionable for every role |
+| **Total** | | **30/40** | **Good** |
+
+## Anti-Patterns Verdict
+
+The restrained campaign auth card does not read as AI-generated: no gradients, ornamental cards, or campaign kitsch. It is intentionally familiar product UI. The deterministic detector returned zero findings. Browser review at 390×844, 768×1024, and 1440×900 found no overflow.
+
+## What's Working
+
+- Clear logo → title → credentials → submit hierarchy.
+- Native labels, focus treatment, pending feedback, and error announcement are strong.
+- The new checkbox uses the shared control and keeps the real 8-hour / 14-day contract visible.
+
+## Priority Issues
+
+- **P1 — Checkbox target was 40×32 px.** The shared default pseudo-target was short of the field-use 44 px bar. **Resolved in polish:** this instance now has a measured 44×44 px target.
+- **P2 — Session copy was policy-first and four lines on mobile.** **Resolved in polish:** outcome-first copy is shorter while retaining both exact durations and the personal-device warning.
+- **P1 — Phone-first recovery ambiguity.** Pre-existing and outside B39: phone-only users cannot use e-mail reset.
+- **P2 — Secondary recovery targets are compact.** Pre-existing and outside B39.
+
+## Persona Red Flags
+
+- **Jordan:** first-access recovery assumes they know an assessor.
+- **Sam:** the B39 checkbox is labeled, described with `aria-describedby`, focus-visible, and now has a 44×44 px target.
+- **Casey:** the new option is one tap and defaults safely off; the pre-existing identifier placeholder remains long on narrow phones.
+
+## Minor Observations
+
+- The remembered option remains visually subordinate to the primary login action.
+- The final description takes two lines at desktop/tablet and wraps without overflow on mobile.
+- The identifier placeholder clipping is unrelated to B39.
+
+## Questions to Consider
+
+- Should a future auth pass make recovery adapt to the identifier type?
+- Should compact secondary auth links receive the same 44 px target policy?
diff --git a/AGENTS.md b/AGENTS.md
index 246c36e8..e98dbb43 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -145,10 +145,10 @@ curl -X POST "https://
tokenExpiration short|long"]
- login --> cookie["setCampaignAuthCookie
maxAge = tokenExpiration"]
+ action --> login["payload.login
JWT + session 14 d"]
+ login --> cookie["setCampaignAuthCookie
re-sign 8 h ou mantém 14 d"]
cookie --> gate["getCampaignUser
(layout app)"]
```
@@ -67,8 +75,9 @@ Componentes:
- **`src/lib/schemas/campaign-login.ts`**: campo opcional `rememberMe` (boolean / "on").
- **`LoginForm.tsx`**: `Checkbox` + label; `name="rememberMe"`; `autoComplete` inalterado.
- **`loginCampaign` / `setCampaignAuthCookie`**: aceitar TTL explícito; alinhar JWT + cookie; constantes numéricas num módulo pequeno (`campaignSessionTtl.ts` em `lib/` se puro).
-- **`CampaignUser` auth config**: se Payload exigir `tokenExpiration` estático na collection, usar o **máximo** (longo) na config e emitir tokens com `exp` menor no caminho curto — ou o mecanismo que o Payload 3 expuser sem segundo cookie. Confirmar na implementação lendo a API de `payload.login` / collection auth (depth check: não inventar refresh token).
-- **Testes:** unit do schema; int do login com/sem remember (cookie `maxAge` / claim `exp` se observável); e2e smoke do checkbox opcional.
+- **`CampaignUser` auth config**: `tokenExpiration = CAMPAIGN_SESSION_TTL_LONG`; o caminho curto re-assina por API pública `jwtSign`, com `payload.secret`.
+- **Logout:** autentica o token, revoga só a sessão corrente via `logoutOperation` do Payload e sempre apaga o cookie.
+- **Testes:** unit de claims/`exp`/`maxAge` e action/FormData; int de autenticação após revogação; e2e do checkbox e expiração longa.
- **Migration:** nenhuma.
Depth check: reusa `setCampaignAuthCookie`, `CAMPAIGN_TOKEN_COOKIE`, form action existente. Não cria sessão paralela nem collection de refresh.
@@ -87,7 +96,7 @@ Depth check: reusa `setCampaignAuthCookie`, `CAMPAIGN_TOKEN_COOKIE`, form action
## Rabbit holes
- **Refresh-token / dual-cookie.** Se alguém “só completar” com refresh: segundo segredo, rotação, invalidação no logout, testes de race. **Mitigação:** uma JWT + um cookie, duas durações no emit.
-- **`tokenExpiration` estático na collection vs por login.** Payload pode não aceitar TTL por request. **Mitigação:** medir na Fase 1 da implementação; se só houver TTL estático, config = longo e o caminho curto usa cookie `maxAge` menor **somente se** o JWT curto for emitível — senão documentar e cair para default único alongado + checkbox como no-op visual (falha de produto: não mentir). Preferir falhar o gate a mentir a duração.
+- **`tokenExpiration` estático na collection vs por login.** Resolvido: Payload não aceita TTL por request, mas exporta `jwtSign`; config = longo e o helper re-assina para o curto preservando `id`/`collection`/`role`/`email`/`sid`.
## Adiado com gatilho
@@ -97,7 +106,7 @@ Depth check: reusa `setCampaignAuthCookie`, `CAMPAIGN_TOKEN_COOKIE`, form action
## Referências
- `docs/roadmap.md` (Trilha B · B39)
-- `src/utilities/campaignAuth.ts` — cookie + `DEFAULT_TOKEN_EXPIRATION`
+- `src/utilities/campaignAuth.ts` — re-assinatura, cookie e revogação de sessão
- `src/app/(campaign)/campanha/actions/auth.ts` — `loginCampaign` / logout
- `src/app/(campaign)/campanha/login/LoginForm.tsx`
- `src/collections/CampaignUser.ts` — auth
diff --git a/docs/roadmap.md b/docs/roadmap.md
index 0885f65a..90fb920d 100644
--- a/docs/roadmap.md
+++ b/docs/roadmap.md
@@ -119,7 +119,7 @@ O discovery literatura→persona→entrevista ([relatório aprovado](research/re
- **B17** seletor de colunas (mostrar/ocultar) na lista de municípios — Popover “Colunas” + `localStorage`; desktop only; `name` obrigatória; **Pass 2 W1 deixou trivial:** colunas já são dado (`CampaignTableColumn.id`/`mandatory`/`defaultVisible`); soft: B15 ✓ (ids) / B16 ✓ (barra slim); prepara viewport para **E9** · ~0,5–1d · Janela 1–2 · cortável · [plano](plans/seletor-colunas-lista-municipios.md)
- **B18** filtros salvos na lista de municípios — nomear o estado URL atual (`localStorage`); **Pass 2 W1 deixou trivial:** serialização canônica pronta e congelada em `municipalityListUrl.ts`; acesso rápido de 2º nível sob Municípios no sidebar (hover desktop / expand mobile; sticky só no filtro salvo ativo) · ~1–1,5d · Janela 1–2 · soft: B16/B17 (barra) · cortável · [plano](plans/filtros-salvos-municipios.md)
- ~~**B19** gerenciar assessores — `/campanha/assessores` (lista + novo + detalhe): criar/editar contas `advisor`, ver/atribuir municípios, reenviar link de senha; **só Coordenador Geral e Candidato** (`isCampaignUnrestricted`); alinha access de update/phone do `candidate`~~ — **entregue 2026-07-24** (também alinha atribuição `municipality.advisors` a unrestricted; e-mail na lista via leitura privilegiada; auto-save da carteira) · [plano](plans/gerenciar-assessores.md)
-- **B39** lembrar de mim / sessão longa em `/campanha` — pedido de produto (2026-07-26): logout frequente; hoje `DEFAULT_TOKEN_EXPIRATION = 7200` (2 h) no cookie `campaign-token`. Checkbox "Lembrar de mim neste dispositivo" (default off); sem lembrar ≈ **8 h**, com lembrar ≈ **14 d**; JWT + `maxAge` alinhados; sem migration/Consent · ~0,5d · Janela 1 · Impeccable B · soft: B40 (mesmo `LoginForm`) · **não cortável** nesta leva (atrito diário de adoção) · [plano](plans/lembrar-de-mim-sessao-campanha.md)
+- ~~**B39** lembrar de mim / sessão longa em `/campanha`~~ — **entregue 2026-07-27** (checkbox default off; 8 h padrão / 14 d opt-in; `CampaignUser` configura o teto longo e `setCampaignAuthCookie` re-assina JWT + cookie para o TTL escolhido; `logoutCampaign` revoga o `sid` no servidor antes de limpar cookie/PWA; smoke E2E mede a expiração longa; sem migration/Consent) · [plano](plans/lembrar-de-mim-sessao-campanha.md)
- **B40** login com biometria (WebAuthn) em `/campanha` — após o 1º login com senha, enrollment opt-in no dispositivo; na próxima visita, platform authenticator (digital/Face ID) emite o mesmo `campaign-token` (TTL longo). Collection/credenciais server-side; senha permanece; migration leve · ~1,5–2d · Janela 1–2 · Impeccable C · soft: **B39** (TTL/copy no login) · cortável · [plano](plans/login-biometrico-campanha.md)
- **B41** scroll horizontal + coluna Município fixa na lista de municípios — em `md+`, a tabela deixa de estourar a viewport (`overflow-x-auto` + `sticky left-0` na coluna nome), espelhando **B21 ✓** `TerritoryList`; revisita o trade-off do **B16 ✓** (`overflow-x-visible` para sticky header) · ~0,5–0,75d · Janela 1 · Impeccable B · soft: B17/B38 ✓ · cortável (depois de B42 na lista de cortes) · [plano](plans/scroll-horizontal-lista-municipios.md)
- **B42** polimento mobile da lista de municípios — Tendência e Assessores abrem **Drawer** no mobile (Popover no desktop), no padrão do **B26 ✓** sinal; card mais baixo (Classe sem prosa, Cobertura `compact`, sem botão "Abrir município", tap no card abre o detalhe) · ~1d · Janela 1 · Impeccable B · soft: B24 ✓/B27 ✓/B26 ✓; paralelo a B41 · cortável · [plano](plans/polimento-mobile-lista-municipios.md)
@@ -170,7 +170,7 @@ flowchart TD
B8F2["B8 F2 ✓ Polígonos
zonas de Salvador"]
B14n["B14 ✓ Município mais próximo"]
B38n["B38 ✓ Sidebar recolhido
em tablet"]
- B39n["B39 Lembrar de mim
/ sessão longa"]
+ B39n["B39 ✓ Lembrar de mim
/ sessão longa"]
B40n["B40 Login biométrico
WebAuthn"]
B41n["B41 Scroll horizontal
+ sticky Município"]
B42n["B42 Polimento mobile
lista municípios"]
diff --git a/src/app/(campaign)/campanha/actions/auth.ts b/src/app/(campaign)/campanha/actions/auth.ts
index 8b6865d2..fa5ebea8 100644
--- a/src/app/(campaign)/campanha/actions/auth.ts
+++ b/src/app/(campaign)/campanha/actions/auth.ts
@@ -9,11 +9,13 @@ import {
CAMPAIGN_ACCOUNT_LOCKED_MESSAGE,
CAMPAIGN_LOGIN_INVALID_CREDENTIALS_MESSAGE,
} from '@/lib/campaignAuthCopy'
-import { requiredFormSecret, requiredFormText } from '@/lib/formData'
+import { CAMPAIGN_SESSION_TTL_LONG, CAMPAIGN_SESSION_TTL_SHORT } from '@/lib/campaignSessionTtl'
+import { checkboxFormValue, requiredFormSecret, requiredFormText } from '@/lib/formData'
import { campaignLoginSchema, type CampaignLoginInput } from '@/lib/schemas/campaign-login'
import {
- CAMPAIGN_COOKIE_PATH,
CAMPAIGN_TOKEN_COOKIE,
+ clearCampaignAuthCookie,
+ revokeCampaignSession,
setCampaignAuthCookie,
} from '@/utilities/campaignAuth'
@@ -33,11 +35,11 @@ export const loginCampaign = async (input: CampaignLoginInput): Promise