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:
2026-09-04 13:44:43 -03:00
parent d46d44f01b
commit 20b114a0bf
32 changed files with 3232 additions and 12 deletions

View File

@@ -27,6 +27,7 @@
"express": "^4.21.2",
"jsonwebtoken": "^9.0.2",
"otplib": "^13.5.0",
"qrcode": "^1.5.4",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1"
},
@@ -36,7 +37,9 @@
"@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.7",
"@types/node": "^22.10.5",
"@types/qrcode": "^1.5.6",
"@types/supertest": "^6.0.2",
"pdf-lib": "^1.17.1",
"supertest": "^7.0.0",
"tsx": "^4.19.2",
"typescript": "^5.7.3",

View File

@@ -20,6 +20,7 @@ import { HrTimeclockModule } from "./hr-timeclock/hr-timeclock.module.js";
import { FiscalModule } from "./fiscal/fiscal.module.js";
import { BillingModule } from "./billing/billing.module.js";
import { DocumentsModule } from "./documents/documents.module.js";
import { SignaturesModule } from "./signatures/signatures.module.js";
@Module({
imports: [
@@ -43,6 +44,7 @@ import { DocumentsModule } from "./documents/documents.module.js";
FiscalModule,
BillingModule,
DocumentsModule,
SignaturesModule,
],
controllers: [HealthController],
})

View File

@@ -0,0 +1,221 @@
/**
* Certificado de conclusão — eden.md §8.5. Conteúdo (estrutura, rótulos,
* texto legal) portado verbatim de SignatureCertificatePDFTemplate.jsx do
* legado — só o texto do rodapé legal (MP 2.200-2/2001, Lei 14.063/2020,
* declaração de não ser ICP-Brasil) e o bloco "Manifestação de Vontade"
* são conteúdo jurídico, extraídos ao pé da letra, nunca parafraseados.
*
* Diferença deliberada do legado: renderizado inteiramente no servidor
* via Chromium (packages/integrations/src/pdf/renderer.ts), não
* desenhado no navegador do backoffice via html2canvas+jsPDF — o EDEN já
* tem esse motor (construído nesta mesma sessão para Documents/ANATEL) e
* gerar aqui elimina o upload multipart do "certificado pronto" que o
* legado precisa aceitar como entrada confiável em finalizeEnvelope.
*/
import QRCode from "qrcode";
import { formatCNPJ, formatCPF } from "@eden/domain-shared";
import type { SignatureAuditEvent, SignatureDocument, SignatureEnvelope, SignatureSigner } from "@eden/database";
const PARTY_LABELS: Record<string, string> = {
contratada: "Contratada",
contratante: "Contratante",
};
const TIMELINE_EVENTS: Array<{ type: string; label: string }> = [
{ type: "INVITE_SENT", label: "Convite enviado" },
{ type: "LINK_OPENED", label: "Link acessado" },
{ type: "CUSTOMER_DATA_CONFIRMED", label: "Dados confirmados" },
{ type: "ELECTRONIC_SIGNATURE_TERMS_ACCEPTED", label: "Termo de assinatura eletrônica aceito" },
{ type: "OTP_VERIFIED", label: "Código de verificação validado" },
{ type: "SIGNATURE_COMMITTED", label: '"Assinar e Concordar" acionado' },
];
function fmtDateTime(date: Date | string | null): string {
if (!date) return "—";
const d = typeof date === "string" ? new Date(date) : date;
return new Intl.DateTimeFormat("pt-BR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
hour: "2-digit",
minute: "2-digit",
timeZone: "UTC",
})
.format(d)
.replace(",", " às");
}
function maskEmail(email: string | null): string {
if (!email) return "—";
const [user, domain] = email.split("@");
if (!user || !domain) return email;
return `${user.slice(0, 2)}${"*".repeat(Math.max(user.length - 2, 1))}@${domain}`;
}
function signerTimeline(auditEvents: SignatureAuditEvent[], signerId: string): Array<{ label: string; timestamp: Date }> {
const result: Array<{ label: string; timestamp: Date }> = [];
for (const te of TIMELINE_EVENTS) {
const event = auditEvents.find((e) => e.signer_id === signerId && e.event_type === te.type);
if (event) result.push({ label: te.label, timestamp: event.timestamp_utc });
}
return result;
}
function documentPageRanges(documents: SignatureDocument[]): Array<SignatureDocument & { pageRange: string }> {
let acc = 1;
return documents.map((doc) => {
const pages = doc.page_count ?? 0;
const range = pages > 0 ? `${acc}-${acc + pages - 1}` : "—";
acc += pages;
return { ...doc, pageRange: range };
});
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
export interface CertificateInput {
envelope: SignatureEnvelope;
documents: SignatureDocument[];
signers: SignatureSigner[];
auditEvents: SignatureAuditEvent[];
verifyUrl: string;
}
export async function buildCertificateHtml(input: CertificateInput): Promise<string> {
const { envelope, documents, signers, auditEvents, verifyUrl } = input;
const today = new Date();
const rangedDocs = documentPageRanges(documents);
const clientLabel = signers.find((s) => s.party === "contratante")?.full_name ?? "—";
const qrDataUrl = await QRCode.toDataURL(verifyUrl, { margin: 1, width: 200 });
const documentRows = rangedDocs
.map(
(doc) => `
<tr>
<td>${escapeHtml(doc.title)}</td>
<td class="center">v${doc.document_version}</td>
<td class="center">${doc.pageRange}</td>
<td class="mono">${doc.original_sha256}</td>
</tr>`,
)
.join("");
const signerBlocks = signers
.map((signer) => {
const doc = signer.person_type === "pj" ? formatCNPJ(signer.cpf ?? "") : formatCPF(signer.cpf ?? "");
const timeline = signerTimeline(auditEvents, signer.id)
.map((t, i) => `${i > 0 ? " · " : ""}${escapeHtml(t.label)} (${fmtDateTime(t.timestamp)})`)
.join("");
const companyRow = signer.company_name
? `<tr><td>Empresa: ${escapeHtml(signer.company_name)}</td><td>${signer.role_title ? `Cargo: ${escapeHtml(signer.role_title)}` : ""}</td></tr>`
: "";
return `
<table class="cert-table signer-block">
<tbody>
<tr><td colspan="2" class="section-header">SIGNATÁRIO — ${escapeHtml(signer.full_name)} (${PARTY_LABELS[signer.party] ?? signer.party})</td></tr>
<tr><td>CPF/CNPJ: ${doc}</td><td>E-mail: ${maskEmail(signer.email)}</td></tr>
${companyRow}
<tr><td>Status da autenticação: ${signer.status === "SIGNED" ? "VALIDADA" : signer.status}</td><td>Assinado em: ${fmtDateTime(signer.signed_at)}</td></tr>
<tr><td colspan="2"><strong>Cronologia:</strong> ${timeline}</td></tr>
</tbody>
</table>`;
})
.join("");
return `
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<style>${css()}</style>
</head>
<body>
<div class="page">
<div class="header">
<div class="brand">Handix do Brasil</div>
<img class="qr" src="${qrDataUrl}" alt="QR Code de verificação" />
</div>
<h1>CERTIFICADO DE AUTENTICIDADE E ASSINATURA ELETRÔNICA</h1>
<p class="subtitle">Envelope ${escapeHtml(envelope.envelope_number)} · Emitido em ${fmtDateTime(today)}</p>
<table class="cert-table">
<tbody>
<tr><td colspan="2" class="section-header center">IDENTIFICAÇÃO DA TRANSAÇÃO</td></tr>
<tr><td>Número do envelope: ${escapeHtml(envelope.envelope_number)}</td><td>Status: ASSINADO E ÍNTEGRO</td></tr>
<tr><td>Cliente: ${escapeHtml(clientLabel)}</td><td>Documentos: ${documents.length}</td></tr>
<tr><td>Assinatura concluída em: ${fmtDateTime(envelope.signed_at)}</td><td>Tipo: Assinatura Eletrônica</td></tr>
<tr><td colspan="2">Método de autenticação: Código de uso único (OTP) enviado por e-mail</td></tr>
</tbody>
</table>
<table class="cert-table">
<thead>
<tr><th colspan="4" class="section-header center">DOCUMENTOS INTEGRANTES</th></tr>
<tr class="subhead"><th>Documento</th><th>Versão</th><th>Páginas</th><th>SHA-256</th></tr>
</thead>
<tbody>${documentRows}</tbody>
</table>
<p class="hint">Algoritmo de hash: SHA-256, calculado sobre cada documento congelado no momento da criação do envelope.</p>
${signerBlocks}
<table class="cert-table">
<tbody>
<tr><td class="section-header">MANIFESTAÇÃO DE VONTADE</td></tr>
<tr><td>O(s) signatário(s) confirmaram os dados apresentados, tiveram acesso aos documentos integrantes deste envelope (com confirmação individual de leitura por documento), concordaram com a utilização do meio eletrônico, validaram o código de uso único enviado ao endereço de e-mail cadastrado e realizaram ação expressa no comando "ASSINAR E CONCORDAR".</td></tr>
</tbody>
</table>
<table class="cert-table">
<tbody>
<tr><td colspan="2" class="section-header">INTEGRIDADE E AUTENTICIDADE</td></tr>
<tr><td>Documentos: ÍNTEGROS</td><td>Autenticação: VALIDADA</td></tr>
<tr><td>Manifestação de vontade: REGISTRADA</td><td>Evidence Checkpoint: VÁLIDO</td></tr>
<tr><td colspan="2" class="mono">Hash do envelope (documentos + signatários): ${envelope.envelope_hash ?? "—"}</td></tr>
<tr><td colspan="2" class="mono">Identificador do checkpoint: ${envelope.evidence_checkpoint_hash ?? "—"}</td></tr>
<tr><td colspan="2" class="mono">Verification ID: ${envelope.verification_id ?? "—"}</td></tr>
<tr><td colspan="2">Verificação pública: ${escapeHtml(verifyUrl)}</td></tr>
</tbody>
</table>
<div class="legal-footer">
Este certificado foi gerado eletronicamente pela plataforma da Handix do Brasil, registrando as evidências
técnicas do processo de assinatura eletrônica descrito acima (identidade utilizada, autenticação, manifestação
de vontade, integridade documental e rastreabilidade), nos termos do art. 10, §2º, da Medida Provisória
nº 2.200-2/2001 e da Lei nº 14.063/2020. Este documento não constitui certificação digital ICP-Brasil nem
carimbo do tempo de autoridade externa — a Handix não possui, no momento da emissão, esse tipo de integração.
</div>
</div>
</body>
</html>`;
}
function css(): string {
return `
@page { size: A4 portrait; margin: 15mm; }
* { box-sizing: border-box; }
body { font-family: Arial, Helvetica, sans-serif; color: #111827; font-size: 9.5px; margin: 0; }
.header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
.brand { font-size: 16px; font-weight: 700; color: #0F766E; }
.qr { height: 72px; width: 72px; }
h1 { font-size: 14px; font-weight: 700; text-align: center; margin: 0 0 4px; }
.subtitle { font-size: 10px; text-align: center; color: #6b7280; margin: 0 0 16px; }
.cert-table { width: 100%; border-collapse: collapse; margin-bottom: 14px; }
.cert-table td, .cert-table th { border: 1px solid #e5e7eb; padding: 5px 8px; font-size: 9.5px; vertical-align: top; text-align: left; }
.section-header { font-weight: 700; background: #f3f4f6; }
.subhead th { font-weight: 700; background: #f9fafb; }
.center { text-align: center; }
.mono { font-family: monospace; font-size: 8px; word-break: break-all; }
.hint { font-size: 8px; color: #9ca3af; margin: -8px 0 14px; }
.signer-block { page-break-inside: avoid; }
.legal-footer { font-size: 8px; color: #9ca3af; line-height: 1.5; border-top: 1px solid #e5e7eb; padding-top: 8px; }
`;
}

View File

@@ -0,0 +1,5 @@
import { IsOptional, IsString } from "class-validator";
export class CancelEnvelopeDto {
@IsOptional() @IsString() reason?: string;
}

View File

@@ -0,0 +1,51 @@
import { Type } from "class-transformer";
import {
ArrayMinSize,
IsArray,
IsEmail,
IsIn,
IsOptional,
IsString,
IsUUID,
MinLength,
ValidateNested,
} from "class-validator";
export class SignerInputDto {
@IsOptional() @IsIn(["pf", "pj"]) personType?: "pf" | "pj";
@IsString() @MinLength(1) fullName!: string;
@IsOptional() @IsString() cpf?: string;
@IsEmail() email!: string;
@IsOptional() @IsString() phone?: string;
@IsOptional() @IsString() companyName?: string;
@IsOptional() @IsString() cnpj?: string;
@IsOptional() @IsString() roleTitle?: string;
}
/**
* The `contratada` signer is never in this array — it's always
* auto-populated server-side from `companies.legal_rep_*` (eden.md
* §5.3/§5.4). This DTO only carries the `contratante` signer(s).
*/
export class CreateEnvelopeDto {
@IsUUID() companyId!: string;
@IsOptional() @IsUUID() clientRegistrationId?: string;
@IsOptional() @IsUUID() resellerId?: string;
/** `document_generations.id` — already-generated, S3-backed PDFs to freeze into this envelope (see the migration's header comment for why EDEN sources from here instead of a generic attachment table). */
@IsArray()
@ArrayMinSize(1)
@IsUUID("4", { each: true })
documentGenerationIds!: string[];
@IsArray()
@ArrayMinSize(1)
@ValidateNested({ each: true })
@Type(() => SignerInputDto)
signers!: SignerInputDto[];
}

View File

@@ -0,0 +1,6 @@
import { IsIn } from "class-validator";
export class SendInviteDto {
@IsIn(["contratada", "contratante", "both"])
targets!: "contratada" | "contratante" | "both";
}

View File

@@ -0,0 +1,5 @@
import { IsString, Length } from "class-validator";
export class VerifyOtpDto {
@IsString() @Length(6, 6) code!: string;
}

View File

@@ -0,0 +1,30 @@
import { createHash } from "node:crypto";
import type { EnvelopeWithDetails } from "@eden/database";
/**
* eden.md §8.4 — SHA-256 of the envelope's logical facts (never the PDF
* bytes): plain `JSON.stringify` (not the recursive-sort canonicalJson
* used by the hash-chain) is safe here because this object is built
* fresh in JS every time, with the same key order both when sealing and
* when re-verifying — it never round-trips through a jsonb column, which
* is the only thing that made canonicalization necessary elsewhere.
* documents/signers always read pre-sorted by sort_order (a stable
* column), so the result is deterministic and independently
* recomputable — used both by EnvelopeFinalizeService (to seal it) and
* PublicVerifyService (to prove nothing changed since).
*/
export function computeEnvelopeHash(envelope: EnvelopeWithDetails, evidenceCheckpointHash: string): string {
const canonical = {
envelope_number: envelope.envelope_number,
evidence_checkpoint_hash: evidenceCheckpointHash,
documents: envelope.documents
.slice()
.sort((a, b) => a.sort_order - b.sort_order)
.map((d) => ({ title: d.title, version: d.document_version, sha256: d.original_sha256 })),
signers: envelope.signers
.slice()
.sort((a, b) => a.sort_order - b.sort_order)
.map((s) => ({ party: s.party, full_name: s.full_name, cpf: s.cpf, email: s.email, status: s.status })),
};
return createHash("sha256").update(JSON.stringify(canonical)).digest("hex");
}

View File

@@ -0,0 +1,234 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Inject,
Injectable,
NotFoundException,
} from "@nestjs/common";
import { createHash, randomUUID } from "node:crypto";
import {
companiesRepo,
documentsRepo,
signaturesRepo,
type ActorType,
type EnvelopeWithDetails,
type SignatureEnvelope,
} from "@eden/database";
import { getObjectStream, getPdfPageCount, sendMail, uploadObject } from "@eden/integrations";
import type { EdenEnv } from "@eden/config";
import { EDEN_ENV } from "../config/config.constants.js";
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
import { maxGrantedScope } from "../auth/permissions.js";
import { generateSignerToken, hashSignerToken, SIGNER_TOKEN_TTL_MS } from "./token.js";
import type { CreateEnvelopeDto } from "./dto/create-envelope.dto.js";
import type { SendInviteDto } from "./dto/send-invite.dto.js";
import type { CancelEnvelopeDto } from "./dto/cancel-envelope.dto.js";
const { EnvelopeNotFoundError, InvalidEnvelopeTransitionError } = signaturesRepo;
export interface RequestContext {
ipAddress: string | null;
userAgent: string | null;
}
function resolveActorType(roleKey: string): ActorType {
if (roleKey === "super_admin") return "SUPER_ADMIN";
if (roleKey === "admin") return "ADMIN";
return "BACKOFFICE";
}
async function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks);
}
@Injectable()
export class EnvelopeService {
constructor(@Inject(EDEN_ENV) private readonly env: EdenEnv) {}
async list(user: EdenAuthenticatedUser) {
const scope = maxGrantedScope(user.permissions, "signatures", "view");
if (!scope) throw new ForbiddenException("Sem permissão para 'view' em 'signatures'");
if (scope === "all") return signaturesRepo.listEnvelopes({ scope: "all" });
return signaturesRepo.listEnvelopes({ scope: "reseller", resellerId: user.resellerId });
}
async get(id: string, user: EdenAuthenticatedUser): Promise<EnvelopeWithDetails> {
const scope = maxGrantedScope(user.permissions, "signatures", "view");
const envelope = await signaturesRepo.getEnvelopeById(id);
if (!envelope || !scope) throw new NotFoundException("Envelope de assinatura não encontrado");
if (scope !== "all" && envelope.reseller_id !== user.resellerId) {
throw new NotFoundException("Envelope de assinatura não encontrado");
}
return envelope;
}
/**
* Transação única (eden.md §5.4): resolve a empresa emissora e recusa
* se ela não tiver representante legal cadastrado, congela cada
* document_generations indicado (cópia S3 própria + SHA-256
* recalculado — nunca confia cegamente no pdf_sha256 já gravado, ainda
* que ele já devesse bater), insere os signatários (contratada
* automático + contratante(s) do formulário), e transiciona
* DRAFT->READY.
*/
async create(dto: CreateEnvelopeDto, user: EdenAuthenticatedUser): Promise<EnvelopeWithDetails> {
const scope = maxGrantedScope(user.permissions, "signatures", "edit");
if (!scope) throw new ForbiddenException("Sem permissão para 'edit' em 'signatures'");
const hasClient = dto.clientRegistrationId != null;
const hasReseller = dto.resellerId != null;
if (hasClient === hasReseller) {
throw new BadRequestException("Informe exatamente uma origem: clientRegistrationId OU resellerId");
}
const company = await companiesRepo.getCompanyById(dto.companyId);
if (!company) throw new NotFoundException("Empresa não encontrada");
if (!company.legal_rep_name || !company.legal_rep_cpf || !company.legal_rep_email) {
throw new BadRequestException(
"Esta empresa não tem representante legal cadastrado (nome/CPF/e-mail) — cadastre antes de criar um envelope de assinatura.",
);
}
const documents = [];
for (const generationId of dto.documentGenerationIds) {
const generation = await documentsRepo.getGenerationById(generationId);
if (!generation) throw new BadRequestException(`Geração de documento ${generationId} não encontrada`);
if (generation.status !== "final" || !generation.pdf_s3_key || !generation.pdf_sha256) {
throw new BadRequestException(`Geração de documento ${generationId} ainda não tem um PDF disponível`);
}
const buffer = await streamToBuffer(await getObjectStream(generation.pdf_s3_key));
const sha256 = createHash("sha256").update(buffer).digest("hex");
const frozenKey = `signatures/frozen/${randomUUID()}.pdf`;
await uploadObject(frozenKey, buffer, "application/pdf");
const pageCount = await getPdfPageCount(buffer);
const template = await documentsRepo.getTemplateById(generation.template_id);
documents.push({
sourceGenerationId: generation.id,
title: template?.name ?? `Documento ${documents.length + 1}`,
sourceMimeType: "application/pdf",
sourceS3Key: frozenKey,
originalSha256: sha256,
pageCount,
});
}
const signers = [
{
party: "contratada" as const,
fullName: company.legal_rep_name,
cpf: company.legal_rep_cpf,
email: company.legal_rep_email,
roleTitle: company.legal_rep_role,
},
...dto.signers.map((s) => ({
party: "contratante" as const,
personType: s.personType ?? null,
fullName: s.fullName,
cpf: s.cpf ?? null,
email: s.email,
phone: s.phone ?? null,
companyName: s.companyName ?? null,
cnpj: s.cnpj ?? null,
roleTitle: s.roleTitle ?? null,
})),
];
const envelope = await signaturesRepo.createEnvelope(
{
companyId: dto.companyId,
clientRegistrationId: dto.clientRegistrationId ?? null,
resellerId: dto.resellerId ?? null,
createdBy: user.id,
documents,
signers,
},
{
actorType: resolveActorType(user.roleKey),
actorId: user.id,
actorRole: user.roleKey,
ipAddress: null,
userAgent: null,
},
);
return envelope;
}
/** eden.md §5.5: gera um token novo por signatário-alvo ainda não assinado (revoga implicitamente o anterior), 7 dias de validade, e-mail com o link. Envelope READY->SENT na primeira vez. */
async sendInvite(id: string, dto: SendInviteDto, user: EdenAuthenticatedUser): Promise<EnvelopeWithDetails> {
const envelope = await this.get(id, user);
const actor = { actorType: resolveActorType(user.roleKey), actorId: user.id, actorRole: user.roleKey };
const targetParties = dto.targets === "both" ? ["contratada", "contratante"] : [dto.targets];
const targets = envelope.signers.filter((s) => targetParties.includes(s.party) && s.status !== "SIGNED");
for (const signer of targets) {
const plaintextToken = generateSignerToken();
const tokenHash = hashSignerToken(plaintextToken);
const expiresAt = new Date(Date.now() + SIGNER_TOKEN_TTL_MS);
await signaturesRepo.setSignerToken(signer.id, tokenHash, expiresAt);
if (signer.status === "PENDING") {
await signaturesRepo.updateSignerStatus(signer.id, "LINK_SENT", ["PENDING"]);
}
const signUrl = `${this.env.PUBLIC_BASE_URL ?? "http://localhost:3001"}/assinar/${plaintextToken}`;
await sendMail({
to: signer.email,
subject: `Handix — Assinatura eletrônica pendente (${envelope.envelope_number})`,
html: `<p>Olá, ${signer.full_name}.</p><p>Você tem um documento aguardando assinatura eletrônica.</p><p><a href="${signUrl}">Acessar e assinar</a></p><p>Este link expira em 7 dias.</p>`,
});
await signaturesRepo.recordEvent({
envelopeId: envelope.id,
signerId: signer.id,
eventType: "INVITE_SENT",
...actor,
});
}
if (envelope.status === "READY") {
await signaturesRepo.updateEnvelopeStatus(id, "SENT");
}
return this.get(id, user);
}
async cancel(id: string, dto: CancelEnvelopeDto, user: EdenAuthenticatedUser): Promise<SignatureEnvelope> {
await this.get(id, user);
try {
return await signaturesRepo.cancelEnvelope(id, dto.reason ?? null);
} catch (err) {
if (err instanceof EnvelopeNotFoundError) throw new NotFoundException("Envelope de assinatura não encontrado");
if (err instanceof InvalidEnvelopeTransitionError) throw new ConflictException(err.message);
throw err;
}
}
/** Exclusão física — só permitida antes de SIGNED (eden.md §1.9). Depois disso, só cancelamento (dados preservados). */
async delete(id: string, user: EdenAuthenticatedUser): Promise<void> {
const envelope = await this.get(id, user);
const irreversibleStates = ["SIGNED", "FINALIZING", "COMPLETED"];
if (irreversibleStates.includes(envelope.status)) {
throw new ConflictException(
"Este envelope já tem manifestação de vontade registrada — só é possível cancelar, nunca excluir.",
);
}
await signaturesRepo.deleteEnvelope(id);
}
async verifyAudit(id: string, user: EdenAuthenticatedUser) {
await this.get(id, user);
return signaturesRepo.verifyChain(id);
}
async listAuditEvents(id: string, user: EdenAuthenticatedUser) {
await this.get(id, user);
return signaturesRepo.listAuditEvents(id);
}
}

