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",
|
"express": "^4.21.2",
|
||||||
"jsonwebtoken": "^9.0.2",
|
"jsonwebtoken": "^9.0.2",
|
||||||
"otplib": "^13.5.0",
|
"otplib": "^13.5.0",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"reflect-metadata": "^0.2.2",
|
"reflect-metadata": "^0.2.2",
|
||||||
"rxjs": "^7.8.1"
|
"rxjs": "^7.8.1"
|
||||||
},
|
},
|
||||||
@@ -36,7 +37,9 @@
|
|||||||
"@types/express": "^5.0.0",
|
"@types/express": "^5.0.0",
|
||||||
"@types/jsonwebtoken": "^9.0.7",
|
"@types/jsonwebtoken": "^9.0.7",
|
||||||
"@types/node": "^22.10.5",
|
"@types/node": "^22.10.5",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"@types/supertest": "^6.0.2",
|
"@types/supertest": "^6.0.2",
|
||||||
|
"pdf-lib": "^1.17.1",
|
||||||
"supertest": "^7.0.0",
|
"supertest": "^7.0.0",
|
||||||
"tsx": "^4.19.2",
|
"tsx": "^4.19.2",
|
||||||
"typescript": "^5.7.3",
|
"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 { FiscalModule } from "./fiscal/fiscal.module.js";
|
||||||
import { BillingModule } from "./billing/billing.module.js";
|
import { BillingModule } from "./billing/billing.module.js";
|
||||||
import { DocumentsModule } from "./documents/documents.module.js";
|
import { DocumentsModule } from "./documents/documents.module.js";
|
||||||
|
import { SignaturesModule } from "./signatures/signatures.module.js";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -43,6 +44,7 @@ import { DocumentsModule } from "./documents/documents.module.js";
|
|||||||
FiscalModule,
|
FiscalModule,
|
||||||
BillingModule,
|
BillingModule,
|
||||||
DocumentsModule,
|
DocumentsModule,
|
||||||
|
SignaturesModule,
|
||||||
],
|
],
|
||||||
controllers: [HealthController],
|
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
|
### 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.
|
`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 { query, withTransaction, closePool } from "./pool.js";
|
||||||
|
export { canonicalJson, computeEventHash } from "./hash-chain.js";
|
||||||
export * as rolesRepo from "./repositories/roles.js";
|
export * as rolesRepo from "./repositories/roles.js";
|
||||||
export * as usersRepo from "./repositories/users.js";
|
export * as usersRepo from "./repositories/users.js";
|
||||||
export * as sessionsRepo from "./repositories/sessions.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 fiscalRepo from "./repositories/fiscal.js";
|
||||||
export * as billingRepo from "./repositories/billing.js";
|
export * as billingRepo from "./repositories/billing.js";
|
||||||
export * as documentsRepo from "./repositories/documents.js";
|
export * as documentsRepo from "./repositories/documents.js";
|
||||||
|
export * as signaturesRepo from "./repositories/signatures.js";
|
||||||
export {
|
export {
|
||||||
listLeads,
|
listLeads,
|
||||||
getLead,
|
getLead,
|
||||||
@@ -178,3 +180,23 @@ export type {
|
|||||||
CreateGenerationInput,
|
CreateGenerationInput,
|
||||||
ListGenerationsFilter,
|
ListGenerationsFilter,
|
||||||
} from "./repositories/documents.js";
|
} 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 { query } from "../pool.js";
|
||||||
|
import { computeEventHash } from "../hash-chain.js";
|
||||||
|
|
||||||
export interface AuditEventInput {
|
export interface AuditEventInput {
|
||||||
actorType: "user" | "system" | "service";
|
actorType: "user" | "system" | "service";
|
||||||
@@ -11,14 +11,6 @@ export interface AuditEventInput {
|
|||||||
ipAddress?: string | null;
|
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
|
* Appends one event to the audit_log hash chain (see baseline-schema
|
||||||
* migration and .claude/skills/eden-security/references/audit.md). Never
|
* 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 ?? {},
|
metadata: input.metadata ?? {},
|
||||||
ipAddress: input.ipAddress ?? null,
|
ipAddress: input.ipAddress ?? null,
|
||||||
};
|
};
|
||||||
const eventHash = createHash("sha256")
|
const eventHash = computeEventHash(previousEventHash, fields);
|
||||||
.update(previousEventHash + canonicalJson(fields))
|
|
||||||
.digest("hex");
|
|
||||||
|
|
||||||
await query(
|
await query(
|
||||||
`INSERT INTO audit_log
|
`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",
|
"@aws-sdk/client-s3": "^3.1096.0",
|
||||||
"@eden/config": "workspace:*",
|
"@eden/config": "workspace:*",
|
||||||
"nodemailer": "^9.1.1",
|
"nodemailer": "^9.1.1",
|
||||||
|
"pdf-lib": "^1.17.1",
|
||||||
"playwright": "^1.62.1"
|
"playwright": "^1.62.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -7,3 +7,4 @@
|
|||||||
export * from "./s3/client.js";
|
export * from "./s3/client.js";
|
||||||
export * from "./smtp/mailer.js";
|
export * from "./smtp/mailer.js";
|
||||||
export * from "./pdf/renderer.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 nodemailer, { type Transporter } from "nodemailer";
|
||||||
import { loadEnv } from "@eden/config";
|
import { loadEnv } from "@eden/config";
|
||||||
|
|
||||||
|
export interface SendMailAttachment {
|
||||||
|
filename: string;
|
||||||
|
content: Buffer;
|
||||||
|
contentType?: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface SendMailInput {
|
export interface SendMailInput {
|
||||||
to: string;
|
to: string;
|
||||||
subject: string;
|
subject: string;
|
||||||
html: string;
|
html: string;
|
||||||
from?: string;
|
from?: string;
|
||||||
|
attachments?: SendMailAttachment[];
|
||||||
}
|
}
|
||||||
|
|
||||||
let transporter: Transporter | undefined;
|
let transporter: Transporter | undefined;
|
||||||
@@ -55,6 +62,7 @@ export async function sendMail({
|
|||||||
subject,
|
subject,
|
||||||
html,
|
html,
|
||||||
from,
|
from,
|
||||||
|
attachments,
|
||||||
}: SendMailInput): Promise<void> {
|
}: SendMailInput): Promise<void> {
|
||||||
const t = getTransporter();
|
const t = getTransporter();
|
||||||
if (!t) {
|
if (!t) {
|
||||||
@@ -69,6 +77,7 @@ export async function sendMail({
|
|||||||
to,
|
to,
|
||||||
subject,
|
subject,
|
||||||
html,
|
html,
|
||||||
|
attachments,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(`[mailer] falha ao enviar e-mail para ${to}:`, 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:
|
otplib:
|
||||||
specifier: ^13.5.0
|
specifier: ^13.5.0
|
||||||
version: 13.5.0
|
version: 13.5.0
|
||||||
|
qrcode:
|
||||||
|
specifier: ^1.5.4
|
||||||
|
version: 1.5.4
|
||||||
reflect-metadata:
|
reflect-metadata:
|
||||||
specifier: ^0.2.2
|
specifier: ^0.2.2
|
||||||
version: 0.2.2
|
version: 0.2.2
|
||||||
@@ -84,9 +87,15 @@ importers:
|
|||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^22.10.5
|
specifier: ^22.10.5
|
||||||
version: 22.20.1
|
version: 22.20.1
|
||||||
|
'@types/qrcode':
|
||||||
|
specifier: ^1.5.6
|
||||||
|
version: 1.5.6
|
||||||
'@types/supertest':
|
'@types/supertest':
|
||||||
specifier: ^6.0.2
|
specifier: ^6.0.2
|
||||||
version: 6.0.3
|
version: 6.0.3
|
||||||
|
pdf-lib:
|
||||||
|
specifier: ^1.17.1
|
||||||
|
version: 1.17.1
|
||||||
supertest:
|
supertest:
|
||||||
specifier: ^7.0.0
|
specifier: ^7.0.0
|
||||||
version: 7.2.2(supports-color@7.2.0)
|
version: 7.2.2(supports-color@7.2.0)
|
||||||
@@ -306,6 +315,9 @@ importers:
|
|||||||
nodemailer:
|
nodemailer:
|
||||||
specifier: ^9.1.1
|
specifier: ^9.1.1
|
||||||
version: 9.1.1
|
version: 9.1.1
|
||||||
|
pdf-lib:
|
||||||
|
specifier: ^1.17.1
|
||||||
|
version: 1.17.1
|
||||||
playwright:
|
playwright:
|
||||||
specifier: ^1.62.1
|
specifier: ^1.62.1
|
||||||
version: 1.62.1
|
version: 1.62.1
|
||||||
@@ -1166,6 +1178,12 @@ packages:
|
|||||||
'@paralleldrive/cuid2@2.3.1':
|
'@paralleldrive/cuid2@2.3.1':
|
||||||
resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==}
|
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':
|
'@phc/format@1.0.0':
|
||||||
resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==}
|
resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -1538,6 +1556,9 @@ packages:
|
|||||||
'@types/prop-types@15.7.15':
|
'@types/prop-types@15.7.15':
|
||||||
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
|
resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==}
|
||||||
|
|
||||||
|
'@types/qrcode@1.5.6':
|
||||||
|
resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==}
|
||||||
|
|
||||||
'@types/qs@6.15.1':
|
'@types/qs@6.15.1':
|
||||||
resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==}
|
resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==}
|
||||||
|
|
||||||
@@ -1728,6 +1749,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
|
resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
|
|
||||||
|
camelcase@5.3.1:
|
||||||
|
resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
camelcase@6.3.0:
|
camelcase@6.3.0:
|
||||||
resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==}
|
resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -1765,6 +1790,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==}
|
resolution: {integrity: sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
|
cliui@6.0.0:
|
||||||
|
resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==}
|
||||||
|
|
||||||
cliui@8.0.1:
|
cliui@8.0.1:
|
||||||
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
|
resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -1852,6 +1880,10 @@ packages:
|
|||||||
supports-color:
|
supports-color:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
decamelize@1.2.0:
|
||||||
|
resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
deep-eql@5.0.2:
|
deep-eql@5.0.2:
|
||||||
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
|
resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==}
|
||||||
engines: {node: '>=6'}
|
engines: {node: '>=6'}
|
||||||
@@ -1874,6 +1906,9 @@ packages:
|
|||||||
didyoumean@1.2.2:
|
didyoumean@1.2.2:
|
||||||
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
|
resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==}
|
||||||
|
|
||||||
|
dijkstrajs@1.0.3:
|
||||||
|
resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==}
|
||||||
|
|
||||||
dlv@1.1.3:
|
dlv@1.1.3:
|
||||||
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
|
resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==}
|
||||||
|
|
||||||
@@ -1994,6 +2029,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==}
|
resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
|
find-up@4.1.0:
|
||||||
|
resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
foreground-child@3.3.1:
|
foreground-child@3.3.1:
|
||||||
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
|
resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==}
|
||||||
engines: {node: '>=14'}
|
engines: {node: '>=14'}
|
||||||
@@ -2178,6 +2217,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==}
|
resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==}
|
||||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
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:
|
lodash.includes@4.3.0:
|
||||||
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
|
resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==}
|
||||||
|
|
||||||
@@ -2359,13 +2402,32 @@ packages:
|
|||||||
otplib@13.5.0:
|
otplib@13.5.0:
|
||||||
resolution: {integrity: sha512-RpcC6aq4rANX6MverMuU3pqHVLgMPO7vl6qR8ga7YzoEHlPJknJ58cSVAaf3jBYnasiQVQHXXl2mj/4pYlsVEQ==}
|
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:
|
package-json-from-dist@1.0.1:
|
||||||
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
|
resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==}
|
||||||
|
|
||||||
|
pako@1.0.11:
|
||||||
|
resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==}
|
||||||
|
|
||||||
parseurl@1.3.3:
|
parseurl@1.3.3:
|
||||||
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
|
resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==}
|
||||||
engines: {node: '>= 0.8'}
|
engines: {node: '>= 0.8'}
|
||||||
|
|
||||||
|
path-exists@4.0.0:
|
||||||
|
resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
path-key@3.1.1:
|
path-key@3.1.1:
|
||||||
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
|
resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
@@ -2390,6 +2452,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
|
resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==}
|
||||||
engines: {node: '>= 14.16'}
|
engines: {node: '>= 14.16'}
|
||||||
|
|
||||||
|
pdf-lib@1.17.1:
|
||||||
|
resolution: {integrity: sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==}
|
||||||
|
|
||||||
pg-cloudflare@1.4.0:
|
pg-cloudflare@1.4.0:
|
||||||
resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==}
|
resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==}
|
||||||
|
|
||||||
@@ -2449,6 +2514,10 @@ packages:
|
|||||||
engines: {node: '>=20'}
|
engines: {node: '>=20'}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
|
pngjs@5.0.0:
|
||||||
|
resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==}
|
||||||
|
engines: {node: '>=10.13.0'}
|
||||||
|
|
||||||
postcss-import@15.1.0:
|
postcss-import@15.1.0:
|
||||||
resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==}
|
resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==}
|
||||||
engines: {node: '>=14.0.0'}
|
engines: {node: '>=14.0.0'}
|
||||||
@@ -2516,6 +2585,11 @@ packages:
|
|||||||
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
|
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
|
||||||
engines: {node: '>= 0.10'}
|
engines: {node: '>= 0.10'}
|
||||||
|
|
||||||
|
qrcode@1.5.4:
|
||||||
|
resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==}
|
||||||
|
engines: {node: '>=10.13.0'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
qs@6.14.2:
|
qs@6.14.2:
|
||||||
resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==}
|
resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==}
|
||||||
engines: {node: '>=0.6'}
|
engines: {node: '>=0.6'}
|
||||||
@@ -2579,6 +2653,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==}
|
||||||
engines: {node: '>=0.10.0'}
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
|
require-main-filename@2.0.0:
|
||||||
|
resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==}
|
||||||
|
|
||||||
resolve@1.22.12:
|
resolve@1.22.12:
|
||||||
resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==}
|
resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
@@ -2625,6 +2702,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==}
|
resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==}
|
||||||
engines: {node: '>= 0.8.0'}
|
engines: {node: '>= 0.8.0'}
|
||||||
|
|
||||||
|
set-blocking@2.0.0:
|
||||||
|
resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==}
|
||||||
|
|
||||||
setprototypeof@1.2.0:
|
setprototypeof@1.2.0:
|
||||||
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
|
resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==}
|
||||||
|
|
||||||
@@ -2772,6 +2852,9 @@ packages:
|
|||||||
ts-interface-checker@0.1.13:
|
ts-interface-checker@0.1.13:
|
||||||
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
|
resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==}
|
||||||
|
|
||||||
|
tslib@1.14.1:
|
||||||
|
resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==}
|
||||||
|
|
||||||
tslib@2.8.1:
|
tslib@2.8.1:
|
||||||
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==}
|
||||||
|
|
||||||
@@ -2984,6 +3067,9 @@ packages:
|
|||||||
whatwg-url@5.0.0:
|
whatwg-url@5.0.0:
|
||||||
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
|
resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==}
|
||||||
|
|
||||||
|
which-module@2.0.1:
|
||||||
|
resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==}
|
||||||
|
|
||||||
which@2.0.2:
|
which@2.0.2:
|
||||||
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
||||||
engines: {node: '>= 8'}
|
engines: {node: '>= 8'}
|
||||||
@@ -2998,6 +3084,10 @@ packages:
|
|||||||
resolution: {integrity: sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==}
|
resolution: {integrity: sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==}
|
||||||
engines: {node: '>=8'}
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
|
wrap-ansi@6.2.0:
|
||||||
|
resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
wrap-ansi@7.0.0:
|
wrap-ansi@7.0.0:
|
||||||
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -3009,6 +3099,9 @@ packages:
|
|||||||
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
|
||||||
engines: {node: '>=0.4'}
|
engines: {node: '>=0.4'}
|
||||||
|
|
||||||
|
y18n@4.0.3:
|
||||||
|
resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==}
|
||||||
|
|
||||||
y18n@5.0.8:
|
y18n@5.0.8:
|
||||||
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
|
||||||
engines: {node: '>=10'}
|
engines: {node: '>=10'}
|
||||||
@@ -3016,10 +3109,18 @@ packages:
|
|||||||
yallist@3.1.1:
|
yallist@3.1.1:
|
||||||
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==}
|
||||||
|
|
||||||
|
yargs-parser@18.1.3:
|
||||||
|
resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==}
|
||||||
|
engines: {node: '>=6'}
|
||||||
|
|
||||||
yargs-parser@21.1.1:
|
yargs-parser@21.1.1:
|
||||||
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
|
|
||||||
|
yargs@15.4.1:
|
||||||
|
resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==}
|
||||||
|
engines: {node: '>=8'}
|
||||||
|
|
||||||
yargs@17.7.3:
|
yargs@17.7.3:
|
||||||
resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==}
|
resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==}
|
||||||
engines: {node: '>=12'}
|
engines: {node: '>=12'}
|
||||||
@@ -3700,6 +3801,14 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@noble/hashes': 1.8.0
|
'@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': {}
|
'@phc/format@1.0.0': {}
|
||||||
|
|
||||||
'@remix-run/router@1.23.4': {}
|
'@remix-run/router@1.23.4': {}
|
||||||
@@ -3988,6 +4097,10 @@ snapshots:
|
|||||||
|
|
||||||
'@types/prop-types@15.7.15': {}
|
'@types/prop-types@15.7.15': {}
|
||||||
|
|
||||||
|
'@types/qrcode@1.5.6':
|
||||||
|
dependencies:
|
||||||
|
'@types/node': 22.20.1
|
||||||
|
|
||||||
'@types/qs@6.15.1': {}
|
'@types/qs@6.15.1': {}
|
||||||
|
|
||||||
'@types/range-parser@1.2.7': {}
|
'@types/range-parser@1.2.7': {}
|
||||||
@@ -4220,6 +4333,8 @@ snapshots:
|
|||||||
|
|
||||||
camelcase-css@2.0.1: {}
|
camelcase-css@2.0.1: {}
|
||||||
|
|
||||||
|
camelcase@5.3.1: {}
|
||||||
|
|
||||||
camelcase@6.3.0: {}
|
camelcase@6.3.0: {}
|
||||||
|
|
||||||
caniuse-lite@1.0.30001810: {}
|
caniuse-lite@1.0.30001810: {}
|
||||||
@@ -4263,6 +4378,12 @@ snapshots:
|
|||||||
|
|
||||||
cli-boxes@2.2.1: {}
|
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:
|
cliui@8.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
string-width: 4.2.3
|
string-width: 4.2.3
|
||||||
@@ -4335,6 +4456,8 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
supports-color: 7.2.0
|
supports-color: 7.2.0
|
||||||
|
|
||||||
|
decamelize@1.2.0: {}
|
||||||
|
|
||||||
deep-eql@5.0.2: {}
|
deep-eql@5.0.2: {}
|
||||||
|
|
||||||
delayed-stream@1.0.0: {}
|
delayed-stream@1.0.0: {}
|
||||||
@@ -4350,6 +4473,8 @@ snapshots:
|
|||||||
|
|
||||||
didyoumean@1.2.2: {}
|
didyoumean@1.2.2: {}
|
||||||
|
|
||||||
|
dijkstrajs@1.0.3: {}
|
||||||
|
|
||||||
dlv@1.1.3: {}
|
dlv@1.1.3: {}
|
||||||
|
|
||||||
dunder-proto@1.0.1:
|
dunder-proto@1.0.1:
|
||||||
@@ -4602,6 +4727,11 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
find-up@4.1.0:
|
||||||
|
dependencies:
|
||||||
|
locate-path: 5.0.0
|
||||||
|
path-exists: 4.0.0
|
||||||
|
|
||||||
foreground-child@3.3.1:
|
foreground-child@3.3.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
cross-spawn: 7.0.6
|
cross-spawn: 7.0.6
|
||||||
@@ -4776,6 +4906,10 @@ snapshots:
|
|||||||
|
|
||||||
load-tsconfig@0.2.5: {}
|
load-tsconfig@0.2.5: {}
|
||||||
|
|
||||||
|
locate-path@5.0.0:
|
||||||
|
dependencies:
|
||||||
|
p-locate: 4.1.0
|
||||||
|
|
||||||
lodash.includes@4.3.0: {}
|
lodash.includes@4.3.0: {}
|
||||||
|
|
||||||
lodash.isboolean@3.0.3: {}
|
lodash.isboolean@3.0.3: {}
|
||||||
@@ -4914,10 +5048,24 @@ snapshots:
|
|||||||
'@otplib/totp': 13.5.0
|
'@otplib/totp': 13.5.0
|
||||||
'@otplib/uri': 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: {}
|
package-json-from-dist@1.0.1: {}
|
||||||
|
|
||||||
|
pako@1.0.11: {}
|
||||||
|
|
||||||
parseurl@1.3.3: {}
|
parseurl@1.3.3: {}
|
||||||
|
|
||||||
|
path-exists@4.0.0: {}
|
||||||
|
|
||||||
path-key@3.1.1: {}
|
path-key@3.1.1: {}
|
||||||
|
|
||||||
path-parse@1.0.7: {}
|
path-parse@1.0.7: {}
|
||||||
@@ -4935,6 +5083,13 @@ snapshots:
|
|||||||
|
|
||||||
pathval@2.0.1: {}
|
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:
|
pg-cloudflare@1.4.0:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
@@ -4986,6 +5141,8 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
fsevents: 2.3.2
|
fsevents: 2.3.2
|
||||||
|
|
||||||
|
pngjs@5.0.0: {}
|
||||||
|
|
||||||
postcss-import@15.1.0(postcss@8.5.26):
|
postcss-import@15.1.0(postcss@8.5.26):
|
||||||
dependencies:
|
dependencies:
|
||||||
postcss: 8.5.26
|
postcss: 8.5.26
|
||||||
@@ -5039,6 +5196,12 @@ snapshots:
|
|||||||
forwarded: 0.2.0
|
forwarded: 0.2.0
|
||||||
ipaddr.js: 1.9.1
|
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:
|
qs@6.14.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
side-channel: 1.1.1
|
side-channel: 1.1.1
|
||||||
@@ -5099,6 +5262,8 @@ snapshots:
|
|||||||
|
|
||||||
require-directory@2.1.1: {}
|
require-directory@2.1.1: {}
|
||||||
|
|
||||||
|
require-main-filename@2.0.0: {}
|
||||||
|
|
||||||
resolve@1.22.12:
|
resolve@1.22.12:
|
||||||
dependencies:
|
dependencies:
|
||||||
es-errors: 1.3.0
|
es-errors: 1.3.0
|
||||||
@@ -5187,6 +5352,8 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
|
set-blocking@2.0.0: {}
|
||||||
|
|
||||||
setprototypeof@1.2.0: {}
|
setprototypeof@1.2.0: {}
|
||||||
|
|
||||||
shebang-command@2.0.0:
|
shebang-command@2.0.0:
|
||||||
@@ -5366,6 +5533,8 @@ snapshots:
|
|||||||
|
|
||||||
ts-interface-checker@0.1.13: {}
|
ts-interface-checker@0.1.13: {}
|
||||||
|
|
||||||
|
tslib@1.14.1: {}
|
||||||
|
|
||||||
tslib@2.8.1: {}
|
tslib@2.8.1: {}
|
||||||
|
|
||||||
tsx@4.23.13:
|
tsx@4.23.13:
|
||||||
@@ -5530,6 +5699,8 @@ snapshots:
|
|||||||
tr46: 0.0.3
|
tr46: 0.0.3
|
||||||
webidl-conversions: 3.0.1
|
webidl-conversions: 3.0.1
|
||||||
|
|
||||||
|
which-module@2.0.1: {}
|
||||||
|
|
||||||
which@2.0.2:
|
which@2.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
isexe: 2.0.0
|
isexe: 2.0.0
|
||||||
@@ -5543,6 +5714,12 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
string-width: 4.2.3
|
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:
|
wrap-ansi@7.0.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
ansi-styles: 4.3.0
|
ansi-styles: 4.3.0
|
||||||
@@ -5553,12 +5730,33 @@ snapshots:
|
|||||||
|
|
||||||
xtend@4.0.2: {}
|
xtend@4.0.2: {}
|
||||||
|
|
||||||
|
y18n@4.0.3: {}
|
||||||
|
|
||||||
y18n@5.0.8: {}
|
y18n@5.0.8: {}
|
||||||
|
|
||||||
yallist@3.1.1: {}
|
yallist@3.1.1: {}
|
||||||
|
|
||||||
|
yargs-parser@18.1.3:
|
||||||
|
dependencies:
|
||||||
|
camelcase: 5.3.1
|
||||||
|
decamelize: 1.2.0
|
||||||
|
|
||||||
yargs-parser@21.1.1: {}
|
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:
|
yargs@17.7.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
cliui: 8.0.1
|
cliui: 8.0.1
|
||||||
|
|||||||
Reference in New Issue
Block a user