@chrono-os/consent-lgpd
v1.0.0
Published
LGPD reutilizável: consentimento (CookieBanner 3-cat, ConditionalScript, ConsentLog), ciclo de vida de direitos do titular (export com link assinado, exclusão com carência, varredura, caixa do DPO) por portas, adaptadores Fastify e NestJS, páginas legais
Downloads
832
Readme
@chrono-os/consent-lgpd
LGPD reutilizável, em duas metades:
- Consentimento — CookieBanner 3-cat (necessarios/analiticos/marketing), ConditionalScript gated por consent, ConsentLog com hash de IP, formulário simples de DPO e páginas legais (Privacidade, Cookies, Termos, DPO).
- Direitos do titular (desde 1.0, vindo do SobreVivendo Adulto) — export com link de validade, exclusão com carência, varredura de pendências, caixa do DPO com marcos de prazo. Sem framework: fala com o banco do app por portas; adaptador NestJS opcional.
Status:
1.0.0. Consumidores: Naírio (institucional, calculadoras) na metade de consentimento; SobreVivendo Adulto no ciclo de direitos do titular. Plano original em Naírio/Plan/03-modulos/2026-05-24_modulo-consent-lgpd.md.
Install
Pacote público no npmjs.org — sem necessidade de .npmrc ou token.
yarn add @chrono-os/consent-lgpdAplicar migration template (só a metade de consentimento — ConsentLog e DpoRequest simples):
cat node_modules/@chrono-os/consent-lgpd/prisma/schema.template.prisma >> prisma/schema.prisma
yarn prisma migrate dev --name add_consent_lgpdO template é conferido por teste contra os campos que recordConsent, createDpoRequest e purgeOldConsents gravam (src/core/template-prisma.test.ts).
Tailwind — passo obrigatório no consumer
O banner e o botão de revogar são estilizados com classes Tailwind que vivem
dentro do bundle do pacote. O purge do Tailwind só varre o que está no
content, então sem a linha abaixo essas classes somem do CSS final:
// tailwind.config.ts do app consumer
content: [
'./app/**/*.{ts,tsx}',
// ...
'./node_modules/@chrono-os/consent-lgpd/dist/**/*.{js,mjs,cjs}',
]Como o defeito se manifesta (é enganoso, vale reconhecer de longe): o banner
renderiza, mas largura cheia e colado ao fim do conteúdo em vez de flutuar num
card no canto. Parte das classes continua funcionando — bg-slate-900, p-6,
rounded-lg sobrevivem se outra página do app as usar — o que passa impressão
de CSS meio quebrado, não de purge. A confirmação está no computed style: com
bottom-4 purgada, um position: fixed sem bottom cai na posição estática do
fluxo e getComputedStyle devolve algo como bottom: -3304px; md:max-w-xl
purgada aparece como max-width: none.
Conferir em produção sem abrir o navegador:
curl -s "$SEU_SITE/_next/static/chunks/<hash>.css" | grep -c '\.bottom-4{'
# 0 = purgadaUso
Frontend — banner + script condicional
// app/layout.tsx
import { CookieBanner, ConditionalScript } from "@chrono-os/consent-lgpd/react";
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html>
<body>
{children}
<CookieBanner
cookiePolicyHref="/politica-de-cookies"
onConsentChange={async ({ state, action, sessionId }) => {
await fetch("/api/consent", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
sessionId,
categories: {
necessarios: state.necessarios,
analiticos: state.analiticos,
marketing: state.marketing,
version: state.version,
},
action,
}),
});
}}
/>
{/* Google Analytics 4 — só carrega após consent "analiticos" */}
<ConditionalScript
category="analiticos"
src="https://www.googletagmanager.com/gtag/js?id=G-XXXX"
async
/>
</body>
</html>
);
}Frontend — botão revogar consent
// app/politica-de-cookies/page.tsx
import { RevokeButton } from "@chrono-os/consent-lgpd/react";
export default function PoliticaCookiesPage() {
return (
<main>
<h1>Política de Cookies</h1>
<RevokeButton
onConsentChange={async ({ state, action, sessionId }) => {
await fetch("/api/consent", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({ sessionId, categories: state, action }),
});
}}
/>
</main>
);
}Backend (Fastify)
import Fastify from "fastify";
import { PrismaClient } from "@prisma/client";
import { consentPlugin } from "@chrono-os/consent-lgpd/fastify";
const app = Fastify();
const prisma = new PrismaClient();
await app.register(consentPlugin, {
prisma,
ipHashSalt: process.env.CONSENT_IP_HASH_SALT!, // 32+ chars
// prefix: "/api", // opcional
// routes: { consent: true, dpo: true }, // opcional
// dpoRateLimit: { max: 5, timeWindow: "1 minute" },
});Expõe:
POST /consent— gravaConsentLog(IP hashed server-side, body validado com Zod)POST /webhooks/dpo— recebe solicitação LGPD art. 18, rate-limited
Páginas legais
import { POLITICA_PRIVACIDADE_MD, renderTemplate } from "@chrono-os/consent-lgpd/pages";
const html = renderTemplate(POLITICA_PRIVACIDADE_MD, {
razaoSocial: "Naírio Augusto Advogados",
cnpj: "28.373.650/0001-00",
dpoNome: "Dr. Naírio Augusto",
contatoHref: "/contato",
dpoHref: "/dpo",
privacidadeHref: "/politica-de-privacidade",
cookiesHref: "/politica-de-cookies",
termosHref: "/termos-de-uso",
ultimaAtualizacao: "2026-05-24",
versao: "1.0",
foroComarca: "Porto Alegre/RS",
operadoresMd: "- Coolify (hospedagem)\n- ClickUp (CRM)\n- Z-API (validação WhatsApp)",
analyticsScriptsMd: "Google Analytics 4 com IP anonimizado",
marketingScriptsMd: "Meta Pixel + Google Ads Conversions",
});
// Renderize `html` (markdown) via MDX, react-markdown, etc.Direitos do titular (1.0)
O núcleo guarda as regras que precisam ser iguais em todo app; o que depende do schema, da fila, do e-mail e do storage o app entrega como portas.
| Caso de uso | Regra que o núcleo garante |
|---|---|
| solicitarExport | 1 export por titular a cada 24 h (configurável); fila fora do ar não perde o pedido |
| processar(id) — EXPORT | coleta → grava no storage → URL com validade (7 d, nunca além do pedido) → READY; storage sem URL cai no modo inline (JSON na solicitação) |
| resolverDownload | só o dono (ou token assinado); alheio/inexistente/adulterado dão o mesmo NAO_ENCONTRADA; expirado ou sem prazo → EXPIRADA; FULFILLED só no 1º download |
| solicitarExclusao | exige a palavra EXCLUIR; agenda para agora + carência (30 d); o app corta o acesso na mesma transação |
| processar(id) — DELETE | antes da carência lança DELETE_NOT_DUE_YET (a fila re-tenta); sem data agendada recusa e alerta; READY numa exclusão não conta como feita |
| varrer() | re-enfileira export parado (+1 h) e exclusão vencida com o mesmo jobId; idempotente; nunca lança |
| criarCaixaDpo().receber | honeypot (website) → sucesso aparente sem gravar; aviso de recebimento pela fila, com envio direto de reserva; alerta sem nome/e-mail por padrão |
| criarCaixaDpo().atualizar | confirmadoEm/resolvidoEm gravados uma vez só; devolve antes/depois para o log de auditoria |
import { criarDireitosTitular, criarCaixaDpo, type PortasDireitosTitular } from "@chrono-os/consent-lgpd/core";
const portas: PortasDireitosTitular = {
solicitacoes: { buscar, buscarExportRecente, criarExport, criarExclusao, atualizar, listarPresas },
dadosTitular: { coletar, apagar }, // o que o app guarda do titular
armazenamento: { gravar, assinarUrl }, // opcional — sem ele, modo inline
fila: { enfileirar }, // { enfileirado: false } = sem fila
notificacao: { exportPronto }, // opcional, best-effort
alertar, eventos, log, // opcionais
};
const direitos = criarDireitosTitular(portas, { carenciaExclusaoDias: 30 });Contrato das portas que o núcleo não consegue checar sozinho (está nos JSDoc de tipos.ts):
criarExclusaoé uma transação: solicitação + soft-delete + revogar sessões.apagaré idempotente e não apaga a solicitação — ela fica comtitularId = null(FKSetNull) como prova da exclusão.atualizarnão lança se a linha sumiu (updateMany).
Link assinado próprio (opcional). Com linkAssinado: { segredo, urlDownload }, o e-mail de "export pronto" leva um link para o app (token HMAC com validade) em vez da URL do storage, e resolverDownload({ solicitacaoId, token }) aceita esse token sem sessão. Assinatura válida não basta: o banco ainda precisa confirmar solicitação, dono e prazo — então apagar o titular revoga o link. Primitivas avulsas: assinarLinkExport / verificarLinkExport.
Erros. Casos esperados voltam como { ok: false, erro }; os que precisam derrubar o job lançam (ExclusaoNaoVencidaError, ExclusaoSemAgendamentoError). Confira pelo .code, não por instanceof: /core e /nestjs são bundles separados, cada um com a sua cópia da classe.
NestJS
import { ConsentLgpdModule, InjectDireitosTitular, ouLancar, type DireitosTitular } from "@chrono-os/consent-lgpd/nestjs";
@Module({
imports: [
ConsentLgpdModule.forRootAsync({
imports: [PortasModule],
inject: [MinhasPortas],
useFactory: (p: MinhasPortas) => ({
direitos: { portas: p.direitos(), opcoes: { carenciaExclusaoDias: 30 } },
caixaDpo: { portas: p.caixaDpo() },
}),
}),
],
})
// no controller — rota, guard e auditoria continuam do app
const { solicitacao } = ouLancar(await this.direitos.solicitarExport({ titularId, ipHash }));ouLancar/excecaoHttpLgpd traduzem os erros para HttpException com { error, message, details } (429 RATE_LIMITED, 404 NOT_FOUND, 425 NOT_READY, 410 EXPIRED, 500 PERSIST_FAILED...). Implementação de referência: sobrevivendo-backend/apps/api/src/modules/lgpd/lgpd-portas.ts.
API
| Entrypoint | Exporta |
|---|---|
| /react | CookieBanner, ConditionalScript, RevokeButton, useConsent, readConsent/writeConsent/clearConsent/hasConsented/getSessionId, constantes |
| /core | consentimento: hashIp, hashIpHmacDiario, verificarIpHash, recordConsent, createDpoRequest, purgeOldConsents · direitos do titular: criarDireitosTitular, criarCaixaDpo, assinarLinkExport, verificarLinkExport, tipos das portas |
| /nestjs | ConsentLgpdModule, InjectDireitosTitular, InjectCaixaDpo, DIREITOS_TITULAR, CAIXA_DPO, ouLancar, excecaoHttpLgpd (peer opcional @nestjs/common) |
| /fastify | consentPlugin + as funções de consentimento do /core |
| /schema | consentStateSchema, consentLogPayloadSchema, dpoRequestPayloadSchema, assuntoDpoSchema, statusPedidoDpoSchema, pedidoDpoSchema, CONFIRMACAO_EXCLUSAO, types Zod, constantes |
| /pages | POLITICA_PRIVACIDADE_MD, POLITICA_COOKIES_MD, TERMOS_USO_MD, PAGINA_DPO_MD, renderTemplate, LegalPageVars |
Roadmap
| Fase | Conteúdo | ETA | |---|---|---| | 0 | Estrutura + CI + 0.1.0 stub + schema Zod | 2026-05-31 ✅ | | 1 | Banner + ConditionalScript + ConsentLog + DPO + páginas legais (0.2.0) | 2026-06-28 ✅ | | 2 | Calculadora Naírio adota o pacote (deadline legal) | 2026-07-12 ✅ | | 3 | 1.0 — fusão do ciclo de direitos do titular do SVA + adaptador NestJS | 2026-09-10 ✅ |
Versionamento
SemVer. Releases via tag vX.Y.Z no main. Ver CHANGELOG.md.