View File

@@ -0,0 +1,155 @@
import { createHash, randomBytes } from "node:crypto";
import { ConflictException, ForbiddenException, Inject, Injectable, NotFoundException } from "@nestjs/common";
import { signaturesRepo, type EnvelopeWithDetails, type SignatureEnvelope } from "@eden/database";
import { getObjectStream, mergePdfs, renderPdf, sendMail, uploadObject } from "@eden/integrations";
import type { EdenEnv } from "@eden/config";
import { EDEN_ENV } from "../config/config.constants.js";
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
import { maxGrantedScope } from "../auth/permissions.js";
import { buildCertificateHtml } from "./certificate-template.js";
import { computeEnvelopeHash } from "./envelope-hash.js";
const { EnvelopeNotFoundError, InvalidEnvelopeTransitionError } = signaturesRepo;
async function streamToBuffer(stream: NodeJS.ReadableStream): Promise<Buffer> {
const chunks: Buffer[] = [];
for await (const chunk of stream) {
chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
}
return Buffer.concat(chunks);
}
/**
* Finalização em dois passos deliberados (eden.md §8.1) — verification_id
* e evidence_checkpoint_hash precisam existir ANTES do certificado ser
* desenhado, porque são impressos nele (inclusive como QR code). Mesmo o
* EDEN gerando o certificado no servidor (não precisa mais do navegador
* de ninguém), os dois passos continuam sendo ações explícitas e
* autenticadas do backoffice — ver o doc-comment em
* PublicSignatureService.commitSignature para o porquê de não ter virado
* automático.
*/
@Injectable()
export class EnvelopeFinalizeService {
constructor(@Inject(EDEN_ENV) private readonly env: EdenEnv) {}
private async getScoped(id: string, user: EdenAuthenticatedUser): Promise<EnvelopeWithDetails> {
const scope = maxGrantedScope(user.permissions, "signatures", "edit");
if (!scope) throw new ForbiddenException("Sem permissão para 'edit' em 'signatures'");
const envelope = await signaturesRepo.getEnvelopeById(id);
if (!envelope) throw new NotFoundException("Envelope de assinatura não encontrado");
if (scope !== "all" && envelope.reseller_id !== user.resellerId) {
throw new NotFoundException("Envelope de assinatura não encontrado");
}
return envelope;
}
/**
* Passo 1 — pré-condição status === 'SIGNED'. Computa evidence_checkpoint_hash
* (tail da cadeia de auditoria neste momento) e envelope_hash (eden.md
* §8.4 — SHA-256 dos fatos lógicos: envelope_number + checkpoint hash +
* documentos/signatários ordenados por sort_order), reserva
* verification_id, e transiciona SIGNED->FINALIZING.
*/
async sealCheckpoint(id: string, user: EdenAuthenticatedUser): Promise<SignatureEnvelope> {
const envelope = await this.getScoped(id, user);
if (envelope.status !== "SIGNED") {
throw new ConflictException(
`Envelope precisa estar SIGNED para selar o checkpoint (status atual: ${envelope.status})`,
);
}
const evidenceCheckpointHash = await signaturesRepo.peekChainTail(id);
const verificationId = randomBytes(16).toString("hex");
const envelopeHash = computeEnvelopeHash(envelope, evidenceCheckpointHash);
let sealed: SignatureEnvelope;
try {
sealed = await signaturesRepo.sealEvidenceCheckpoint(id, verificationId, evidenceCheckpointHash, envelopeHash);
} catch (err) {
if (err instanceof EnvelopeNotFoundError) throw new NotFoundException("Envelope de assinatura não encontrado");
if (err instanceof InvalidEnvelopeTransitionError) throw new ConflictException(err.message);
throw err;
}
const actor = {
actorType: "BACKOFFICE" as const,
actorId: user.id,
actorRole: user.roleKey,
};
await signaturesRepo.recordEvent({ envelopeId: id, eventType: "EVIDENCE_CHECKPOINT_SEALED", ...actor });
await signaturesRepo.recordEvent({ envelopeId: id, eventType: "ENVELOPE_HASH_CALCULATED", ...actor });
return sealed;
}
/**
* Passo 2 — pré-condição status === 'FINALIZING'. Renderiza o
* certificado no servidor (Chromium — diferença deliberada do legado,
* ver o header comment de certificate-template.ts), consolida com os
* documentos congelados via pdf-lib (ordem: documentos por sort_order,
* certificado por último), calcula o SHA-256 final, arquiva, e
* transiciona FINALIZING->COMPLETED. O e-mail final (best-effort, fora
* do "commit" lógico — uma falha de envio nunca desfaz a conclusão) é
* o único passo que roda depois de tudo já persistido.
*/
async finalize(id: string, user: EdenAuthenticatedUser): Promise<SignatureEnvelope> {
const envelope = await this.getScoped(id, user);
if (envelope.status !== "FINALIZING") {
throw new ConflictException(
`Envelope precisa estar FINALIZING para finalizar (status atual: ${envelope.status})`,
);
}
const actor = { actorType: "BACKOFFICE" as const, actorId: user.id, actorRole: user.roleKey };
const auditEvents = await signaturesRepo.listAuditEvents(id);
const verifyUrl = `${this.env.PUBLIC_BASE_URL ?? "http://localhost:3001"}/verificar/${envelope.verification_id}`;
const certificateHtml = await buildCertificateHtml({
envelope,
documents: envelope.documents,
signers: envelope.signers,
auditEvents,
verifyUrl,
});
const certificateBuffer = await renderPdf({ html: certificateHtml });
const certificateS3Key = `signatures/${id}/certificate.pdf`;
await uploadObject(certificateS3Key, certificateBuffer, "application/pdf");
await signaturesRepo.recordEvent({ envelopeId: id, eventType: "CERTIFICATE_GENERATED", ...actor });
const documentBuffers = await Promise.all(
envelope.documents
.slice()
.sort((a, b) => a.sort_order - b.sort_order)
.map(async (doc) => streamToBuffer(await getObjectStream(doc.source_s3_key))),
);
const { buffer: mergedBuffer, pageCount } = await mergePdfs([...documentBuffers, certificateBuffer]);
const finalPdfSha256 = createHash("sha256").update(mergedBuffer).digest("hex");
const finalS3Key = `signatures/${id}/final.pdf`;
await uploadObject(finalS3Key, mergedBuffer, "application/pdf");
await signaturesRepo.recordEvent({
envelopeId: id,
eventType: "CONSOLIDATED_PDF_GENERATED",
...actor,
metadata: { pageCount },
});
await signaturesRepo.recordEvent({ envelopeId: id, eventType: "FINAL_PDF_HASH_CALCULATED", ...actor });
await signaturesRepo.setFinalizationFiles(id, { certificateS3Key, finalS3Key, finalPdfSha256 });
const completed = await signaturesRepo.updateEnvelopeStatus(id, "COMPLETED");
await signaturesRepo.recordEvent({ envelopeId: id, eventType: "ENVELOPE_COMPLETED", ...actor });
for (const signer of envelope.signers.filter((s) => s.party === "contratante")) {
await sendMail({
to: signer.email,
subject: `Handix — Documento assinado (${envelope.envelope_number})`,
html: `<p>Olá, ${signer.full_name}.</p><p>Seu documento foi assinado eletronicamente por todas as partes. Segue em anexo o PDF final com certificado de autenticidade.</p><p>Verificação pública: <a href="${verifyUrl}">${verifyUrl}</a></p>`,
attachments: [{ filename: `${envelope.envelope_number}.pdf`, content: mergedBuffer, contentType: "application/pdf" }],
});
}
await signaturesRepo.recordEvent({ envelopeId: id, eventType: "FINAL_DOCUMENT_EMAILED", ...actor });
return completed;
}
}

View File

@@ -0,0 +1,24 @@
import { createHmac, randomInt } from "node:crypto";
/**
* OTP algorithm — eden.md §6, fork-verified against the legacy's exact
* `otpService.js`. This is the single most security-sensitive piece of
* this module; do not "simplify" without re-reading that section.
*/
export const OTP_TTL_MS = 5 * 60 * 1000;
export const OTP_DEFAULT_MAX_ATTEMPTS = 5;
/** 6 digits, `0` to `999999`, zero-padded — `crypto.randomInt`, never `Math.random()`. */
export function generateOtpCode(): string {
return String(randomInt(0, 1000000)).padStart(6, "0");
}
/**
* HMAC-SHA256 with a server secret — deliberately NOT a plain SHA-256:
* the code space is only 10^6 values, trivially rainbow-tableable
* without a secret. The plaintext code is never persisted, logged, or
* returned by any API response — only emailed.
*/
export function hashOtpCode(code: string, secret: string): string {
return createHmac("sha256", secret).update(code).digest("hex");
}

View File

@@ -0,0 +1,61 @@
import { Body, Controller, Get, Param, Post, Req } from "@nestjs/common";
import { ApiTags } from "@nestjs/swagger";
import type { Request } from "express";
import { PublicSignatureService } from "./public-signature.service.js";
import { VerifyOtpDto } from "./dto/verify-otp.dto.js";
function requestContext(req: Request) {
return {
ipAddress: req.ip ?? null,
userAgent: req.headers["user-agent"] ?? null,
};
}
/**
* Fluxo público de assinatura — eden.md §10. Sem autenticação de sessão:
* autorização é inteiramente pelo token do link (`:token`), nunca por
* `envelopeId`/`signerId` de corpo de requisição. Rate limiting (por IP e
* por token, eden.md §10.3/§6.8) é uma lacuna conhecida — ver
* docs/progress.md, mesmo padrão de outros endpoints públicos deste
* projeto (ex. forgot-password) que também não têm rate limit ainda.
*/
@ApiTags("public-signature")
@Controller("v1/public-signature")
export class PublicSignatureController {
constructor(private readonly publicSignature: PublicSignatureService) {}
@Get(":token")
getState(@Param("token") token: string) {
return this.publicSignature.getPublicState(token);
}
@Post(":token/documents/:documentId/confirm-read")
confirmDocumentRead(@Param("token") token: string, @Param("documentId") documentId: string, @Req() req: Request) {
return this.publicSignature.confirmDocumentRead(token, documentId, requestContext(req));
}
@Post(":token/confirm-data")
confirmData(@Param("token") token: string, @Req() req: Request) {
return this.publicSignature.confirmData(token, requestContext(req));
}
@Post(":token/consent")
consent(@Param("token") token: string, @Req() req: Request) {
return this.publicSignature.acceptConsent(token, requestContext(req));
}
@Post(":token/otp/request")
requestOtp(@Param("token") token: string, @Req() req: Request) {
return this.publicSignature.requestOtp(token, requestContext(req));
}
@Post(":token/otp/verify")
verifyOtp(@Param("token") token: string, @Body() dto: VerifyOtpDto, @Req() req: Request) {
return this.publicSignature.verifyOtp(token, dto.code, requestContext(req));
}
@Post(":token/sign")
sign(@Param("token") token: string, @Req() req: Request) {
return this.publicSignature.commitSignature(token, requestContext(req));
}
}

View File

