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:
@@ -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",
|
||||
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
221
apps/api/src/signatures/certificate-template.ts
Normal file
221
apps/api/src/signatures/certificate-template.ts
Normal 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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """)
|
||||
.replace(/'/g, "'");
|
||||
}
|
||||
|
||||
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; }
|
||||
`;
|
||||
}
|
||||
5
apps/api/src/signatures/dto/cancel-envelope.dto.ts
Normal file
5
apps/api/src/signatures/dto/cancel-envelope.dto.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { IsOptional, IsString } from "class-validator";
|
||||
|
||||
export class CancelEnvelopeDto {
|
||||
@IsOptional() @IsString() reason?: string;
|
||||
}
|
||||
51
apps/api/src/signatures/dto/create-envelope.dto.ts
Normal file
51
apps/api/src/signatures/dto/create-envelope.dto.ts
Normal 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[];
|
||||
}
|
||||
6
apps/api/src/signatures/dto/send-invite.dto.ts
Normal file
6
apps/api/src/signatures/dto/send-invite.dto.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { IsIn } from "class-validator";
|
||||
|
||||
export class SendInviteDto {
|
||||
@IsIn(["contratada", "contratante", "both"])
|
||||
targets!: "contratada" | "contratante" | "both";
|
||||
}
|
||||
5
apps/api/src/signatures/dto/verify-otp.dto.ts
Normal file
5
apps/api/src/signatures/dto/verify-otp.dto.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import { IsString, Length } from "class-validator";
|
||||
|
||||
export class VerifyOtpDto {
|
||||
@IsString() @Length(6, 6) code!: string;
|
||||
}
|
||||
30
apps/api/src/signatures/envelope-hash.ts
Normal file
30
apps/api/src/signatures/envelope-hash.ts
Normal 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");
|
||||
}
|
||||
234
apps/api/src/signatures/envelope.service.ts
Normal file
234
apps/api/src/signatures/envelope.service.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
155
apps/api/src/signatures/finalize.service.ts
Normal file
155
apps/api/src/signatures/finalize.service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
24
apps/api/src/signatures/otp.ts
Normal file
24
apps/api/src/signatures/otp.ts
Normal 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");
|
||||
}
|
||||
61
apps/api/src/signatures/public-signature.controller.ts
Normal file
61
apps/api/src/signatures/public-signature.controller.ts
Normal 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));
|
||||
}
|
||||
}
|
||||
268
apps/api/src/signatures/public-signature.service.ts
Normal file
268
apps/api/src/signatures/public-signature.service.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
}
|
||||
14
apps/api/src/signatures/public-verify.controller.ts
Normal file
14
apps/api/src/signatures/public-verify.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
51
apps/api/src/signatures/public-verify.service.ts
Normal file
51
apps/api/src/signatures/public-verify.service.ts
Normal 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",
|
||||
};
|
||||
}
|
||||
}
|
||||
91
apps/api/src/signatures/signatures.controller.ts
Normal file
91
apps/api/src/signatures/signatures.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
17
apps/api/src/signatures/signatures.module.ts
Normal file
17
apps/api/src/signatures/signatures.module.ts
Normal 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 {}
|
||||
18
apps/api/src/signatures/token.ts
Normal file
18
apps/api/src/signatures/token.ts
Normal 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");
|
||||
}
|
||||
38
apps/api/src/signatures/user-agent.ts
Normal file
38
apps/api/src/signatures/user-agent.ts
Normal 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 };
|
||||
}
|
||||
309
apps/api/test/signature-envelope-flow.spec.ts
Normal file
309
apps/api/test/signature-envelope-flow.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
102
apps/api/test/signature-hash-chain.spec.ts
Normal file
102
apps/api/test/signature-hash-chain.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
283
packages/database/migrations/1788536022387_signatures.cjs
Normal file
283
packages/database/migrations/1788536022387_signatures.cjs
Normal 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;
|
||||
`);
|
||||
};
|
||||
37
packages/database/src/hash-chain.ts
Normal file
37
packages/database/src/hash-chain.ts
Normal 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");
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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
|
||||
|
||||
912
packages/database/src/repositories/signatures.ts
Normal file
912
packages/database/src/repositories/signatures.ts
Normal 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]);
|
||||
});
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
"@aws-sdk/client-s3": "^3.1096.0",
|
||||
"@eden/config": "workspace:*",
|
||||
"nodemailer": "^9.1.1",
|
||||
"pdf-lib": "^1.17.1",
|
||||
"playwright": "^1.62.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -7,3 +7,4 @@
|
||||
export * from "./s3/client.js";
|
||||
export * from "./smtp/mailer.js";
|
||||
export * from "./pdf/renderer.js";
|
||||
export * from "./pdf/merge.js";
|
||||
|
||||
32
packages/integrations/src/pdf/merge.ts
Normal file
32
packages/integrations/src/pdf/merge.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
/**
|
||||
* PDF merge — used by the e-signature module's finalization step (all
|
||||
* frozen documents, in order, followed by the certificate) to produce
|
||||
* one consolidated final PDF. Ported from the legacy's use of `pdf-lib`
|
||||
* for the same purpose (eden.md §8.3).
|
||||
*/
|
||||
import { PDFDocument } from "pdf-lib";
|
||||
|
||||
export async function mergePdfs(buffers: Buffer[]): Promise<{ buffer: Buffer; pageCount: number }> {
|
||||
const merged = await PDFDocument.create();
|
||||
let pageCount = 0;
|
||||
for (const buffer of buffers) {
|
||||
const source = await PDFDocument.load(buffer);
|
||||
const pages = await merged.copyPages(source, source.getPageIndices());
|
||||
for (const page of pages) {
|
||||
merged.addPage(page);
|
||||
pageCount += 1;
|
||||
}
|
||||
}
|
||||
const bytes = await merged.save();
|
||||
return { buffer: Buffer.from(bytes), pageCount };
|
||||
}
|
||||
|
||||
/** Page count of a single PDF buffer — used to record `signature_documents.page_count` when freezing a document. Returns `null` if the buffer isn't a valid PDF (never blocks freezing, matches eden.md §1.6). */
|
||||
export async function getPdfPageCount(buffer: Buffer): Promise<number | null> {
|
||||
try {
|
||||
const doc = await PDFDocument.load(buffer);
|
||||
return doc.getPageCount();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -11,11 +11,18 @@
|
||||
import nodemailer, { type Transporter } from "nodemailer";
|
||||
import { loadEnv } from "@eden/config";
|
||||
|
||||
export interface SendMailAttachment {
|
||||
filename: string;
|
||||
content: Buffer;
|
||||
contentType?: string;
|
||||
}
|
||||
|
||||
export interface SendMailInput {
|
||||
to: string;
|
||||
subject: string;
|
||||
html: string;
|
||||
from?: string;
|
||||
attachments?: SendMailAttachment[];
|
||||
}
|
||||
|
||||
let transporter: Transporter | undefined;
|
||||
@@ -55,6 +62,7 @@ export async function sendMail({
|
||||
subject,
|
||||
html,
|
||||
from,
|
||||
attachments,
|
||||
}: SendMailInput): Promise<void> {
|
||||
const t = getTransporter();
|
||||
if (!t) {
|
||||
@@ -69,6 +77,7 @@ export async function sendMail({
|
||||
to,
|
||||
subject,
|
||||
html,
|
||||
attachments,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error(`[mailer] falha ao enviar e-mail para ${to}:`, err);
|
||||
|
||||
198
pnpm-lock.yaml
generated
198
pnpm-lock.yaml
generated
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user