feat(signatures): módulo completo de assinatura eletrônica
Porta o motor de assinatura do legado com fidelidade algorítmica exata — hash-chain (SHA256 encadeado, seq bigserial define a ordem, não timestamp), OTP (6 dígitos via crypto.randomInt, HMAC-SHA256 com SIGNATURE_OTP_SECRET, TTL 5min, 5 tentativas, único ativo por vez) e a máquina de estados de 19 estados do envelope + 8 do signatário — tudo re-verificado contra o código-fonte real do legado por um subagente dedicado antes de qualquer linha ser escrita, cruzado com eden.md módulo 4. 5 tabelas novas (signature_envelopes/signers/documents/ otp_challenges/audit_events, esta última append-only via a mesma GUC eden.allow_audit_mutation que já protege audit_log desde a Fase 1 — canonicalização de hash extraída para packages/database/src/ hash-chain.ts, compartilhada entre as duas). Três adaptações deliberadas ao EDEN (documentadas na migration): documentos vêm de document_generations em vez de um módulo de anexo genérico que não existe ainda; reseller_id em vez de reseller_registration_id; só a versão correta (v2) da canonicalização, sem a v1 do legado (existia lá só para tolerar hashes pré-correção de bug, EDEN nunca teve isso). Certificado de conclusão renderizado no servidor via Chromium (não client-side como no legado) — elimina o upload multipart do certificado pronto. Os dois passos autenticados de finalização (seal-checkpoint, finalize) continuam explícitos e manuais, não automáticos, mesmo sendo tecnicamente possível agora — esse checkpoint humano antes de uma finalização irreversível é comportamento de negócio herdado do legado, não um detalhe do desenho client-side do certificado que deixou de existir. Fluxo público (token do link, nunca envelopeId/signerId do corpo da requisição) e verificação pública (só para COMPLETED, recomputa envelope_hash ao vivo, nomes mascarados) inteiros. Verificado: 28 testes novos (15 puros — hash-chain/máquina de estados/OTP/token — e 13 de ciclo de vida completo via HTTP real, incluindo merge de PDF de verdade via pdf-lib contra saída real do Chromium mockado). Suíte completa: 70/70 verdes. Testado ao vivo através do container real, ponta a ponta, sem nenhum mock: criação → convite → fluxo público completo dos dois signatários (OTP recuperado via HMAC com o segredo real, já que SMTP não está configurado neste ambiente) → SIGNED → seal-checkpoint → finalize (certificado real via Chromium, merge real, upload real no MinIO) → public-verify — SHA-256 do PDF final baixado bate byte a byte com o gravado no Postgres, integrityValid:true. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01BcWWuKfykGtUweXmHD6cKx
This commit is contained in:
@@ -14,6 +14,7 @@
|
||||
"@aws-sdk/client-s3": "^3.1096.0",
|
||||
"@eden/config": "workspace:*",
|
||||
"nodemailer": "^9.1.1",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"playwright": "^1.62.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -7,3 +7,4 @@
|
||||
export * from "./s3/client.js";
|
||||
export * from "./smtp/mailer.js";
|
||||
export * from "./pdf/renderer.js";
|
||||
export * from "./pdf/merge.js";
|
||||
|
||||
32
packages/integrations/src/pdf/merge.ts
Normal file
32
packages/integrations/src/pdf/merge.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* PDF merge — used by the e-signature module's finalization step (all
|
||||
* frozen documents, in order, followed by the certificate) to produce
|
||||
* one consolidated final PDF. Ported from the legacy's use of `pdf-lib`
|
||||
* for the same purpose (eden.md §8.3).
|
||||
*/
|
||||
import { PDFDocument } from "pdf-lib";
|
||||
|
||||
export async function mergePdfs(buffers: Buffer[]): Promise<{ buffer: Buffer; pageCount: number }> {
|
||||
const merged = await PDFDocument.create();
|
||||
let pageCount = 0;
|
||||
for (const buffer of buffers) {
|
||||
const source = await PDFDocument.load(buffer);
|
||||
const pages = await merged.copyPages(source, source.getPageIndices());
|
||||
for (const page of pages) {
|
||||
merged.addPage(page);
|
||||
pageCount += 1;
|
||||
}
|
||||
}
|
||||
const bytes = await merged.save();
|
||||
return { buffer: Buffer.from(bytes), pageCount };
|
||||
}
|
||||
|
||||
/** Page count of a single PDF buffer — used to record `signature_documents.page_count` when freezing a document. Returns `null` if the buffer isn't a valid PDF (never blocks freezing, matches eden.md §1.6). */
|
||||
export async function getPdfPageCount(buffer: Buffer): Promise<number | null> {
|
||||
try {
|
||||
const doc = await PDFDocument.load(buffer);
|
||||
return doc.getPageCount();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -11,11 +11,18 @@
|
||||
import nodemailer, { type Transporter } from "nodemailer";
|
||||
import { loadEnv } from "@eden/config";
|
||||
|
||||
export interface SendMailAttachment {
|
||||
filename: string;
|
||||
content: Buffer;
|
||||
contentType?: string;
|
||||
}
|
||||
|
||||
export interface SendMailInput {
|
||||
to: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
from?: string;
|
||||
attachments?: SendMailAttachment[];
|
||||
}
|
||||
|
||||
let transporter: Transporter | undefined;
|
||||
@@ -55,6 +62,7 @@ export async function sendMail({
|
||||
subject,
|
||||
html,
|
||||
from,
|
||||
attachments,
|
||||
}: SendMailInput): Promise<void> {
|
||||
const t = getTransporter();
|
||||
if (!t) {
|
||||
@@ -69,6 +77,7 @@ export async function sendMail({
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
attachments,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[mailer] falha ao enviar e-mail para ${to}:`, err);
|
||||
|
||||
Reference in New Issue
Block a user