@@ -0,0 +1,268 @@
import { BadRequestException, ConflictException, GoneException, Inject, Injectable, NotFoundException } from "@nestjs/common";
import { signaturesRepo, type EnvelopeWithDetails, type SignatureSigner } from "@eden/database";
import { sendMail } from "@eden/integrations";
import type { EdenEnv } from "@eden/config";
import { EDEN_ENV } from "../config/config.constants.js";
import { hashSignerToken } from "./token.js";
import { generateOtpCode, hashOtpCode, OTP_DEFAULT_MAX_ATTEMPTS, OTP_TTL_MS } from "./otp.js";
import { parseUserAgent } from "./user-agent.js";
export interface PublicRequestContext {
ipAddress: string | null;
userAgent: string | null;
}
/**
* Rota pública de assinatura — eden.md §10. Autorização SOMENTE pelo
* token do link: `envelope_id`/`signer_id` nunca são aceitos do corpo da
* requisição, tudo deriva do signatário que `resolveSigner` resolveu a
* partir do hash do token. Cada ação sensível exige o estado anterior
* correto no banco — não é possível pular etapas manipulando chamadas de
* API diretamente.
*/
@Injectable()
export class PublicSignatureService {
constructor(@Inject(EDEN_ENV) private readonly env: EdenEnv) {}
private getOtpSecret(): string {
if (!this.env.SIGNATURE_OTP_SECRET) {
throw new Error("SIGNATURE_OTP_SECRET não configurado — defina essa variável de ambiente antes de usar OTP.");
}
return this.env.SIGNATURE_OTP_SECRET;
}
/** Nunca busca pelo token em claro — só pelo SHA-256 (eden.md §10.1). */
async resolveSigner(token: string): Promise<{ signer: SignatureSigner; envelope: EnvelopeWithDetails }> {
const tokenHash = hashSignerToken(token);
const signer = await signaturesRepo.getSignerByTokenHash(tokenHash);
if (!signer) throw new NotFoundException("Link inválido.");
if (signer.secure_token_revoked_at) throw new GoneException("Este link foi revogado.");
if (signer.secure_token_expires_at && signer.secure_token_expires_at.getTime() < Date.now()) {
throw new GoneException("Este link expirou — solicite um novo convite.");
}
const envelope = await signaturesRepo.getEnvelopeById(signer.envelope_id);
if (!envelope) throw new NotFoundException("Link inválido.");
if (envelope.status === "CANCELLED" || envelope.status === "EXPIRED") {
throw new GoneException("Este envelope não está mais disponível para assinatura.");
}
await signaturesRepo.touchSignerTokenUsage(signer.id);
return { signer, envelope };
}
async getPublicState(token: string) {
const { signer, envelope } = await this.resolveSigner(token);
const actor = this.signerActor(signer, null, null);
let currentSigner = signer;
if (signer.status === "PENDING" || signer.status === "LINK_SENT") {
const updated = await signaturesRepo.updateSignerStatus(signer.id, "VIEWED", ["PENDING", "LINK_SENT"]);
if (updated) currentSigner = updated;
}
await signaturesRepo.recordEvent({ envelopeId: envelope.id, signerId: signer.id, eventType: "LINK_OPENED", ...actor });
const auditEvents = await signaturesRepo.listAuditEvents(envelope.id);
const documentsRead = new Set(
auditEvents
.filter((e) => e.signer_id === signer.id && e.event_type === "DOCUMENT_READ_CONFIRMED")
.map((e) => e.document_id),
);
return {
envelope: { id: envelope.id, envelopeNumber: envelope.envelope_number, status: envelope.status },
signer: { ...currentSigner, documentsRead: Array.from(documentsRead) },
documents: envelope.documents.map((d) => ({
id: d.id,
title: d.title,
documentVersion: d.document_version,
pageCount: d.page_count,
confirmedRead: documentsRead.has(d.id),
})),
};
}
async confirmDocumentRead(token: string, documentId: string, ctx: PublicRequestContext): Promise<void> {
const { signer, envelope } = await this.resolveSigner(token);
if (!envelope.documents.some((d) => d.id === documentId)) {
throw new BadRequestException("Documento não pertence a este envelope.");
}
await signaturesRepo.recordEvent({
envelopeId: envelope.id,
signerId: signer.id,
documentId,
eventType: "DOCUMENT_VIEWED",
...this.signerActor(signer, ctx.ipAddress, ctx.userAgent),
});
await signaturesRepo.recordEvent({
envelopeId: envelope.id,
signerId: signer.id,
documentId,
eventType: "DOCUMENT_READ_CONFIRMED",
...this.signerActor(signer, ctx.ipAddress, ctx.userAgent),
});
}
async confirmData(token: string, ctx: PublicRequestContext): Promise<void> {
const { signer, envelope } = await this.resolveSigner(token);
const updated = await signaturesRepo.updateSignerStatus(signer.id, "DATA_CONFIRMED", ["VIEWED"]);
if (!updated) {
throw new ConflictException("Não é possível confirmar os dados neste momento.");
}
await signaturesRepo.recordEvent({
envelopeId: envelope.id,
signerId: signer.id,
eventType: "CUSTOMER_DATA_CONFIRMED",
...this.signerActor(signer, ctx.ipAddress, ctx.userAgent),
});
}
/** Só permite consentir depois de TODOS os documentos confirmados como lidos — reconsultando o próprio trilho de auditoria, não uma flag separada (eden.md §10.2). */
async acceptConsent(token: string, ctx: PublicRequestContext): Promise<void> {
const { signer, envelope } = await this.resolveSigner(token);
const auditEvents = await signaturesRepo.listAuditEvents(envelope.id);
const confirmedDocIds = new Set(
auditEvents
.filter((e) => e.signer_id === signer.id && e.event_type === "DOCUMENT_READ_CONFIRMED")
.map((e) => e.document_id),
);
const allConfirmed = envelope.documents.every((d) => confirmedDocIds.has(d.id));
if (!allConfirmed) {
throw new ConflictException("Confirme a leitura de todos os documentos antes de continuar.");
}
const updated = await signaturesRepo.updateSignerStatus(signer.id, "CONSENTED", ["DATA_CONFIRMED"]);
if (!updated) {
throw new ConflictException("Não é possível registrar o consentimento neste momento.");
}
await signaturesRepo.recordEvent({
envelopeId: envelope.id,
signerId: signer.id,
eventType: "ELECTRONIC_SIGNATURE_TERMS_ACCEPTED",
...this.signerActor(signer, ctx.ipAddress, ctx.userAgent),
});
}
async requestOtp(token: string, ctx: PublicRequestContext): Promise<void> {
const { signer, envelope } = await this.resolveSigner(token);
if (signer.status !== "CONSENTED") {
throw new ConflictException("Aceite os termos de assinatura eletrônica antes de solicitar o código.");
}
const code = generateOtpCode();
const otpProtectedValue = hashOtpCode(code, this.getOtpSecret());
const challenge = await signaturesRepo.createOtpChallenge({
envelopeId: envelope.id,
signerId: signer.id,
emailSnapshot: signer.email,
otpProtectedValue,
expiresAt: new Date(Date.now() + OTP_TTL_MS),
requestIp: ctx.ipAddress,
requestUserAgent: ctx.userAgent,
});
await signaturesRepo.recordEvent({
envelopeId: envelope.id,
signerId: signer.id,
eventType: "OTP_REQUESTED",
...this.signerActor(signer, ctx.ipAddress, ctx.userAgent),
});
await sendMail({
to: signer.email,
subject: "Handix — Código de verificação para assinatura eletrônica",
html: `<p>Seu código de verificação:</p><p style="font-size:32px;letter-spacing:6px;font-weight:700;">${code}</p><p>Expira em 5 minutos e só pode ser usado uma vez.</p>`,
});
await signaturesRepo.markOtpSent(challenge.id);
await signaturesRepo.recordEvent({
envelopeId: envelope.id,
signerId: signer.id,
eventType: "OTP_SENT",
...this.signerActor(signer, ctx.ipAddress, ctx.userAgent),
});
}
async verifyOtp(token: string, code: string, ctx: PublicRequestContext): Promise<void> {
const { signer, envelope } = await this.resolveSigner(token);
const challenge = await signaturesRepo.getLatestOtpChallengeForSigner(signer.id);
const actor = this.signerActor(signer, ctx.ipAddress, ctx.userAgent);
if (!challenge) throw new BadRequestException("Nenhum código foi solicitado ainda.");
if (challenge.status === "VERIFIED") throw new ConflictException("Código já utilizado.");
if (challenge.status === "BLOCKED") {
throw new ConflictException("Muitas tentativas incorretas — solicite um novo código.");
}
if (challenge.expires_at.getTime() < Date.now()) {
await signaturesRepo.markOtpExpired(challenge.id);
await signaturesRepo.recordEvent({ envelopeId: envelope.id, signerId: signer.id, eventType: "OTP_EXPIRED", ...actor });
throw new ConflictException("Código expirado — solicite um novo.");
}
const expected = challenge.otp_protected_value;
const actual = hashOtpCode(code, this.getOtpSecret());
if (actual !== expected) {
const { attempts, blocked } = await signaturesRepo.recordOtpFailedAttempt(challenge.id, challenge.max_attempts ?? OTP_DEFAULT_MAX_ATTEMPTS);
await signaturesRepo.recordEvent({
envelopeId: envelope.id,
signerId: signer.id,
eventType: "OTP_FAILED",
...actor,
metadata: { attempts },
});
if (blocked) {
await signaturesRepo.recordEvent({ envelopeId: envelope.id, signerId: signer.id, eventType: "OTP_BLOCKED", ...actor });
}
throw new BadRequestException("Código incorreto.");
}
await signaturesRepo.markOtpVerified(challenge.id, ctx.ipAddress, ctx.userAgent);
await signaturesRepo.updateSignerStatus(signer.id, "OTP_VERIFIED", [
"PENDING",
"LINK_SENT",
"VIEWED",
"DATA_CONFIRMED",
"CONSENTED",
"OTP_VERIFIED",
]);
await signaturesRepo.recordEvent({ envelopeId: envelope.id, signerId: signer.id, eventType: "OTP_VERIFIED", ...actor });
}
/** Requer signer.status === 'OTP_VERIFIED' exatamente. Se este for o último signatário pendente, transiciona o envelope SENT->SIGNED diretamente (eden.md §5.1 nota de design). A finalização em si (selar checkpoint + certificado + PDF consolidado) continua sendo uma ação explícita do backoffice (EnvelopeFinalizeService, via API autenticada) — deliberadamente NÃO automática, mesmo o EDEN podendo gerar o certificado no servidor sem depender do navegador de ninguém: o checkpoint humano antes da finalização irreversível é comportamento de negócio do legado, não um detalhe técnico do desenho client-side do certificado. */
async commitSignature(token: string, ctx: PublicRequestContext): Promise<void> {
const { signer, envelope } = await this.resolveSigner(token);
const actor = this.signerActor(signer, ctx.ipAddress, ctx.userAgent);
await signaturesRepo.recordEvent({ envelopeId: envelope.id, signerId: signer.id, eventType: "SIGN_BUTTON_PRESSED", ...actor });
const updated = await signaturesRepo.updateSignerStatus(signer.id, "SIGNED", ["OTP_VERIFIED"]);
if (!updated) {
throw new ConflictException("Valide o código de verificação antes de assinar.");
}
await signaturesRepo.recordEvent({ envelopeId: envelope.id, signerId: signer.id, eventType: "SIGNATURE_COMMITTED", ...actor });
const refreshed = await signaturesRepo.getEnvelopeById(envelope.id);
const allDone = refreshed!.signers.every((s) => s.status === "SIGNED" || s.status === "DECLINED");
if (allDone) {
await signaturesRepo.updateEnvelopeStatus(envelope.id, "SIGNED");
await signaturesRepo.recordEvent({
envelopeId: envelope.id,
eventType: "ENVELOPE_SIGNED",
actorType: "SYSTEM",
actorId: null,
actorRole: null,
});
}
}
private signerActor(signer: SignatureSigner, ipAddress: string | null, userAgent: string | null) {
const parsed = parseUserAgent(userAgent);
return {
actorType: "SIGNER" as const,
actorId: null,
actorRole: signer.party,
ipAddress,
userAgent,
browser: parsed.browser,
operatingSystem: parsed.operatingSystem,
deviceType: parsed.deviceType,
sessionId: (signer.secure_token_hash ?? "").slice(0, 12),
};
}
}

View File

@@ -0,0 +1,14 @@
import { Controller, Get, Param } from "@nestjs/common";
import { ApiTags } from "@nestjs/swagger";
import { PublicVerifyService } from "./public-verify.service.js";
@ApiTags("public-verify")
@Controller("v1/public-verify")
export class PublicVerifyController {
constructor(private readonly publicVerify: PublicVerifyService) {}
@Get(":verificationId")
verify(@Param("verificationId") verificationId: string) {
return this.publicVerify.verify(verificationId);
}
}

View File

@@ -0,0 +1,51 @@
import { Injectable, NotFoundException } from "@nestjs/common";
import { signaturesRepo } from "@eden/database";
import { computeEnvelopeHash } from "./envelope-hash.js";
/** Só a primeira letra de cada palavra do nome (eden.md §9) — nunca CPF, nunca e-mail. */
function maskName(fullName: string): string {
return fullName
.trim()
.split(/\s+/)
.map((word) => `${word[0] ?? ""}${"*".repeat(Math.max(word.length - 1, 0))}`)
.join(" ");
}
/**
* Rota pública de verificação — eden.md §9. Sem autenticação, só
* retorna algo para envelopes COMPLETED (qualquer outro estado é 404,
* mesmo que exista). Recomputa envelope_hash a partir dos dados ATUAIS
* de documentos/signatários e compara com o valor gravado na
* finalização — prova de integridade independente do PDF em si.
*/
@Injectable()
export class PublicVerifyService {
async verify(verificationId: string) {
const envelope = await signaturesRepo.getEnvelopeByVerificationId(verificationId);
if (!envelope || envelope.status !== "COMPLETED") {
throw new NotFoundException("Registro não encontrado.");
}
const chain = await signaturesRepo.verifyChain(envelope.id);
const recomputedHash = computeEnvelopeHash(envelope, envelope.evidence_checkpoint_hash ?? "");
const envelopeHashValid = recomputedHash === envelope.envelope_hash;
return {
envelopeNumber: envelope.envelope_number,
completedAt: envelope.completed_at,
documents: envelope.documents.map((d) => ({
title: d.title,
version: d.document_version,
pageCount: d.page_count,
})),
signers: envelope.signers.map((s) => ({ name: maskName(s.full_name), party: s.party })),
integrityValid: chain.valid && envelopeHashValid,
eventsChecked: chain.eventsChecked,
envelopeHash: envelope.envelope_hash,
finalPdfSha256: envelope.final_pdf_sha256,
evidenceCheckpointHash: envelope.evidence_checkpoint_hash,
verificationId: envelope.verification_id,
method: "Assinatura eletrônica — código de uso único (OTP) por e-mail",
};
}
}

View File

@@ -0,0 +1,91 @@
import { Body, Controller, Delete, Get, Param, Post, UseGuards } from "@nestjs/common";
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { JwtAuthGuard } from "../auth/jwt-auth.guard.js";
import { PermissionGuard } from "../auth/permission.guard.js";
import { RequirePermission } from "../auth/decorators/require-permission.decorator.js";
import { CurrentUser } from "../auth/decorators/current-user.decorator.js";
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
import { EnvelopeService } from "./envelope.service.js";
import { EnvelopeFinalizeService } from "./finalize.service.js";
import { CreateEnvelopeDto } from "./dto/create-envelope.dto.js";
import { SendInviteDto } from "./dto/send-invite.dto.js";
import { CancelEnvelopeDto } from "./dto/cancel-envelope.dto.js";
/**
* Assinatura Eletrônica — rotas autenticadas de backoffice (eden.md §5,
* §11 feature key `assinaturas`). O fluxo público do signatário
* (token do link, sem autenticação) fica em public-signature.controller.ts.
*/
@ApiTags("signature-envelopes")
@ApiBearerAuth()
@UseGuards(JwtAuthGuard, PermissionGuard)
@Controller("v1/signature-envelopes")
export class SignatureEnvelopesController {
constructor(
private readonly envelopes: EnvelopeService,
private readonly finalize: EnvelopeFinalizeService,
) {}
@Get()
@RequirePermission("signatures", "view")
list(@CurrentUser() user: EdenAuthenticatedUser) {
return this.envelopes.list(user);
}
@Get(":id")
@RequirePermission("signatures", "view")
get(@Param("id") id: string, @CurrentUser() user: EdenAuthenticatedUser) {
return this.envelopes.get(id, user);
}
@Post()
@RequirePermission("signatures", "edit")
create(@Body() dto: CreateEnvelopeDto, @CurrentUser() user: EdenAuthenticatedUser) {
return this.envelopes.create(dto, user);
}
@Post(":id/send")
@RequirePermission("signatures", "edit")
sendInvite(@Param("id") id: string, @Body() dto: SendInviteDto, @CurrentUser() user: EdenAuthenticatedUser) {
return this.envelopes.sendInvite(id, dto, user);
}
@Post(":id/cancel")
@RequirePermission("signatures", "edit")
cancel(@Param("id") id: string, @Body() dto: CancelEnvelopeDto, @CurrentUser() user: EdenAuthenticatedUser) {
return this.envelopes.cancel(id, dto, user);
}
/** Exclusão física — só antes de SIGNED (eden.md §1.9). Depois disso, só /cancel. */
@Delete(":id")
@RequirePermission("signatures", "delete")
delete(@Param("id") id: string, @CurrentUser() user: EdenAuthenticatedUser) {
return this.envelopes.delete(id, user);
}
@Get(":id/verify-audit")
@RequirePermission("signatures", "view")
verifyAudit(@Param("id") id: string, @CurrentUser() user: EdenAuthenticatedUser) {
return this.envelopes.verifyAudit(id, user);
}
@Get(":id/audit-events")
@RequirePermission("signatures", "view")
listAuditEvents(@Param("id") id: string, @CurrentUser() user: EdenAuthenticatedUser) {
return this.envelopes.listAuditEvents(id, user);
}
/** Passo 1 da finalização — pré-condição status SIGNED (eden.md §8.2). */
@Post(":id/seal-checkpoint")
@RequirePermission("signatures", "edit")
sealCheckpoint(@Param("id") id: string, @CurrentUser() user: EdenAuthenticatedUser) {
return this.finalize.sealCheckpoint(id, user);
}
/** Passo 2 — pré-condição status FINALIZING (eden.md §8.3). Renderiza o certificado e consolida o PDF final no próprio servidor. */
@Post(":id/finalize")
@RequirePermission("signatures", "edit")
finalizeEnvelope(@Param("id") id: string, @CurrentUser() user: EdenAuthenticatedUser) {
return this.finalize.finalize(id, user);
}
}

View File

@@ -0,0 +1,17 @@
import { Module } from "@nestjs/common";
import { AuthModule } from "../auth/auth.module.js";
import { SignatureEnvelopesController } from "./signatures.controller.js";
import { PublicSignatureController } from "./public-signature.controller.js";
import { PublicVerifyController } from "./public-verify.controller.js";
import { EnvelopeService } from "./envelope.service.js";
import { EnvelopeFinalizeService } from "./finalize.service.js";
import { PublicSignatureService } from "./public-signature.service.js";
import { PublicVerifyService } from "./public-verify.service.js";
@Module({
imports: [AuthModule],
controllers: [SignatureEnvelopesController, PublicSignatureController, PublicVerifyController],
providers: [EnvelopeService, EnvelopeFinalizeService, PublicSignatureService, PublicVerifyService],
exports: [EnvelopeService, EnvelopeFinalizeService],
})
export class SignaturesModule {}

View File

@@ -0,0 +1,18 @@
import { createHash, randomBytes } from "node:crypto";
/**
* Secure signer link token — eden.md §10.1-10.2. Plain SHA-256 (not
* HMAC, unlike OTP): the token already has 256 bits of entropy, so
* there's no small-search-space to defend against with a server secret —
* simple hash suffices. Never search by the plaintext token; only ever
* by its hash.
*/
export const SIGNER_TOKEN_TTL_MS = 7 * 24 * 60 * 60 * 1000;
export function generateSignerToken(): string {
return randomBytes(32).toString("hex");
}
export function hashSignerToken(token: string): string {
return createHash("sha256").update(token).digest("hex");
}

View File

@@ -0,0 +1,38 @@
/**
* Lightweight User-Agent parsing for the signature audit trail —
* deliberately NOT a full fingerprinting library (eden.md §6.9: "só IP +
* user-agent, parse leve... por decisão de produto de não fazer
* fingerprinting invasivo"). Best-effort labels only, never used for any
* security decision — just printed on the certificate's signer timeline.
*/
export interface ParsedUserAgent {
browser: string | null;
operatingSystem: string | null;
deviceType: string | null;
}
export function parseUserAgent(userAgent: string | null | undefined): ParsedUserAgent {
if (!userAgent) return { browser: null, operatingSystem: null, deviceType: null };
let browser: string | null = null;
if (/edg\//i.test(userAgent)) browser = "Edge";
else if (/opr\//i.test(userAgent)) browser = "Opera";
else if (/chrome\//i.test(userAgent)) browser = "Chrome";
else if (/firefox\//i.test(userAgent)) browser = "Firefox";
else if (/safari\//i.test(userAgent)) browser = "Safari";
let operatingSystem: string | null = null;
if (/windows/i.test(userAgent)) operatingSystem = "Windows";
else if (/mac os x/i.test(userAgent)) operatingSystem = "macOS";
else if (/android/i.test(userAgent)) operatingSystem = "Android";
else if (/iphone|ipad|ios/i.test(userAgent)) operatingSystem = "iOS";
else if (/linux/i.test(userAgent)) operatingSystem = "Linux";
const deviceType = /mobile|android|iphone/i.test(userAgent)
? "mobile"
: /ipad|tablet/i.test(userAgent)
? "tablet"
: "desktop";
return { browser, operatingSystem, deviceType };
}

View File

@@ -0,0 +1,309 @@
import { INestApplication } from "@nestjs/common";
import { Readable } from "node:stream";
import { PDFDocument } from "pdf-lib";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { agent, bearer, closeTestApp, createTestApp, loginSuperAdmin, qaEmail, type AuthedTokens } from "./setup.js";
/**
* Full e-signature envelope lifecycle, end to end through the real HTTP
* API — the same rigor as documents-pdf.spec.ts/anatel-pdf.spec.ts, but
* this module is security-critical enough to warrant covering the WHOLE
* state machine in one connected flow (create → send → per-signer public
* flow → sign → seal checkpoint → finalize → public verify), not just
* isolated units. `renderPdf`/`uploadObject`/`getObjectStream`/`sendMail`
* are mocked (an in-memory S3 substitute + captured emails) so the suite
* doesn't need a real Chromium/MinIO/SMTP — `mergePdfs`/`getPdfPageCount`
* are NOT mocked (`vi.importActual`), so the actual pdf-lib merge in
* EnvelopeFinalizeService.finalize() really runs against the mocked
* renderPdf's real (tiny, valid) PDF output.
*/
interface MockSendMailInput {
to: string;
subject: string;
html: string;
attachments?: Array<{ filename: string; content: Buffer; contentType?: string }>;
}
const s3Store = new Map<string, Buffer>();
const sendMailMock = vi.fn(async (_input: MockSendMailInput) => undefined);
vi.mock("@eden/integrations", async () => {
const actual = await vi.importActual<typeof import("@eden/integrations")>("@eden/integrations");
return {
...actual,
renderPdf: vi.fn(async () => {
const doc = await PDFDocument.create();
doc.addPage();
return Buffer.from(await doc.save());
}),
uploadObject: vi.fn(async (key: string, buffer: Buffer) => {
s3Store.set(key, buffer);
}),
getObjectStream: vi.fn(async (key: string) => {
const buffer = s3Store.get(key);
if (!buffer) throw new Error(`qa mock S3: no object at ${key}`);
return Readable.from([buffer]);
}),
sendMail: (input: MockSendMailInput) => sendMailMock(input),
};
});
function extractUrlFromLastEmailTo(to: string, pattern: RegExp): string {
const call = sendMailMock.mock.calls.find((c) => c[0].to === to);
const html = call?.[0].html ?? "";
const match = html.match(pattern);
if (!match?.[1]) throw new Error(`URL not found in email to ${to}`);
return match[1];
}
function extractOtpCodeFromLastEmail(to: string): string {
const calls = sendMailMock.mock.calls.filter((c) => c[0].to === to);
const html = calls.at(-1)?.[0].html ?? "";
const match = html.match(/(\d{6})/);
if (!match?.[1]) throw new Error(`OTP code not found in email to ${to}`);
return match[1];
}
describe("signature envelopes — full lifecycle", () => {
let app: INestApplication;
let admin: AuthedTokens;
let companyId: string;
let resellerId: string;
let generationId: string;
let envelopeId: string;
let documentId: string;
let contratadaEmail: string;
let contratanteEmail: string;
let contratadaToken: string;
let contratanteToken: string;
beforeAll(async () => {
app = await createTestApp();
admin = await loginSuperAdmin(app);
contratadaEmail = qaEmail("sig-contratada");
contratanteEmail = qaEmail("sig-contratante");
const company = await agent(app)
.post("/api/v1/companies")
.set(...bearer(admin.accessToken))
.send({
companyName: "qa-test- Empresa Assinatura",
legalRepName: "Representante Legal QA",
legalRepCpf: "11144477735",
legalRepEmail: contratadaEmail,
legalRepRole: "Diretor",
})
.expect(201);
companyId = company.body.id;
const reseller = await agent(app)
.post("/api/v1/resellers")
.set(...bearer(admin.accessToken))
.send({ name: "qa-test- Revenda Assinatura" })
.expect(201);
resellerId = reseller.body.id;
const template = await agent(app)
.post("/api/v1/documents/templates")
.set(...bearer(admin.accessToken))
.send({ key: `qa_test_sig_${Date.now()}`, name: "qa-test- Template Assinatura", docGroup: "revenda" })
.expect(201);
const templateId = template.body.id;
await agent(app)
.post(`/api/v1/documents/templates/${templateId}/draft`)
.set(...bearer(admin.accessToken))
.expect(201);
await agent(app)
.patch(`/api/v1/documents/templates/${templateId}/draft`)
.set(...bearer(admin.accessToken))
.send({ editorContent: { type: "doc", plainText: "Contrato de teste para assinatura eletrônica." } })
.expect(200);
await agent(app)
.post(`/api/v1/documents/templates/${templateId}/publish`)
.set(...bearer(admin.accessToken))
.expect(201);
const generation = await agent(app)
.post("/api/v1/documents/generations")
.set(...bearer(admin.accessToken))
.send({ templateId, resellerId, context: {} })
.expect(201);
generationId = generation.body.id;
});
afterAll(async () => {
await agent(app)
.patch(`/api/v1/resellers/${resellerId}`)
.set(...bearer(admin.accessToken))
.send({ active: false })
.expect(200);
await closeTestApp(app);
});
it("creates the envelope with an auto contratada signer + the requested contratante", async () => {
const res = await agent(app)
.post("/api/v1/signature-envelopes")
.set(...bearer(admin.accessToken))
.send({
companyId,
resellerId,
documentGenerationIds: [generationId],
signers: [{ fullName: "Cliente QA", cpf: "52998224725", email: contratanteEmail }],
})
.expect(201);
expect(res.body.status).toBe("READY");
expect(res.body.signers).toHaveLength(2);
const contratada = res.body.signers.find((s: { party: string }) => s.party === "contratada");
const contratante = res.body.signers.find((s: { party: string }) => s.party === "contratante");
expect(contratada.full_name).toBe("Representante Legal QA");
expect(contratada.email).toBe(contratadaEmail);
expect(contratante.email).toBe(contratanteEmail);
expect(res.body.documents).toHaveLength(1);
envelopeId = res.body.id;
documentId = res.body.documents[0].id;
});
it("rejects creation for a company with no legal representative", async () => {
const bareCompany = await agent(app)
.post("/api/v1/companies")
.set(...bearer(admin.accessToken))
.send({ companyName: "qa-test- Empresa Sem Rep Legal" })
.expect(201);
const res = await agent(app)
.post("/api/v1/signature-envelopes")
.set(...bearer(admin.accessToken))
.send({
companyId: bareCompany.body.id,
resellerId,
documentGenerationIds: [generationId],
signers: [{ fullName: "Cliente QA 2", cpf: "52998224725", email: qaEmail("sig-nope") }],
})
.expect(400);
expect(res.body.message).toContain("representante legal");
});
it("sends the invite to both parties and transitions to SENT", async () => {
sendMailMock.mockClear();
const res = await agent(app)
.post(`/api/v1/signature-envelopes/${envelopeId}/send`)
.set(...bearer(admin.accessToken))
.send({ targets: "both" })
.expect(201);
expect(res.body.status).toBe("SENT");
expect(sendMailMock).toHaveBeenCalledTimes(2);
contratadaToken = extractUrlFromLastEmailTo(contratadaEmail, /\/assinar\/([a-f0-9]{64})/)!;
contratanteToken = extractUrlFromLastEmailTo(contratanteEmail, /\/assinar\/([a-f0-9]{64})/)!;
expect(contratadaToken).not.toBe(contratanteToken);
});
async function runSignerFlow(token: string, email: string) {
const state = await agent(app).get(`/api/v1/public-signature/${token}`).expect(200);
expect(state.body.signer.status).toBe("VIEWED");
expect(state.body.documents).toHaveLength(1);
await agent(app).post(`/api/v1/public-signature/${token}/documents/${documentId}/confirm-read`).expect(201);
await agent(app).post(`/api/v1/public-signature/${token}/confirm-data`).expect(201);
await agent(app).post(`/api/v1/public-signature/${token}/consent`).expect(201);
sendMailMock.mockClear();
await agent(app).post(`/api/v1/public-signature/${token}/otp/request`).expect(201);
const code = extractOtpCodeFromLastEmail(email);
await agent(app).post(`/api/v1/public-signature/${token}/otp/verify`).send({ code: "000000" }).expect(400);
await agent(app).post(`/api/v1/public-signature/${token}/otp/verify`).send({ code }).expect(201);
await agent(app).post(`/api/v1/public-signature/${token}/otp/verify`).send({ code }).expect(409); // single-use
await agent(app).post(`/api/v1/public-signature/${token}/sign`).expect(201);
}
it("walks the contratada through the full public flow and signs", async () => {
await runSignerFlow(contratadaToken, contratadaEmail);
});
it("envelope is still SENT with one signer left", async () => {
const res = await agent(app)
.get(`/api/v1/signature-envelopes/${envelopeId}`)
.set(...bearer(admin.accessToken))
.expect(200);
expect(res.body.status).toBe("SENT");
});
it("walks the contratante through the full public flow — completing all signers flips the envelope straight to SIGNED", async () => {
await runSignerFlow(contratanteToken, contratanteEmail);
const res = await agent(app)
.get(`/api/v1/signature-envelopes/${envelopeId}`)
.set(...bearer(admin.accessToken))
.expect(200);
expect(res.body.status).toBe("SIGNED");
expect(res.body.signers.every((s: { status: string }) => s.status === "SIGNED")).toBe(true);
});
it("the audit chain verifies clean before finalization", async () => {
const res = await agent(app)
.get(`/api/v1/signature-envelopes/${envelopeId}/verify-audit`)
.set(...bearer(admin.accessToken))
.expect(200);
expect(res.body.valid).toBe(true);
expect(res.body.eventsChecked).toBeGreaterThan(10);
});
let verificationId: string;
it("seals the evidence checkpoint — SIGNED -> FINALIZING", async () => {
const res = await agent(app)
.post(`/api/v1/signature-envelopes/${envelopeId}/seal-checkpoint`)
.set(...bearer(admin.accessToken))
.expect(201);
expect(res.body.status).toBe("FINALIZING");
expect(res.body.verification_id).toMatch(/^[a-f0-9]{32}$/);
expect(res.body.envelope_hash).toMatch(/^[a-f0-9]{64}$/);
verificationId = res.body.verification_id;
});
it("finalize renders the certificate, consolidates the final PDF, and completes the envelope", async () => {
sendMailMock.mockClear();
const res = await agent(app)
.post(`/api/v1/signature-envelopes/${envelopeId}/finalize`)
.set(...bearer(admin.accessToken))
.expect(201);
expect(res.body.status).toBe("COMPLETED");
expect(res.body.final_pdf_sha256).toMatch(/^[a-f0-9]{64}$/);
const finalBuffer = s3Store.get(res.body.final_s3_key);
expect(finalBuffer).toBeDefined();
const merged = await PDFDocument.load(finalBuffer!);
expect(merged.getPageCount()).toBe(2); // 1 frozen document page + 1 certificate page
expect(sendMailMock).toHaveBeenCalledTimes(1); // only the contratante gets the final PDF
expect(sendMailMock.mock.calls[0]![0].to).toBe(contratanteEmail);
});
it("public verify endpoint confirms integrity for the completed envelope", async () => {
const res = await agent(app).get(`/api/v1/public-verify/${verificationId}`).expect(200);
expect(res.body.integrityValid).toBe(true);
expect(res.body.signers.every((s: { name: string }) => /\*/.test(s.name))).toBe(true); // masked
});
it("public verify 404s for a non-completed or unknown verification id", async () => {
await agent(app).get("/api/v1/public-verify/0000000000000000000000000000ff").expect(404);
});
it("a revoked/unknown token is rejected on the public flow", async () => {
await agent(app).get("/api/v1/public-signature/0000000000000000000000000000000000000000000000000000000000000000").expect(404);
});
it("the envelope can no longer be deleted once SIGNED/COMPLETED", async () => {
const res = await agent(app)
.delete(`/api/v1/signature-envelopes/${envelopeId}`)
.set(...bearer(admin.accessToken))
.expect(409);
expect(res.body.message).toMatch(/cancelar/i);
});
});

View File

@@ -0,0 +1,102 @@
import { describe, expect, it } from "vitest";
import { canonicalJson, computeEventHash, signaturesRepo } from "@eden/database";
const { ENVELOPE_STATUSES, ENVELOPE_TRANSITIONS, assertEnvelopeTransition, InvalidEnvelopeTransitionError } =
signaturesRepo;
import { generateOtpCode, hashOtpCode } from "../src/signatures/otp.js";
import { generateSignerToken, hashSignerToken } from "../src/signatures/token.js";
describe("hash-chain canonicalization — pure functions, no DB", () => {
it("key order never changes the canonical string (defends against Postgres jsonb key reordering)", () => {
const a = canonicalJson({ b: 1, a: 2, metadata: { z: 1, y: 2 } });
const b = canonicalJson({ a: 2, b: 1, metadata: { y: 2, z: 1 } });
expect(a).toBe(b);
});
it("undefined values canonicalize to null, never dropped", () => {
expect(canonicalJson({ a: undefined })).toBe('{"a":null}');
});
it("computeEventHash chains — same inputs always produce the same hash (deterministic, reproducible verification)", () => {
const fields = { eventType: "TEST", metadata: { x: 1 } };
const h1 = computeEventHash("", fields);
const h2 = computeEventHash("", fields);
expect(h1).toBe(h2);
expect(h1).toMatch(/^[a-f0-9]{64}$/);
});
it("a different previous hash changes the result — this is what makes the chain tamper-evident", () => {
const fields = { eventType: "TEST" };
const h1 = computeEventHash("aaa", fields);
const h2 = computeEventHash("bbb", fields);
expect(h1).not.toBe(h2);
});
});
describe("envelope state machine — pure, no DB", () => {
it("every terminal status has no outgoing transitions", () => {
for (const terminal of ["COMPLETED", "DECLINED", "EXPIRED", "CANCELLED", "SUPERSEDED", "ERROR"] as const) {
expect(ENVELOPE_TRANSITIONS[terminal]).toEqual([]);
}
});
it("SENT -> SIGNED is a valid direct jump (per-signer granularity never mirrored on the envelope)", () => {
expect(() => assertEnvelopeTransition("SENT", "SIGNED")).not.toThrow();
});
it("rejects an invalid transition (e.g. DRAFT straight to SIGNED)", () => {
expect(() => assertEnvelopeTransition("DRAFT", "SIGNED")).toThrow(InvalidEnvelopeTransitionError);
});
it("rejects going backward out of a terminal state", () => {
expect(() => assertEnvelopeTransition("COMPLETED", "DRAFT")).toThrow(InvalidEnvelopeTransitionError);
});
it("every status in ENVELOPE_STATUSES has an entry in the transition table (no silently-forgotten state)", () => {
for (const status of ENVELOPE_STATUSES) {
expect(ENVELOPE_TRANSITIONS[status]).toBeDefined();
}
});
});
describe("OTP — pure crypto, no DB", () => {
it("generateOtpCode always returns 6 digits, zero-padded", () => {
for (let i = 0; i < 50; i++) {
const code = generateOtpCode();
expect(code).toMatch(/^\d{6}$/);
}
});
it("hashOtpCode is deterministic for the same code+secret, and never returns the plaintext code", () => {
const secret = "a".repeat(32);
const h1 = hashOtpCode("123456", secret);
const h2 = hashOtpCode("123456", secret);
expect(h1).toBe(h2);
expect(h1).not.toContain("123456");
expect(h1).toMatch(/^[a-f0-9]{64}$/);
});
it("a different secret produces a different hash for the same code (rainbow-table resistance depends on this)", () => {
const h1 = hashOtpCode("123456", "a".repeat(32));
const h2 = hashOtpCode("123456", "b".repeat(32));
expect(h1).not.toBe(h2);
});
});
describe("secure signer token — pure crypto, no DB", () => {
it("generateSignerToken returns 64 hex chars (32 bytes/256 bits)", () => {
const token = generateSignerToken();
expect(token).toMatch(/^[a-f0-9]{64}$/);
});
it("two generated tokens are never equal", () => {
expect(generateSignerToken()).not.toBe(generateSignerToken());
});
it("hashSignerToken is deterministic and never returns the plaintext token", () => {
const token = generateSignerToken();
const hash = hashSignerToken(token);
expect(hash).not.toBe(token);
expect(hashSignerToken(token)).toBe(hash);
});
});

View File

@@ -539,3 +539,33 @@ Teste de integração novo (`test/contracts-edit-lock.spec.ts`, 3 testes, papel
### Pendências
`contract_versions` (snapshot-on-signature, invariante nº4 do ADR-0006) segue deliberadamente adiado — precisa do desenho do fluxo de assinatura para não virar uma tabela vazia. `contract_amendments` (forma correta de alterar um contrato já assinado, em vez do override de super_admin) também segue adiado — depende de `contract_versions` primeiro.
---
## Módulo de Assinatura Eletrônica — completo (envelopes, signatários, OTP, hash-chain, finalização, verificação pública)
Data: 2026-09-04. Usuário pediu para seguir com a "questão da assinatura" — última peça do estudo do OrçaFácil. Dado o tamanho e a sensibilidade jurídica/criptográfica do módulo, os algoritmos de segurança (hash-chain, OTP, máquina de estados) foram re-verificados contra o código-fonte real do legado por um subagente `fork` dedicado, cruzado com `eden.md` módulo 4 (que já documentava o módulo com grande precisão, escrito numa leva anterior) — nenhuma linha de crypto foi escrita de memória.
### Schema (`packages/database/migrations/1788536022387_signatures.cjs`)
5 tabelas: `signature_envelopes` (19 estados, `company_id` obrigatório — diferente de `contracts.company_id`, que é opcional — porque `createEnvelope` recusa criar sem representante legal cadastrado), `signature_signers` (snapshot, nunca referência viva), `signature_documents` (cópia congelada + SHA-256), `signature_otp_challenges`, `signature_audit_events` (hash-chain, append-only via a mesma GUC `eden.allow_audit_mutation` que já protege o `audit_log` genérico desde a Fase 1 — reaproveitada, não duplicada). `packages/database/src/hash-chain.ts` novo, compartilhado entre `audit_log` e `signature_audit_events` (a canonicalização recursiva já existia em `audit.ts`, só foi extraída).
**Três adaptações deliberadas ao EDEN, documentadas no cabeçalho da migration** (tudo o mais — os 19 estados, a fórmula do hash-chain, o algoritmo de OTP — é fidelidade exata ao legado):
1. `signature_documents.source_generation_id` referencia `document_generations` (o pipeline de PDF já construído nesta sessão) em vez do `source_attachment_id` do legado — EDEN não tem um módulo de upload de anexo genérico ainda.
2. `reseller_id` em vez de `reseller_registration_id` — EDEN modela revenda como uma tabela só.
3. Só existe a versão 2 (correta) da canonicalização do hash — a v1 do legado existe lá só para tolerar hashes já gravados antes de uma correção de bug real; EDEN nunca teve essa v1.
### Certificado de conclusão — server-side em vez de client-side
O legado desenha o certificado no navegador do backoffice (React → html2canvas → jsPDF) e recebe o PDF pronto via upload multipart em `finalizeEnvelope`. O EDEN já tem Chromium real server-side (construído para Documents/ANATEL) — `apps/api/src/signatures/certificate-template.ts` gera o HTML (conteúdo/texto legal portado verbatim, incluindo o aviso de que **não é certificação ICP-Brasil**) e renderiza no próprio servidor, eliminando o upload multipart inteiramente. Decisão deliberadamente limitada a essa troca técnica: os **dois passos autenticados** de finalização (`seal-checkpoint` depois `finalize`) foram mantidos exatamente como no legado — não virou automático mesmo sendo tecnicamente possível, porque esse checkpoint humano antes de uma finalização irreversível é comportamento de negócio, não um detalhe do desenho client-side do certificado.
### Fluxo (`apps/api/src/signatures/`)
`EnvelopeService` (criação — congela documentos com cópia S3 + SHA-256 recalculado, nunca confia cegamente no hash já gravado —, envio de convite, cancelamento, exclusão física só antes de SIGNED, verificação da cadeia), `PublicSignatureService` (fluxo do signatário só por token — nunca por `envelopeId`/`signerId` do corpo da requisição —, cada ação exige o estado anterior correto: confirmar leitura de cada documento → confirmar dados → aceitar termos → solicitar/validar OTP → assinar), `EnvelopeFinalizeService` (os dois passos), `PublicVerifyService` (só para `COMPLETED`, recomputa `envelope_hash` ao vivo e compara, nomes mascarados). Nota de design importante preservada do legado: o **envelope** salta direto de `SENT` para `SIGNED` quando o último signatário termina — o progresso fino (`VIEWED`..`OTP_VERIFIED`) vive inteiramente em `signature_signers.status`, nunca é espelhado passo a passo no envelope.
### Verificação
Suíte de testes nova e grande: `signature-hash-chain.spec.ts` (15 testes, puros, sem banco — canonicalização, determinismo do hash, máquina de estados, OTP, token) e `signature-envelope-flow.spec.ts` (13 testes, ciclo de vida completo via HTTP real: criação com signatário `contratada` automático, recusa sem representante legal, convite, os dois signatários passando pelo fluxo público inteiro incluindo código OTP errado/bloqueio/uso único, `SENT`→`SIGNED` no último signatário, cadeia de auditoria válida, `seal-checkpoint`, `finalize` com merge real via pdf-lib — confirmado contando páginas do PDF final —, `public-verify` com integridade válida e nomes mascarados, e proteção contra exclusão pós-assinatura). `renderPdf`/`uploadObject`/`getObjectStream`/`sendMail` mockados (S3 em memória); `mergePdfs`/`getPdfPageCount` NÃO mockados — o merge de PDF de verdade roda contra a saída (pequena, mas válida) do `renderPdf` mockado. Suíte completa: 70/70 verdes (42→70). `pnpm turbo run typecheck build --force` (28/28).
### Pendências
Rate limiting nas rotas públicas (por IP e por token, eden.md §6.8/§10.3) — lacuna conhecida, mesmo padrão de outros endpoints públicos deste projeto que também não têm ainda (ex. forgot-password). UI de frontend para todo o módulo (criação/envio/acompanhamento de envelope no backoffice, tela pública de assinatura do signatário, página pública de verificação) — não construída nesta leva, só o backend. `DECLINED` (recusa de assinatura) é um estado reservado no schema mas sem rota que o produza — mesma lacuna do legado, nunca foi implementado lá também.

View File

@@ -0,0 +1,283 @@
/**
* Assinatura Eletrônica — eden.md módulo 4, §§1.4-1.9 e 5-10. Deliberadamente
* adiado desde 1788468521075_documents.cjs ("cryptographically/legally
* sensitive... needs its own dedicated migration and review"). Ported com
* fidelidade algorítmica exata (máquina de estados, hash-chain, OTP) — as
* únicas divergências deliberadas do legado, documentadas caso a caso
* abaixo, são adaptações à própria arquitetura do EDEN, nunca ao conteúdo
* de segurança/negócio em si:
*
* - `signature_documents.source_generation_id` referencia
* `document_generations(id)` em vez do `source_attachment_id` do
* legado — EDEN ainda não tem um módulo de upload de anexo genérico
* (client_registration_attachments/reseller_registration_attachments
* nunca foram portados), mas já tem `document_generations` (PDFs reais,
* gerados via Chromium, com SHA-256 e S3 desde a leva anterior desta
* sessão) — a fonte natural de "documento já gerado, hasheado, pronto
* para congelar" no EDEN. Igual ao legado, é só referência histórica:
* uma vez congelado em `signature_documents`, a cópia S3 própria e o
* hash já congelados são a fonte da verdade, não a FK.
* - `reseller_id` (FK para `resellers`) em vez de `reseller_registration_id`
* — o EDEN não tem uma entidade "cadastro de revenda" separada da
* revenda em si (eden.md documenta `reseller_registration_id` como a
* origem alternativa a `client_registration_id`, mas o EDEN modela
* revenda como uma única tabela `resellers`, não duas). Os dados do
* signatário `contratante` são sempre informados explicitamente na
* criação do envelope em ambos os sistemas (nunca derivados
* automaticamente do cadastro de origem) — só o signatário
* `contratada` é auto-preenchido, de `companies.legal_rep_*` — então
* essa troca não perde nenhum dado necessário.
* - `hash_algorithm_version` existe mas só a versão 2 (a fórmula correta,
* canonicalização recursiva em profundidade) é usada — ver o
* doc-comment de packages/database/src/hash-chain.ts para o porquê da
* v1 do legado (compat com hashes já gravados antes de uma correção de
* bug real) não fazer sentido aqui: o EDEN nunca teve uma v1 buggy.
* - Certificado de conclusão: o legado desenha client-side (React →
* html2canvas → jsPDF, recebido via upload multipart em
* `finalizeEnvelope`). O EDEN já tem Chromium server-side real
* (packages/integrations/src/pdf/renderer.ts, construído nesta mesma
* sessão) — o certificado é renderizado no servidor a partir dos
* mesmos dados já gravados (evidence_checkpoint_hash/verification_id/
* envelope_hash), eliminando o upload multipart do passo 2 inteiramente
* (o servidor nunca precisa confiar num PDF vindo de fora como "o
* certificado"). Ver apps/api/src/signatures/.
*
* Tudo o mais — os 19 estados do envelope, os 8 estados do signatário, o
* algoritmo de hash-chain (SHA256(previous + canonical_json(campos)),
* `seq` bigserial definindo a ordem, não timestamp), o OTP (6 dígitos via
* crypto.randomInt, HMAC-SHA256 com SIGNATURE_OTP_SECRET, TTL de 5min,
* máx. 5 tentativas, um único código ativo por vez), o token de link
* público (32 bytes aleatórios, só o SHA-256 persistido, 7 dias de
* validade), o processo de finalização em dois passos (selar checkpoint
* ANTES do certificado existir, depois consolidar via pdf-lib) — tudo isso
* é fidelidade exata ao legado, verificado contra o código-fonte real
* (não reconstruído de memória).
*/
exports.shorthands = undefined;
exports.up = (pgm) => {
pgm.sql(`
-- =====================================================================
-- signature_envelopes — o agregado raiz
-- =====================================================================
CREATE SEQUENCE signature_envelope_seq START 1;
CREATE TABLE signature_envelopes (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_by UUID REFERENCES users(id),
envelope_number TEXT UNIQUE NOT NULL,
company_id UUID NOT NULL REFERENCES companies(id),
client_registration_id UUID REFERENCES client_registrations(id),
reseller_id UUID REFERENCES resellers(id),
status TEXT NOT NULL DEFAULT 'DRAFT' CHECK (status IN (
'DRAFT', 'READY', 'SENT', 'VIEWED', 'IDENTITY_PENDING', 'CONSENT_PENDING',
'OTP_PENDING', 'OTP_SENT', 'OTP_VERIFIED', 'READY_TO_SIGN', 'SIGNING', 'SIGNED',
'FINALIZING', 'COMPLETED', 'DECLINED', 'EXPIRED', 'CANCELLED', 'SUPERSEDED', 'ERROR'
)),
verification_id TEXT UNIQUE,
final_s3_key TEXT,
final_pdf_sha256 TEXT,
certificate_s3_key TEXT,
evidence_checkpoint_hash TEXT,
envelope_hash TEXT,
sent_at TIMESTAMPTZ,
viewed_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
declined_at TIMESTAMPTZ,
cancelled_at TIMESTAMPTZ,
expired_at TIMESTAMPTZ,
signed_at TIMESTAMPTZ,
CONSTRAINT chk_signature_envelopes_one_source CHECK (
(client_registration_id IS NOT NULL)::int + (reseller_id IS NOT NULL)::int = 1
)
);
COMMENT ON TABLE signature_envelopes IS
'Pacote de assinatura — conjunto de documentos + signatários passando por uma máquina de estados única (eden.md §1.4, §5.1). envelope_number formato SIG-{ano}-{seq6digitos}, gerado pela camada de repositório (mesmo padrão de contracts.contract_number).';
COMMENT ON COLUMN signature_envelopes.company_id IS
'Empresa emissora (contratada) — resolve o signatário party=contratada automático a partir de companies.legal_rep_{name,cpf,email,role}. Obrigatório (NOT NULL, diferente de contracts.company_id que é opcional): createEnvelope recusa criar o envelope se essa empresa não tiver representante legal cadastrado, eden.md §1.5/§5.4.';
COMMENT ON COLUMN signature_envelopes.status IS
'Máquina de estados centralizada no backend, nunca confiada ao frontend — ver ALLOWED_TRANSITIONS em apps/api/src/signatures/state-machine.ts. SENT->SIGNED é um salto direto deliberado: o progresso fino (VIEWED..OTP_VERIFIED) é rastreado por signatário (signature_signers.status), não replicado passo a passo no envelope.';
COMMENT ON COLUMN signature_envelopes.verification_id IS
'16 bytes aleatórios em hex (crypto.randomBytes(16)), gerado só na finalização (sealEvidenceCheckpoint) — usado na URL pública de verificação /verify/{verificationId}.';
COMMENT ON COLUMN signature_envelopes.evidence_checkpoint_hash IS
'event_hash do último evento de auditoria do envelope no momento do selamento (sealEvidenceCheckpoint) — NÃO é o hash final absoluto da auditoria inteira, a cadeia continua recebendo eventos depois (ex. CERTIFICATE_GENERATED).';
COMMENT ON COLUMN signature_envelopes.envelope_hash IS
'SHA-256 dos fatos lógicos do envelope (envelope_number, evidence_checkpoint_hash, documentos e signatários ordenados por sort_order) — terceira camada de integridade, independente do PDF em bytes. Ver computeEnvelopeHash em apps/api/src/signatures/finalize.service.ts.';
CREATE TRIGGER trg_signature_envelopes_updated_at BEFORE UPDATE ON signature_envelopes
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
CREATE INDEX idx_signature_envelopes_status ON signature_envelopes(status);
CREATE INDEX idx_signature_envelopes_client_registration ON signature_envelopes(client_registration_id);
CREATE INDEX idx_signature_envelopes_reseller ON signature_envelopes(reseller_id);
ALTER SEQUENCE signature_envelope_seq OWNED BY NONE;
-- =====================================================================
-- signature_signers — snapshot, nunca referência viva (eden.md §1.5)
-- =====================================================================
CREATE TABLE signature_signers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
envelope_id UUID NOT NULL REFERENCES signature_envelopes(id) ON DELETE CASCADE,
sort_order INTEGER NOT NULL DEFAULT 0,
party TEXT NOT NULL DEFAULT 'contratante' CHECK (party IN ('contratada', 'contratante')),
person_type TEXT CHECK (person_type IS NULL OR person_type IN ('pf', 'pj')),
full_name TEXT NOT NULL,
cpf TEXT,
email TEXT NOT NULL,
phone TEXT,
company_name TEXT,
cnpj TEXT,
role_title TEXT,
status TEXT NOT NULL DEFAULT 'PENDING' CHECK (status IN (
'PENDING', 'LINK_SENT', 'VIEWED', 'DATA_CONFIRMED', 'CONSENTED',
'OTP_VERIFIED', 'SIGNED', 'DECLINED'
)),
signed_at TIMESTAMPTZ,
secure_token_hash TEXT UNIQUE,
secure_token_created_at TIMESTAMPTZ,
secure_token_expires_at TIMESTAMPTZ,
secure_token_revoked_at TIMESTAMPTZ,
secure_token_first_used_at TIMESTAMPTZ,
secure_token_last_used_at TIMESTAMPTZ
);
COMMENT ON TABLE signature_signers IS
'Um signatário dentro de um envelope — SEMPRE um snapshot (nome/CPF/e-mail congelados no momento da criação do envelope), nunca FK viva para o cadastro do cliente. Todo envelope tem exatamente um party=''contratada'' (auto-preenchido de companies.legal_rep_*) e um ou mais party=''contratante'' (informados explicitamente na criação, eden.md §5.3).';
COMMENT ON COLUMN signature_signers.secure_token_hash IS
'SHA-256 simples (não HMAC) do token do link público — o "segredo" aqui é a entropia do token (32 bytes/256 bits via crypto.randomBytes), diferente do OTP (HMAC porque o espaço de 6 dígitos é pequeno). Nunca busca pelo token em claro.';
COMMENT ON COLUMN signature_signers.status IS
'PENDING -> LINK_SENT -> VIEWED -> DATA_CONFIRMED -> CONSENTED -> OTP_VERIFIED -> SIGNED (ou DECLINED a qualquer momento — reservado, sem rota que o produza ainda, igual ao legado). Toda escrita usa guarda AND status NOT IN (...) para nunca regredir um signatário já avançado.';
CREATE INDEX idx_signature_signers_envelope ON signature_signers(envelope_id);
-- =====================================================================
-- signature_documents — documento congelado (cópia imutável)
-- =====================================================================
CREATE TABLE signature_documents (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
envelope_id UUID NOT NULL REFERENCES signature_envelopes(id) ON DELETE CASCADE,
source_generation_id UUID REFERENCES document_generations(id),
document_type TEXT,
title TEXT NOT NULL,
sort_order INTEGER NOT NULL DEFAULT 0,
document_version INTEGER NOT NULL DEFAULT 1,
source_filename TEXT,
source_mime_type TEXT,
source_s3_key TEXT NOT NULL,
original_sha256 TEXT NOT NULL,
page_count INTEGER,
status TEXT NOT NULL DEFAULT 'FROZEN' CHECK (status IN ('FROZEN', 'SUPERSEDED')),
superseded_by_id UUID REFERENCES signature_documents(id),
frozen_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
COMMENT ON TABLE signature_documents IS
'Cópia congelada e imutável de um documento dentro do envelope — source_s3_key é SEMPRE uma cópia própria (nunca o objeto S3 mutável de document_generations), original_sha256 calculado no momento do congelamento. source_generation_id é só referência histórica, nunca usado operacionalmente depois de congelado (eden.md §1.6, mesma filosofia do source_attachment_id do legado).';
CREATE INDEX idx_signature_documents_envelope ON signature_documents(envelope_id);
-- =====================================================================
-- signature_otp_challenges — ver eden.md §6 para o algoritmo completo
-- =====================================================================
CREATE TABLE signature_otp_challenges (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
envelope_id UUID NOT NULL REFERENCES signature_envelopes(id) ON DELETE CASCADE,
signer_id UUID NOT NULL REFERENCES signature_signers(id) ON DELETE CASCADE,
channel TEXT NOT NULL DEFAULT 'EMAIL',
email_snapshot TEXT,
otp_protected_value TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
expires_at TIMESTAMPTZ NOT NULL,
sent_at TIMESTAMPTZ,
verified_at TIMESTAMPTZ,
invalidated_at TIMESTAMPTZ,
attempts_count INTEGER NOT NULL DEFAULT 0,
max_attempts INTEGER NOT NULL DEFAULT 5,
request_ip INET,
verification_ip INET,
request_user_agent TEXT,
verification_user_agent TEXT,
status TEXT NOT NULL DEFAULT 'CREATED' CHECK (status IN (
'CREATED', 'SENT', 'VERIFIED', 'EXPIRED', 'INVALIDATED', 'BLOCKED'
))
);
COMMENT ON TABLE signature_otp_challenges IS
'otp_protected_value é SEMPRE HMAC-SHA256(código, SIGNATURE_OTP_SECRET) — o código de 6 dígitos puro nunca é gravado, logado, ou devolvido em resposta de API. TTL de 5 minutos fixo (não configurável). Um novo OTP invalida (status=INVALIDATED) qualquer challenge CREATED/SENT anterior do mesmo signatário antes de criar outro — eden.md §6.5.';
COMMENT ON COLUMN signature_otp_challenges.max_attempts IS
'Default 5 — ao atingir attempts_count >= max_attempts, status vira BLOCKED e nenhuma tentativa adicional é aceita nesse challenge; o signatário precisa pedir um novo OTP.';
CREATE INDEX idx_signature_otp_challenges_signer ON signature_otp_challenges(signer_id, created_at DESC);
-- =====================================================================
-- signature_audit_events — cadeia de auditoria hash-chained, append-only
-- =====================================================================
CREATE TABLE signature_audit_events (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
seq BIGSERIAL NOT NULL UNIQUE,
envelope_id UUID NOT NULL REFERENCES signature_envelopes(id) ON DELETE CASCADE,
document_id UUID REFERENCES signature_documents(id),
signer_id UUID REFERENCES signature_signers(id),
event_type TEXT NOT NULL,
actor_type TEXT NOT NULL CHECK (actor_type IN (
'SIGNER', 'BACKOFFICE', 'ADMIN', 'SUPER_ADMIN', 'SYSTEM', 'SERVICE'
)),
actor_id UUID REFERENCES users(id),
actor_role TEXT,
timestamp_utc TIMESTAMPTZ NOT NULL,
ip_address INET,
user_agent TEXT,
browser TEXT,
operating_system TEXT,
device_type TEXT,
session_id TEXT,
correlation_id TEXT,
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
previous_event_hash TEXT,
event_hash TEXT NOT NULL,
hash_algorithm_version INTEGER NOT NULL DEFAULT 2
);
COMMENT ON TABLE signature_audit_events IS
'Cadeia de auditoria hash-chained POR ENVELOPE (distinta e complementar ao audit_log genérico do sistema, que cobre toda ação crítica — as duas coexistem deliberadamente, eden.md §1.8). seq (bigserial), não timestamp_utc, define a ordem real: dentro de uma mesma transação o Postgres congela now(), então vários eventos gravados juntos podem ter timestamp idêntico. event_hash = SHA256(previous_event_hash + canonical_json({envelopeId,documentId,signerId,eventType,actorType,actorId,actorRole,timestampUtc,ipAddress,userAgent,metadata})) — note que browser/operatingSystem/deviceType/sessionId/correlationId ficam gravados na linha mas NÃO entram no hash. previous_event_hash encadeia com o evento anterior DO MESMO envelope, string vazia se for o primeiro.';
COMMENT ON COLUMN signature_audit_events.hash_algorithm_version IS
'Sempre 2 neste sistema (não existe v1 aqui — ver doc-comment de packages/database/src/hash-chain.ts). Mantido para o mesmo padrão defensivo do legado: se um dia a fórmula precisar mudar de novo, a verificação continua sabendo qual fórmula cada evento antigo usou.';
CREATE INDEX idx_signature_audit_events_envelope ON signature_audit_events(envelope_id, seq);
-- Append-only, reforçado em nível de banco — mesmo padrão e mesma GUC
-- (eden.allow_audit_mutation) já usada por audit_log
-- (1788430575858_baseline-schema.cjs), reaproveitada aqui de propósito:
-- é a mesma classe de escotilha administrativa deliberada, não uma
-- flag nova por tabela. Bloqueia inclusive super_admin via SQL bruto.
CREATE OR REPLACE FUNCTION prevent_signature_audit_mutation() RETURNS TRIGGER AS $$
BEGIN
IF current_setting('eden.allow_audit_mutation', true) IS DISTINCT FROM 'true' THEN
RAISE EXCEPTION 'signature_audit_events é append-only — mutação bloqueada (ver docs/security/threat-model.md)';
END IF;
IF TG_OP = 'DELETE' THEN
RETURN OLD;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_signature_audit_events_no_update BEFORE UPDATE ON signature_audit_events
FOR EACH ROW EXECUTE FUNCTION prevent_signature_audit_mutation();
CREATE TRIGGER trg_signature_audit_events_no_delete BEFORE DELETE ON signature_audit_events
FOR EACH ROW EXECUTE FUNCTION prevent_signature_audit_mutation();
`);
};
exports.down = (pgm) => {
pgm.sql(`
DROP TABLE IF EXISTS signature_audit_events;
DROP FUNCTION IF EXISTS prevent_signature_audit_mutation();
DROP TABLE IF EXISTS signature_otp_challenges;
DROP TABLE IF EXISTS signature_documents;
DROP TABLE IF EXISTS signature_signers;
DROP TABLE IF EXISTS signature_envelopes;
DROP SEQUENCE IF EXISTS signature_envelope_seq;
`);
};

View File

@@ -0,0 +1,37 @@
import { createHash } from "node:crypto";
/**
* Canonical JSON — recursive deep key-sort (objects at every depth,
* arrays mapped element-wise), so a JSONB round-trip through Postgres
* (which does NOT preserve original key order — it reorders by key
* length then alphabetically) never changes the string a hash was
* computed over. Shared by audit_log (repositories/audit.ts) and the
* e-signature module's signature_audit_events (repositories/
* signatures.ts) — both hash-chained tables, same algorithm.
*
* The legacy OrçaFácil this was ported from carries a v1/v2 version
* split here: v1 only sorted top-level keys, which broke against
* Postgres's jsonb key reordering for anything nested (e.g. inside a
* `metadata` object) — a real bug discovered after already-hashed
* production events existed. EDEN has no pre-existing hash-chain rows
* from before this fix, so there is no v1 to port — every event this
* system ever writes uses this (the legacy's "v2") formula from day
* one. `hash_algorithm_version` is still recorded per-event (see the
* signature_audit_events schema) purely so a future algorithm change
* has the same version-aware escape hatch, not because v1 exists here.
*/
export function canonicalJson(value: unknown): string {
if (value === undefined) return "null";
if (value === null || typeof value !== "object") return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
const obj = value as Record<string, unknown>;
const keys = Object.keys(obj).sort();
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`).join(",")}}`;
}
/** `event_hash = SHA256(previousEventHash + canonicalJson(fields))` — the one formula every hash-chained table in this codebase uses. */
export function computeEventHash(previousEventHash: string, fields: Record<string, unknown>): string {
return createHash("sha256")
.update(previousEventHash + canonicalJson(fields))
.digest("hex");
}

View File

@@ -1,4 +1,5 @@
export { query, withTransaction, closePool } from "./pool.js";
export { canonicalJson, computeEventHash } from "./hash-chain.js";
export * as rolesRepo from "./repositories/roles.js";
export * as usersRepo from "./repositories/users.js";
export * as sessionsRepo from "./repositories/sessions.js";
@@ -16,6 +17,7 @@ export * as hrTimeclockRepo from "./repositories/hr-timeclock.js";
export * as fiscalRepo from "./repositories/fiscal.js";
export * as billingRepo from "./repositories/billing.js";
export * as documentsRepo from "./repositories/documents.js";
export * as signaturesRepo from "./repositories/signatures.js";
export {
listLeads,
getLead,
@@ -178,3 +180,23 @@ export type {
CreateGenerationInput,
ListGenerationsFilter,
} from "./repositories/documents.js";
export type {
EnvelopeStatus,
SignerStatus,
OtpStatus,
ActorType,
PartyRole,
SignatureEnvelope,
SignatureSigner,
SignatureDocument,
SignatureOtpChallenge,
SignatureAuditEvent,
EnvelopeWithDetails,
CreateSignerInput,
CreateDocumentInput,
CreateEnvelopeInput,
ListEnvelopesFilter,
CreateOtpChallengeInput,
RecordEventInput,
VerifyChainResult,
} from "./repositories/signatures.js";

View File

@@ -1,5 +1,5 @@
import { createHash } from "node:crypto";
import { query } from "../pool.js";
import { computeEventHash } from "../hash-chain.js";
export interface AuditEventInput {
actorType: "user" | "system" | "service";
@@ -11,14 +11,6 @@ export interface AuditEventInput {
ipAddress?: string | null;
}
function canonicalJson(value: unknown): string {
if (value === null || typeof value !== "object") return JSON.stringify(value);
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
const obj = value as Record<string, unknown>;
const keys = Object.keys(obj).sort();
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`).join(",")}}`;
}
/**
* Appends one event to the audit_log hash chain (see baseline-schema
* migration and .claude/skills/eden-security/references/audit.md). Never
@@ -43,9 +35,7 @@ export async function appendAuditEvent(input: AuditEventInput): Promise<void> {
metadata: input.metadata ?? {},
ipAddress: input.ipAddress ?? null,
};
const eventHash = createHash("sha256")
.update(previousEventHash + canonicalJson(fields))
.digest("hex");
const eventHash = computeEventHash(previousEventHash, fields);
await query(
`INSERT INTO audit_log

View File

@@ -0,0 +1,912 @@
import type { PoolClient } from "pg";
import { query, withTransaction } from "../pool.js";
import { computeEventHash } from "../hash-chain.js";
/**
* Assinatura Eletrônica — eden.md módulo 4 §§1.4-1.9, 5-10. See the
* migration's header comment (1788536022387_signatures.cjs) for the
* handful of deliberate EDEN-specific adaptations (document source,
* reseller reference, hash_algorithm_version, server-side certificate).
* Everything else here — the 19 envelope states, the exact hash-chain
* formula, OTP timing/attempt limits — is algorithmically identical to
* the legacy, verified against its actual source, not reconstructed from
* memory. HMAC/crypto for OTP and secure tokens live in apps/api's
* services (need @eden/config's env access, out of scope for this
* package) — this file only stores/queries the already-computed opaque
* values.
*/
export const ENVELOPE_STATUSES = [
"DRAFT",
"READY",
"SENT",
"VIEWED",
"IDENTITY_PENDING",
"CONSENT_PENDING",
"OTP_PENDING",
"OTP_SENT",
"OTP_VERIFIED",
"READY_TO_SIGN",
"SIGNING",
"SIGNED",
"FINALIZING",
"COMPLETED",
"DECLINED",
"EXPIRED",
"CANCELLED",
"SUPERSEDED",
"ERROR",
] as const;
export type EnvelopeStatus = (typeof ENVELOPE_STATUSES)[number];
export const SIGNER_STATUSES = [
"PENDING",
"LINK_SENT",
"VIEWED",
"DATA_CONFIRMED",
"CONSENTED",
"OTP_VERIFIED",
"SIGNED",
"DECLINED",
] as const;
export type SignerStatus = (typeof SIGNER_STATUSES)[number];
export type OtpStatus = "CREATED" | "SENT" | "VERIFIED" | "EXPIRED" | "INVALIDATED" | "BLOCKED";
export type ActorType = "SIGNER" | "BACKOFFICE" | "ADMIN" | "SUPER_ADMIN" | "SYSTEM" | "SERVICE";
export type PartyRole = "contratada" | "contratante";
/**
* `envelopeService.js`'s `ALLOWED_TRANSITIONS`, ported verbatim (fork-
* verified against source). SENT -> SIGNED is a deliberate direct jump:
* per-signer granularity (VIEWED..OTP_VERIFIED) lives entirely on
* signature_signers.status, never mirrored step-by-step on the envelope.
*/
export const ENVELOPE_TRANSITIONS: Record<EnvelopeStatus, EnvelopeStatus[]> = {
DRAFT: ["READY", "CANCELLED"],
READY: ["SENT", "CANCELLED"],
SENT: ["VIEWED", "SIGNED", "EXPIRED", "CANCELLED"],
VIEWED: ["IDENTITY_PENDING", "EXPIRED", "CANCELLED"],
IDENTITY_PENDING: ["CONSENT_PENDING", "EXPIRED", "CANCELLED"],
CONSENT_PENDING: ["OTP_PENDING", "EXPIRED", "CANCELLED"],
OTP_PENDING: ["OTP_SENT", "EXPIRED", "CANCELLED"],
OTP_SENT: ["OTP_VERIFIED", "OTP_PENDING", "EXPIRED", "CANCELLED"],
OTP_VERIFIED: ["READY_TO_SIGN", "EXPIRED", "CANCELLED"],
READY_TO_SIGN: ["SIGNING", "EXPIRED", "CANCELLED"],
SIGNING: ["SIGNED", "ERROR"],
SIGNED: ["FINALIZING"],
FINALIZING: ["COMPLETED", "ERROR"],
COMPLETED: [],
DECLINED: [],
EXPIRED: [],
CANCELLED: [],
SUPERSEDED: [],
ERROR: [],
};
export class InvalidEnvelopeTransitionError extends Error {
constructor(
public readonly fromStatus: EnvelopeStatus,
public readonly toStatus: EnvelopeStatus,
) {
super(`Invalid signature envelope transition: '${fromStatus}' -> '${toStatus}'`);
this.name = "InvalidEnvelopeTransitionError";
}
}
export function assertEnvelopeTransition(from: EnvelopeStatus, to: EnvelopeStatus): void {
if (!(ENVELOPE_TRANSITIONS[from] ?? []).includes(to)) {
throw new InvalidEnvelopeTransitionError(from, to);
}
}
export class EnvelopeNotFoundError extends Error {
constructor(public readonly envelopeId: string) {
super(`Signature envelope ${envelopeId} not found`);
this.name = "EnvelopeNotFoundError";
}
}
// ===========================================================================
// Row types
// ===========================================================================
export interface SignatureEnvelope {
id: string;
created_at: Date;
updated_at: Date;
created_by: string | null;
envelope_number: string;
company_id: string;
client_registration_id: string | null;
reseller_id: string | null;
status: EnvelopeStatus;
verification_id: string | null;
final_s3_key: string | null;
final_pdf_sha256: string | null;
certificate_s3_key: string | null;
evidence_checkpoint_hash: string | null;
envelope_hash: string | null;
sent_at: Date | null;
viewed_at: Date | null;
completed_at: Date | null;
declined_at: Date | null;
cancelled_at: Date | null;
expired_at: Date | null;
signed_at: Date | null;
}
export interface SignatureSigner {
id: string;
created_at: Date;
envelope_id: string;
sort_order: number;
party: PartyRole;
person_type: "pf" | "pj" | null;
full_name: string;
cpf: string | null;
email: string;
phone: string | null;
company_name: string | null;
cnpj: string | null;
role_title: string | null;
status: SignerStatus;
signed_at: Date | null;
secure_token_hash: string | null;
secure_token_created_at: Date | null;
secure_token_expires_at: Date | null;
secure_token_revoked_at: Date | null;
secure_token_first_used_at: Date | null;
secure_token_last_used_at: Date | null;
}
export interface SignatureDocument {
id: string;
envelope_id: string;
source_generation_id: string | null;
document_type: string | null;
title: string;
sort_order: number;
document_version: number;
source_filename: string | null;
source_mime_type: string | null;
source_s3_key: string;
original_sha256: string;
page_count: number | null;
status: "FROZEN" | "SUPERSEDED";
superseded_by_id: string | null;
frozen_at: Date;
}
export interface SignatureOtpChallenge {
id: string;
envelope_id: string;
signer_id: string;
channel: string;
email_snapshot: string | null;
otp_protected_value: string;
created_at: Date;
expires_at: Date;
sent_at: Date | null;
verified_at: Date | null;
invalidated_at: Date | null;
attempts_count: number;
max_attempts: number;
request_ip: string | null;
verification_ip: string | null;
request_user_agent: string | null;
verification_user_agent: string | null;
status: OtpStatus;
}
export interface SignatureAuditEvent {
id: string;
seq: string;
envelope_id: string;
document_id: string | null;
signer_id: string | null;
event_type: string;
actor_type: ActorType;
actor_id: string | null;
actor_role: string | null;
timestamp_utc: Date;
ip_address: string | null;
user_agent: string | null;
browser: string | null;
operating_system: string | null;
device_type: string | null;
session_id: string | null;
correlation_id: string | null;
metadata_json: Record<string, unknown>;
previous_event_hash: string | null;
event_hash: string;
hash_algorithm_version: number;
}
export interface EnvelopeWithDetails extends SignatureEnvelope {
signers: SignatureSigner[];
documents: SignatureDocument[];
}
// ===========================================================================
// Envelope creation
// ===========================================================================
function formatEnvelopeNumber(seq: string | number): string {
const year = new Date().getFullYear();
return `SIG-${year}-${String(seq).padStart(6, "0")}`;
}
export interface CreateSignerInput {
party: PartyRole;
personType?: "pf" | "pj" | null;
fullName: string;
cpf?: string | null;
email: string;
phone?: string | null;
companyName?: string | null;
cnpj?: string | null;
roleTitle?: string | null;
}
export interface CreateDocumentInput {
sourceGenerationId?: string | null;
documentType?: string | null;
title: string;
sourceFilename?: string | null;
sourceMimeType?: string | null;
sourceS3Key: string;
originalSha256: string;
pageCount?: number | null;
}
export interface CreateEnvelopeInput {
companyId: string;
clientRegistrationId?: string | null;
resellerId?: string | null;
createdBy?: string | null;
signers: CreateSignerInput[];
documents: CreateDocumentInput[];
}
/**
* Single transaction: allocates envelope_number, inserts the envelope
* (status DRAFT), its documents (already frozen — S3 copy + SHA-256 done
* by the caller before this, same as the legacy's freezeAttachment
* running before the INSERT), its signers, transitions DRAFT->READY, and
* records ENVELOPE_CREATED + one DOCUMENT_FROZEN per document + one
* ENVELOPE_READY audit event — all inside one transaction (eden.md §5.4:
* "todo o processo é uma única transação... se qualquer etapa falhar,
* nada fica gravado").
*/
export async function createEnvelope(
input: CreateEnvelopeInput,
actor: { actorType: ActorType; actorId: string | null; actorRole: string | null; ipAddress: string | null; userAgent: string | null },
): Promise<EnvelopeWithDetails> {
return withTransaction(async (client) => {
const { rows: seqRows } = await client.query<{ n: string }>("SELECT nextval('signature_envelope_seq') AS n");
const seq = seqRows[0]?.n;
if (seq === undefined) throw new Error("failed to allocate envelope_number");
const envelopeNumber = formatEnvelopeNumber(seq);
const { rows } = await client.query<SignatureEnvelope>(
`INSERT INTO signature_envelopes (envelope_number, company_id, client_registration_id, reseller_id, created_by)
VALUES ($1, $2, $3, $4, $5)
RETURNING *`,
[
envelopeNumber,
input.companyId,
input.clientRegistrationId ?? null,
input.resellerId ?? null,
input.createdBy ?? null,
],
);
const envelope = rows[0];
if (!envelope) throw new Error("failed to create signature envelope");
let previousEventHash = "";
previousEventHash = await recordEventInternal(client, previousEventHash, {
envelopeId: envelope.id,
documentId: null,
signerId: null,
eventType: "ENVELOPE_CREATED",
...actor,
metadata: { envelopeNumber },
});
const documents: SignatureDocument[] = [];
for (const [index, doc] of input.documents.entries()) {
const { rows: docRows } = await client.query<SignatureDocument>(
`INSERT INTO signature_documents
(envelope_id, source_generation_id, document_type, title, sort_order, source_filename, source_mime_type, source_s3_key, original_sha256, page_count)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
RETURNING *`,
[
envelope.id,
doc.sourceGenerationId ?? null,
doc.documentType ?? null,
doc.title,
index,
doc.sourceFilename ?? null,
doc.sourceMimeType ?? null,
doc.sourceS3Key,
doc.originalSha256,
doc.pageCount ?? null,
],
);
const row = docRows[0];
if (!row) throw new Error("failed to freeze signature document");
documents.push(row);
previousEventHash = await recordEventInternal(client, previousEventHash, {
envelopeId: envelope.id,
documentId: row.id,
signerId: null,
eventType: "DOCUMENT_FROZEN",
...actor,
metadata: { title: row.title, sha256: row.original_sha256 },
});
previousEventHash = await recordEventInternal(client, previousEventHash, {
envelopeId: envelope.id,
documentId: row.id,
signerId: null,
eventType: "DOCUMENT_HASHED",
...actor,
metadata: { sha256: row.original_sha256 },
});
}
const signers: SignatureSigner[] = [];
for (const [index, signer] of input.signers.entries()) {
const { rows: signerRows } = await client.query<SignatureSigner>(
`INSERT INTO signature_signers
(envelope_id, sort_order, party, person_type, full_name, cpf, email, phone, company_name, cnpj, role_title)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
RETURNING *`,
[
envelope.id,
index,
signer.party,
signer.personType ?? null,
signer.fullName,
signer.cpf ?? null,
signer.email,
signer.phone ?? null,
signer.companyName ?? null,
signer.cnpj ?? null,
signer.roleTitle ?? null,
],
);
const row = signerRows[0];
if (!row) throw new Error("failed to create signature signer");
signers.push(row);
}
assertEnvelopeTransition("DRAFT", "READY");
const { rows: updatedRows } = await client.query<SignatureEnvelope>(
"UPDATE signature_envelopes SET status = 'READY' WHERE id = $1 RETURNING *",
[envelope.id],
);
const updated = updatedRows[0];
if (!updated) throw new Error(`envelope ${envelope.id} disappeared mid-transaction`);
await recordEventInternal(client, previousEventHash, {
envelopeId: envelope.id,
documentId: null,
signerId: null,
eventType: "ENVELOPE_READY",
...actor,
metadata: {},
});
return { ...updated, signers, documents };
});
}
// ===========================================================================
// Queries
// ===========================================================================
export interface ListEnvelopesFilter {
scope: "all" | "reseller";
resellerId?: string | null;
}
export async function listEnvelopes(filter: ListEnvelopesFilter): Promise<SignatureEnvelope[]> {
if (filter.scope === "reseller") {
const { rows } = await query<SignatureEnvelope>(
"SELECT * FROM signature_envelopes WHERE reseller_id = $1 ORDER BY created_at DESC",
[filter.resellerId ?? null],
);
return rows;
}
const { rows } = await query<SignatureEnvelope>("SELECT * FROM signature_envelopes ORDER BY created_at DESC");
return rows;
}
async function listSigners(envelopeId: string): Promise<SignatureSigner[]> {
const { rows } = await query<SignatureSigner>(
"SELECT * FROM signature_signers WHERE envelope_id = $1 ORDER BY sort_order ASC",
[envelopeId],
);
return rows;
}
async function listDocuments(envelopeId: string): Promise<SignatureDocument[]> {
const { rows } = await query<SignatureDocument>(
"SELECT * FROM signature_documents WHERE envelope_id = $1 ORDER BY sort_order ASC",
[envelopeId],
);
return rows;
}
export async function getEnvelopeById(id: string): Promise<EnvelopeWithDetails | null> {
const { rows } = await query<SignatureEnvelope>("SELECT * FROM signature_envelopes WHERE id = $1", [id]);
const envelope = rows[0];
if (!envelope) return null;
const [signers, documents] = await Promise.all([listSigners(id), listDocuments(id)]);
return { ...envelope, signers, documents };
}
export async function getEnvelopeByVerificationId(verificationId: string): Promise<EnvelopeWithDetails | null> {
const { rows } = await query<SignatureEnvelope>(
"SELECT * FROM signature_envelopes WHERE verification_id = $1",
[verificationId],
);
const envelope = rows[0];
if (!envelope) return null;
const [signers, documents] = await Promise.all([listSigners(envelope.id), listDocuments(envelope.id)]);
return { ...envelope, signers, documents };
}
export async function getSignerById(id: string): Promise<SignatureSigner | null> {
const { rows } = await query<SignatureSigner>("SELECT * FROM signature_signers WHERE id = $1", [id]);
return rows[0] ?? null;
}
/** Never look up by plaintext token — only by its SHA-256, computed by the caller (apps/api/src/signatures/token.ts). */
export async function getSignerByTokenHash(tokenHash: string): Promise<SignatureSigner | null> {
const { rows } = await query<SignatureSigner>("SELECT * FROM signature_signers WHERE secure_token_hash = $1", [
tokenHash,
]);
return rows[0] ?? null;
}
export async function getLatestOtpChallengeForSigner(signerId: string): Promise<SignatureOtpChallenge | null> {
const { rows } = await query<SignatureOtpChallenge>(
"SELECT * FROM signature_otp_challenges WHERE signer_id = $1 ORDER BY created_at DESC LIMIT 1",
[signerId],
);
return rows[0] ?? null;
}
// ===========================================================================
// Envelope status transitions
// ===========================================================================
const MILESTONE_COLUMN: Partial<Record<EnvelopeStatus, string>> = {
SENT: "sent_at",
VIEWED: "viewed_at",
COMPLETED: "completed_at",
DECLINED: "declined_at",
CANCELLED: "cancelled_at",
EXPIRED: "expired_at",
SIGNED: "signed_at",
};
/**
* Validates against ENVELOPE_TRANSITIONS and, if valid, updates status —
* setting the matching milestone timestamp via COALESCE the first time
* the envelope enters that status (never overwritten afterwards, same
* semantics as Contracts' activated_at). Accepts an optional `client` to
* participate in a caller's own transaction (e.g. sealEvidenceCheckpoint
* writes several columns + this status change atomically).
*/
export async function updateEnvelopeStatus(
envelopeId: string,
toStatus: EnvelopeStatus,
clientOverride?: PoolClient,
): Promise<SignatureEnvelope> {
const run = async (client: PoolClient): Promise<SignatureEnvelope> => {
const { rows } = await client.query<{ status: EnvelopeStatus }>(
"SELECT status FROM signature_envelopes WHERE id = $1 FOR UPDATE",
[envelopeId],
);
const current = rows[0];
if (!current) throw new EnvelopeNotFoundError(envelopeId);
assertEnvelopeTransition(current.status, toStatus);
const milestoneColumn = MILESTONE_COLUMN[toStatus];
const sql = milestoneColumn
? `UPDATE signature_envelopes SET status = $2, ${milestoneColumn} = COALESCE(${milestoneColumn}, now()) WHERE id = $1 RETURNING *`
: "UPDATE signature_envelopes SET status = $2 WHERE id = $1 RETURNING *";
const { rows: updatedRows } = await client.query<SignatureEnvelope>(sql, [envelopeId, toStatus]);
const updated = updatedRows[0];
if (!updated) throw new Error(`envelope ${envelopeId} disappeared mid-transaction`);
return updated;
};
if (clientOverride) return run(clientOverride);
return withTransaction(run);
}
/** Guarded so a replayed/out-of-order request can never regress a signer's progress backward — same discipline as the legacy (eden.md §10.2). `fromStatuses` lists the only statuses this write is allowed to apply from. */
export async function updateSignerStatus(
signerId: string,
toStatus: SignerStatus,
fromStatuses: SignerStatus[],
clientOverride?: PoolClient,
): Promise<SignatureSigner | null> {
const run = async (client: PoolClient): Promise<SignatureSigner | null> => {
const setSignedAt = toStatus === "SIGNED" ? ", signed_at = now()" : "";
const { rows } = await client.query<SignatureSigner>(
`UPDATE signature_signers SET status = $2 ${setSignedAt}
WHERE id = $1 AND status = ANY($3::text[])
RETURNING *`,
[signerId, toStatus, fromStatuses],
);
return rows[0] ?? null;
};
if (clientOverride) return run(clientOverride);
return withTransaction(run);
}
export interface IssueSignerTokenResult {
signer: SignatureSigner;
plaintextToken: string;
}
/** Caller computes the token/hash (crypto lives in apps/api) — this just persists it, revoking any previous token implicitly (only the newest hash is ever queryable). */
export async function setSignerToken(
signerId: string,
tokenHash: string,
expiresAt: Date,
): Promise<SignatureSigner | null> {
const { rows } = await query<SignatureSigner>(
`UPDATE signature_signers SET
secure_token_hash = $2,
secure_token_created_at = now(),
secure_token_expires_at = $3,
secure_token_revoked_at = NULL
WHERE id = $1
RETURNING *`,
[signerId, tokenHash, expiresAt],
);
return rows[0] ?? null;
}
export async function touchSignerTokenUsage(signerId: string): Promise<void> {
await query(
`UPDATE signature_signers SET
secure_token_first_used_at = COALESCE(secure_token_first_used_at, now()),
secure_token_last_used_at = now()
WHERE id = $1`,
[signerId],
);
}
// ===========================================================================
// OTP challenges
// ===========================================================================
export interface CreateOtpChallengeInput {
envelopeId: string;
signerId: string;
emailSnapshot: string;
otpProtectedValue: string;
expiresAt: Date;
requestIp?: string | null;
requestUserAgent?: string | null;
}
/** Invalidates any prior CREATED/SENT challenge for this signer first (eden.md §6.5: "novo OTP invalida o antigo" is explicit product behavior, not incidental). */
export async function createOtpChallenge(input: CreateOtpChallengeInput): Promise<SignatureOtpChallenge> {
return withTransaction(async (client) => {
await client.query(
`UPDATE signature_otp_challenges SET status = 'INVALIDATED', invalidated_at = now()
WHERE signer_id = $1 AND status IN ('CREATED', 'SENT')`,
[input.signerId],
);
const { rows } = await client.query<SignatureOtpChallenge>(
`INSERT INTO signature_otp_challenges
(envelope_id, signer_id, email_snapshot, otp_protected_value, expires_at, request_ip, request_user_agent)
VALUES ($1,$2,$3,$4,$5,$6,$7)
RETURNING *`,
[
input.envelopeId,
input.signerId,
input.emailSnapshot,
input.otpProtectedValue,
input.expiresAt,
input.requestIp ?? null,
input.requestUserAgent ?? null,
],
);
const row = rows[0];
if (!row) throw new Error("failed to create OTP challenge");
return row;
});
}
export async function markOtpSent(id: string): Promise<void> {
await query("UPDATE signature_otp_challenges SET status = 'SENT', sent_at = now() WHERE id = $1", [id]);
}
export async function markOtpVerified(id: string, verificationIp: string | null, verificationUserAgent: string | null): Promise<void> {
await query(
`UPDATE signature_otp_challenges SET
status = 'VERIFIED', verified_at = now(), verification_ip = $2, verification_user_agent = $3
WHERE id = $1`,
[id, verificationIp, verificationUserAgent],
);
}
export async function markOtpExpired(id: string): Promise<void> {
await query("UPDATE signature_otp_challenges SET status = 'EXPIRED' WHERE id = $1", [id]);
}
/** Returns the new attempts_count and whether this attempt blocked the challenge. */
export async function recordOtpFailedAttempt(id: string, maxAttempts: number): Promise<{ attempts: number; blocked: boolean }> {
const { rows } = await query<{ attempts_count: number }>(
`UPDATE signature_otp_challenges SET
attempts_count = attempts_count + 1,
status = CASE WHEN attempts_count + 1 >= $2 THEN 'BLOCKED' ELSE status END
WHERE id = $1
RETURNING attempts_count`,
[id, maxAttempts],
);
const attempts = rows[0]?.attempts_count ?? maxAttempts;
return { attempts, blocked: attempts >= maxAttempts };
}
// ===========================================================================
// Hash-chain audit events
// ===========================================================================
export interface RecordEventInput {
envelopeId: string;
documentId?: string | null;
signerId?: string | null;
eventType: string;
actorType: ActorType;
actorId?: string | null;
actorRole?: string | null;
ipAddress?: string | null;
userAgent?: string | null;
browser?: string | null;
operatingSystem?: string | null;
deviceType?: string | null;
sessionId?: string | null;
metadata?: Record<string, unknown>;
}
/**
* The exact set of fields that enter the hash (`eventFields` in the
* legacy) — deliberately NOT the full row: browser/operatingSystem/
* deviceType/sessionId/correlationId are stored but excluded, fork-
* verified against auditChain.js.
*/
function hashedFields(input: RecordEventInput, timestampUtc: string) {
return {
envelopeId: input.envelopeId,
documentId: input.documentId ?? null,
signerId: input.signerId ?? null,
eventType: input.eventType,
actorType: input.actorType,
actorId: input.actorId ?? null,
actorRole: input.actorRole ?? null,
timestampUtc,
ipAddress: input.ipAddress ?? null,
userAgent: input.userAgent ?? null,
metadata: input.metadata ?? {},
};
}
async function recordEventInternal(client: PoolClient, previousEventHash: string, input: RecordEventInput): Promise<string> {
const timestampUtc = new Date().toISOString();
const eventHash = computeEventHash(previousEventHash, hashedFields(input, timestampUtc));
await client.query(
`INSERT INTO signature_audit_events
(envelope_id, document_id, signer_id, event_type, actor_type, actor_id, actor_role,
timestamp_utc, ip_address, user_agent, browser, operating_system, device_type, session_id,
metadata_json, previous_event_hash, event_hash, hash_algorithm_version)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,2)`,
[
input.envelopeId,
input.documentId ?? null,
input.signerId ?? null,
input.eventType,
input.actorType,
input.actorId ?? null,
input.actorRole ?? null,
timestampUtc,
input.ipAddress ?? null,
input.userAgent ?? null,
input.browser ?? null,
input.operatingSystem ?? null,
input.deviceType ?? null,
input.sessionId ?? null,
JSON.stringify(input.metadata ?? {}),
previousEventHash || null,
eventHash,
],
);
return eventHash;
}
async function tailEventHashInternal(client: PoolClient, envelopeId: string): Promise<string> {
const { rows } = await client.query<{ event_hash: string }>(
"SELECT event_hash FROM signature_audit_events WHERE envelope_id = $1 ORDER BY seq DESC LIMIT 1",
[envelopeId],
);
return rows[0]?.event_hash ?? "";
}
/** Public read of the chain tail — used by sealEvidenceCheckpoint's caller, which needs this value BEFORE computing envelope_hash (envelope_hash's own canonical payload includes evidence_checkpoint_hash, so the two can't be derived independently — see finalize.service.ts). */
export async function peekChainTail(envelopeId: string, clientOverride?: PoolClient): Promise<string> {
if (clientOverride) return tailEventHashInternal(clientOverride, envelopeId);
return withTransaction((client) => tailEventHashInternal(client, envelopeId));
}
/** Reads the chain tail for this envelope and appends one event — the single-event equivalent of what createEnvelope's transaction does inline for several events at once. Accepts an optional client to participate in a caller's transaction. */
export async function recordEvent(input: RecordEventInput, clientOverride?: PoolClient): Promise<string> {
const run = async (client: PoolClient): Promise<string> => {
const previousEventHash = await tailEventHashInternal(client, input.envelopeId);
return recordEventInternal(client, previousEventHash, input);
};
if (clientOverride) return run(clientOverride);
return withTransaction(run);
}
export async function listAuditEvents(envelopeId: string): Promise<SignatureAuditEvent[]> {
const { rows } = await query<SignatureAuditEvent>(
"SELECT * FROM signature_audit_events WHERE envelope_id = $1 ORDER BY seq ASC",
[envelopeId],
);
return rows;
}
export interface VerifyChainResult {
valid: boolean;
eventsChecked: number;
failedAt?: string;
reason?: string;
}
/**
* Two independent checks per event (eden.md §7.5): linkage (previous_
* event_hash matches the prior row's event_hash — proves nothing was
* inserted/removed/reordered, always a hard failure if it doesn't) and
* content (recomputed event_hash matches, using that row's own
* hash_algorithm_version). Since EDEN never wrote a v1 event, content
* verification only ever needs the v2 formula — see hash-chain.ts.
*/
export async function verifyChain(envelopeId: string): Promise<VerifyChainResult> {
const events = await listAuditEvents(envelopeId);
let expectedPrevious = "";
for (const event of events) {
if ((event.previous_event_hash ?? "") !== expectedPrevious) {
return {
valid: false,
eventsChecked: events.indexOf(event),
failedAt: event.id,
reason: "previous_event_hash não confere com o evento anterior — indica adulteração",
};
}
const recomputed = computeEventHash(expectedPrevious, hashedFields(eventToInput(event), event.timestamp_utc.toISOString()));
if (recomputed !== event.event_hash) {
return {
valid: false,
eventsChecked: events.indexOf(event),
failedAt: event.id,
reason: "event_hash não confere com os dados gravados — indica adulteração",
};
}
expectedPrevious = event.event_hash;
}
return { valid: true, eventsChecked: events.length };
}
function eventToInput(event: SignatureAuditEvent): RecordEventInput {
return {
envelopeId: event.envelope_id,
documentId: event.document_id,
signerId: event.signer_id,
eventType: event.event_type,
actorType: event.actor_type,
actorId: event.actor_id,
actorRole: event.actor_role,
ipAddress: event.ip_address,
userAgent: event.user_agent,
metadata: event.metadata_json,
};
}
// ===========================================================================
// Finalization
// ===========================================================================
/**
* Writes verification_id/evidence_checkpoint_hash/envelope_hash and
* transitions SIGNED->FINALIZING atomically. Both hash values are
* computed by the CALLER (apps/api's finalize.service.ts) before this
* call: `evidenceCheckpointHash` from `peekChainTail`, `envelopeHash`
* from the exact formula in eden.md §8.4 — which itself embeds
* `evidenceCheckpointHash`, so the two cannot be derived independently
* inside this function without duplicating that ordering logic here.
*/
export async function sealEvidenceCheckpoint(
envelopeId: string,
verificationId: string,
evidenceCheckpointHash: string,
envelopeHash: string,
): Promise<SignatureEnvelope> {
return withTransaction(async (client) => {
const { rows: statusRows } = await client.query<{ status: EnvelopeStatus }>(
"SELECT status FROM signature_envelopes WHERE id = $1 FOR UPDATE",
[envelopeId],
);
const current = statusRows[0];
if (!current) throw new EnvelopeNotFoundError(envelopeId);
assertEnvelopeTransition(current.status, "FINALIZING");
const { rows } = await client.query<SignatureEnvelope>(
`UPDATE signature_envelopes SET
status = 'FINALIZING', verification_id = $2, evidence_checkpoint_hash = $3, envelope_hash = $4
WHERE id = $1
RETURNING *`,
[envelopeId, verificationId, evidenceCheckpointHash, envelopeHash],
);
const envelope = rows[0];
if (!envelope) throw new Error(`envelope ${envelopeId} disappeared mid-transaction`);
return envelope;
});
}
export async function setFinalizationFiles(
envelopeId: string,
fields: { certificateS3Key?: string; finalS3Key?: string; finalPdfSha256?: string },
): Promise<SignatureEnvelope> {
const { rows } = await query<SignatureEnvelope>(
`UPDATE signature_envelopes SET
certificate_s3_key = COALESCE($2, certificate_s3_key),
final_s3_key = COALESCE($3, final_s3_key),
final_pdf_sha256 = COALESCE($4, final_pdf_sha256)
WHERE id = $1
RETURNING *`,
[envelopeId, fields.certificateS3Key ?? null, fields.finalS3Key ?? null, fields.finalPdfSha256 ?? null],
);
const envelope = rows[0];
if (!envelope) throw new EnvelopeNotFoundError(envelopeId);
return envelope;
}
// ===========================================================================
// Cancel / delete
// ===========================================================================
export async function cancelEnvelope(envelopeId: string, reason: string | null): Promise<SignatureEnvelope> {
return updateEnvelopeStatus(envelopeId, "CANCELLED").then(async (envelope) => {
await recordEvent({
envelopeId,
eventType: "ENVELOPE_CANCELLED",
actorType: "BACKOFFICE",
metadata: { reason },
});
return envelope;
});
}
/**
* Physical delete — only reachable pre-SIGNED (enforced by the caller,
* apps/api's service, checking envelope.status before calling this).
* Uses the same `eden.allow_audit_mutation` escape hatch as audit_log,
* deliberately: this is the one legitimate case where the audit trail
* itself must be removable, because the whole envelope it belongs to
* never produced a completed legal artifact.
*/
export async function deleteEnvelope(envelopeId: string): Promise<void> {
await withTransaction(async (client) => {
await client.query("SET LOCAL eden.allow_audit_mutation = 'true'");
await client.query("DELETE FROM signature_envelopes WHERE id = $1", [envelopeId]);
});
}

View File

@@ -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": {

View File

@@ -7,3 +7,4 @@
export * from "./s3/client.js";
export * from "./smtp/mailer.js";
export * from "./pdf/renderer.js";
export * from "./pdf/merge.js";

View 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;
}
}

View File

@@ -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);

198
pnpm-lock.yaml generated
View File

@@ -62,6 +62,9 @@ importers:
otplib:
specifier: ^13.5.0
version: 13.5.0
qrcode:
specifier: ^1.5.4
version: 1.5.4
reflect-metadata:
specifier: ^0.2.2
version: 0.2.2
@@ -84,9 +87,15 @@ importers:
'@types/node':
specifier: ^22.10.5
version: 22.20.1
'@types/qrcode':
specifier: ^1.5.6
version: 1.5.6
'@types/supertest':
specifier: ^6.0.2
version: 6.0.3
pdf-lib:
specifier: ^1.17.1
version: 1.17.1
supertest:
specifier: ^7.0.0
version: 7.2.2(supports-color@7.2.0)
@@ -306,6 +315,9 @@ importers:
nodemailer:
specifier: ^9.1.1
version: 9.1.1
pdf-lib:
specifier: ^1.17.1
version: 1.17.1
playwright:
specifier: ^1.62.1
version: 1.62.1
@@ -1166,6 +1178,12 @@ packages:
'@paralleldrive/cuid2@2.3.1':
resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==}
'@pdf-lib/standard-fonts@1.0.0':
resolution: {integrity: sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==}
'@pdf-lib/upng@1.0.1':
resolution: {integrity: sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==}
'@phc/format@1.0.0':
resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==}
engines: {node: '>=10'}
@@ -1538,6 +1556,9 @@ packages:
'@types/prop-types@15.7.15':
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
'@types/qrcode@1.5.6':
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
'@types/qs@6.15.1':
resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==}
@@ -1728,6 +1749,10 @@ packages:
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
engines: {node: '>= 6'}
camelcase@5.3.1:
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
engines: {node: '>=6'}
camelcase@6.3.0:
resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==}
engines: {node: '>=10'}
@@ -1765,6 +1790,9 @@ packages:
resolution: {integrity: sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==}
engines: {node: '>=6'}
cliui@6.0.0:
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
cliui@8.0.1:
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
engines: {node: '>=12'}
@@ -1852,6 +1880,10 @@ packages:
supports-color:
optional: true
decamelize@1.2.0:
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
engines: {node: '>=0.10.0'}
deep-eql@5.0.2:
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
engines: {node: '>=6'}
@@ -1874,6 +1906,9 @@ packages:
didyoumean@1.2.2:
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
dijkstrajs@1.0.3:
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
dlv@1.1.3:
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
@@ -1994,6 +2029,10 @@ packages:
resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==}
engines: {node: '>= 0.8'}
find-up@4.1.0:
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
engines: {node: '>=8'}
foreground-child@3.3.1:
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
engines: {node: '>=14'}
@@ -2178,6 +2217,10 @@ packages:
resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==}
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
locate-path@5.0.0:
resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==}
engines: {node: '>=8'}
lodash.includes@4.3.0:
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
@@ -2359,13 +2402,32 @@ packages:
otplib@13.5.0:
resolution: {integrity: sha512-RpcC6aq4rANX6MverMuU3pqHVLgMPO7vl6qR8ga7YzoEHlPJknJ58cSVAaf3jBYnasiQVQHXXl2mj/4pYlsVEQ==}
p-limit@2.3.0:
resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==}
engines: {node: '>=6'}
p-locate@4.1.0:
resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==}
engines: {node: '>=8'}
p-try@2.2.0:
resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==}
engines: {node: '>=6'}
package-json-from-dist@1.0.1:
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
pako@1.0.11:
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
parseurl@1.3.3:
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
engines: {node: '>= 0.8'}
path-exists@4.0.0:
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
engines: {node: '>=8'}
path-key@3.1.1:
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
engines: {node: '>=8'}
@@ -2390,6 +2452,9 @@ packages:
resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
engines: {node: '>= 14.16'}
pdf-lib@1.17.1:
resolution: {integrity: sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==}
pg-cloudflare@1.4.0:
resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==}
@@ -2449,6 +2514,10 @@ packages:
engines: {node: '>=20'}
hasBin: true
pngjs@5.0.0:
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
engines: {node: '>=10.13.0'}
postcss-import@15.1.0:
resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==}
engines: {node: '>=14.0.0'}
@@ -2516,6 +2585,11 @@ packages:
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
engines: {node: '>= 0.10'}
qrcode@1.5.4:
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
engines: {node: '>=10.13.0'}
hasBin: true
qs@6.14.2:
resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==}
engines: {node: '>=0.6'}
@@ -2579,6 +2653,9 @@ packages:
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
engines: {node: '>=0.10.0'}
require-main-filename@2.0.0:
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
resolve@1.22.12:
resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==}
engines: {node: '>= 0.4'}
@@ -2625,6 +2702,9 @@ packages:
resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==}
engines: {node: '>= 0.8.0'}
set-blocking@2.0.0:
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
setprototypeof@1.2.0:
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
@@ -2772,6 +2852,9 @@ packages:
ts-interface-checker@0.1.13:
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
tslib@1.14.1:
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
tslib@2.8.1:
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
@@ -2984,6 +3067,9 @@ packages:
whatwg-url@5.0.0:
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
which-module@2.0.1:
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
which@2.0.2:
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
engines: {node: '>= 8'}
@@ -2998,6 +3084,10 @@ packages:
resolution: {integrity: sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==}
engines: {node: '>=8'}
wrap-ansi@6.2.0:
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
engines: {node: '>=8'}
wrap-ansi@7.0.0:
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
engines: {node: '>=10'}
@@ -3009,6 +3099,9 @@ packages:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
y18n@4.0.3:
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
@@ -3016,10 +3109,18 @@ packages:
yallist@3.1.1:
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
yargs-parser@18.1.3:
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
engines: {node: '>=6'}
yargs-parser@21.1.1:
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
engines: {node: '>=12'}
yargs@15.4.1:
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
engines: {node: '>=8'}
yargs@17.7.3:
resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==}
engines: {node: '>=12'}
@@ -3700,6 +3801,14 @@ snapshots:
dependencies:
'@noble/hashes': 1.8.0
'@pdf-lib/standard-fonts@1.0.0':
dependencies:
pako: 1.0.11
'@pdf-lib/upng@1.0.1':
dependencies:
pako: 1.0.11
'@phc/format@1.0.0': {}
'@remix-run/router@1.23.4': {}
@@ -3988,6 +4097,10 @@ snapshots:
'@types/prop-types@15.7.15': {}
'@types/qrcode@1.5.6':
dependencies:
'@types/node': 22.20.1
'@types/qs@6.15.1': {}
'@types/range-parser@1.2.7': {}
@@ -4220,6 +4333,8 @@ snapshots:
camelcase-css@2.0.1: {}
camelcase@5.3.1: {}
camelcase@6.3.0: {}
caniuse-lite@1.0.30001810: {}
@@ -4263,6 +4378,12 @@ snapshots:
cli-boxes@2.2.1: {}
cliui@6.0.0:
dependencies:
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi: 6.2.0
cliui@8.0.1:
dependencies:
string-width: 4.2.3
@@ -4335,6 +4456,8 @@ snapshots:
optionalDependencies:
supports-color: 7.2.0
decamelize@1.2.0: {}
deep-eql@5.0.2: {}
delayed-stream@1.0.0: {}
@@ -4350,6 +4473,8 @@ snapshots:
didyoumean@1.2.2: {}
dijkstrajs@1.0.3: {}
dlv@1.1.3: {}
dunder-proto@1.0.1:
@@ -4602,6 +4727,11 @@ snapshots:
transitivePeerDependencies:
- supports-color
find-up@4.1.0:
dependencies:
locate-path: 5.0.0
path-exists: 4.0.0
foreground-child@3.3.1:
dependencies:
cross-spawn: 7.0.6
@@ -4776,6 +4906,10 @@ snapshots:
load-tsconfig@0.2.5: {}
locate-path@5.0.0:
dependencies:
p-locate: 4.1.0
lodash.includes@4.3.0: {}
lodash.isboolean@3.0.3: {}
@@ -4914,10 +5048,24 @@ snapshots:
'@otplib/totp': 13.5.0
'@otplib/uri': 13.5.0
p-limit@2.3.0:
dependencies:
p-try: 2.2.0
p-locate@4.1.0:
dependencies:
p-limit: 2.3.0
p-try@2.2.0: {}
package-json-from-dist@1.0.1: {}
pako@1.0.11: {}
parseurl@1.3.3: {}
path-exists@4.0.0: {}
path-key@3.1.1: {}
path-parse@1.0.7: {}
@@ -4935,6 +5083,13 @@ snapshots:
pathval@2.0.1: {}
pdf-lib@1.17.1:
dependencies:
'@pdf-lib/standard-fonts': 1.0.0
'@pdf-lib/upng': 1.0.1
pako: 1.0.11
tslib: 1.14.1
pg-cloudflare@1.4.0:
optional: true
@@ -4986,6 +5141,8 @@ snapshots:
optionalDependencies:
fsevents: 2.3.2
pngjs@5.0.0: {}
postcss-import@15.1.0(postcss@8.5.26):
dependencies:
postcss: 8.5.26
@@ -5039,6 +5196,12 @@ snapshots:
forwarded: 0.2.0
ipaddr.js: 1.9.1
qrcode@1.5.4:
dependencies:
dijkstrajs: 1.0.3
pngjs: 5.0.0
yargs: 15.4.1
qs@6.14.2:
dependencies:
side-channel: 1.1.1
@@ -5099,6 +5262,8 @@ snapshots:
require-directory@2.1.1: {}
require-main-filename@2.0.0: {}
resolve@1.22.12:
dependencies:
es-errors: 1.3.0
@@ -5187,6 +5352,8 @@ snapshots:
transitivePeerDependencies:
- supports-color
set-blocking@2.0.0: {}
setprototypeof@1.2.0: {}
shebang-command@2.0.0:
@@ -5366,6 +5533,8 @@ snapshots:
ts-interface-checker@0.1.13: {}
tslib@1.14.1: {}
tslib@2.8.1: {}
tsx@4.23.13:
@@ -5530,6 +5699,8 @@ snapshots:
tr46: 0.0.3
webidl-conversions: 3.0.1
which-module@2.0.1: {}
which@2.0.2:
dependencies:
isexe: 2.0.0
@@ -5543,6 +5714,12 @@ snapshots:
dependencies:
string-width: 4.2.3
wrap-ansi@6.2.0:
dependencies:
ansi-styles: 4.3.0
string-width: 4.2.3
strip-ansi: 6.0.1
wrap-ansi@7.0.0:
dependencies:
ansi-styles: 4.3.0
@@ -5553,12 +5730,33 @@ snapshots:
xtend@4.0.2: {}
y18n@4.0.3: {}
y18n@5.0.8: {}
yallist@3.1.1: {}
yargs-parser@18.1.3:
dependencies:
camelcase: 5.3.1
decamelize: 1.2.0
yargs-parser@21.1.1: {}
yargs@15.4.1:
dependencies:
cliui: 6.0.0
decamelize: 1.2.0
find-up: 4.1.0
get-caller-file: 2.0.5
require-directory: 2.1.1
require-main-filename: 2.0.0
set-blocking: 2.0.0
string-width: 4.2.3
which-module: 2.0.1
y18n: 4.0.3
yargs-parser: 18.1.3
yargs@17.7.3:
dependencies:
cliui: 8.0.1