diff --git a/apps/api/package.json b/apps/api/package.json index f86f8cb..c22eae4 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -27,6 +27,7 @@ "express": "^4.21.2", "jsonwebtoken": "^9.0.2", "otplib": "^13.5.0", + "qrcode": "^1.5.4", "reflect-metadata": "^0.2.2", "rxjs": "^7.8.1" }, @@ -36,7 +37,9 @@ "@types/express": "^5.0.0", "@types/jsonwebtoken": "^9.0.7", "@types/node": "^22.10.5", + "@types/qrcode": "^1.5.6", "@types/supertest": "^6.0.2", + "pdf-lib": "^1.17.1", "supertest": "^7.0.0", "tsx": "^4.19.2", "typescript": "^5.7.3", diff --git a/apps/api/src/app.module.ts b/apps/api/src/app.module.ts index 3f2d8d5..bf4b94d 100644 --- a/apps/api/src/app.module.ts +++ b/apps/api/src/app.module.ts @@ -20,6 +20,7 @@ import { HrTimeclockModule } from "./hr-timeclock/hr-timeclock.module.js"; import { FiscalModule } from "./fiscal/fiscal.module.js"; import { BillingModule } from "./billing/billing.module.js"; import { DocumentsModule } from "./documents/documents.module.js"; +import { SignaturesModule } from "./signatures/signatures.module.js"; @Module({ imports: [ @@ -43,6 +44,7 @@ import { DocumentsModule } from "./documents/documents.module.js"; FiscalModule, BillingModule, DocumentsModule, + SignaturesModule, ], controllers: [HealthController], }) diff --git a/apps/api/src/signatures/certificate-template.ts b/apps/api/src/signatures/certificate-template.ts new file mode 100644 index 0000000..6a7ad3f --- /dev/null +++ b/apps/api/src/signatures/certificate-template.ts @@ -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 = { + 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 { + 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, "'"); +} + +export interface CertificateInput { + envelope: SignatureEnvelope; + documents: SignatureDocument[]; + signers: SignatureSigner[]; + auditEvents: SignatureAuditEvent[]; + verifyUrl: string; +} + +export async function buildCertificateHtml(input: CertificateInput): Promise { + 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) => ` + + ${escapeHtml(doc.title)} + v${doc.document_version} + ${doc.pageRange} + ${doc.original_sha256} + `, + ) + .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 + ? `Empresa: ${escapeHtml(signer.company_name)}${signer.role_title ? `Cargo: ${escapeHtml(signer.role_title)}` : ""}` + : ""; + return ` + + + + + ${companyRow} + + + +
SIGNATÁRIO — ${escapeHtml(signer.full_name)} (${PARTY_LABELS[signer.party] ?? signer.party})
CPF/CNPJ: ${doc}E-mail: ${maskEmail(signer.email)}
Status da autenticação: ${signer.status === "SIGNED" ? "VALIDADA" : signer.status}Assinado em: ${fmtDateTime(signer.signed_at)}
Cronologia: ${timeline}
`; + }) + .join(""); + + return ` + + + + + + + +
+
+
Handix do Brasil
+ QR Code de verificação +
+ +

CERTIFICADO DE AUTENTICIDADE E ASSINATURA ELETRÔNICA

+

Envelope ${escapeHtml(envelope.envelope_number)} · Emitido em ${fmtDateTime(today)}

+ + + + + + + + + +
IDENTIFICAÇÃO DA TRANSAÇÃO
Número do envelope: ${escapeHtml(envelope.envelope_number)}Status: ASSINADO E ÍNTEGRO
Cliente: ${escapeHtml(clientLabel)}Documentos: ${documents.length}
Assinatura concluída em: ${fmtDateTime(envelope.signed_at)}Tipo: Assinatura Eletrônica
Método de autenticação: Código de uso único (OTP) enviado por e-mail
+ + + + + + + ${documentRows} +
DOCUMENTOS INTEGRANTES
DocumentoVersãoPáginasSHA-256
+

Algoritmo de hash: SHA-256, calculado sobre cada documento congelado no momento da criação do envelope.

+ + ${signerBlocks} + + + + + + +
MANIFESTAÇÃO DE VONTADE
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".
+ + + + + + + + + + + +
INTEGRIDADE E AUTENTICIDADE
Documentos: ÍNTEGROSAutenticação: VALIDADA
Manifestação de vontade: REGISTRADAEvidence Checkpoint: VÁLIDO
Hash do envelope (documentos + signatários): ${envelope.envelope_hash ?? "—"}
Identificador do checkpoint: ${envelope.evidence_checkpoint_hash ?? "—"}
Verification ID: ${envelope.verification_id ?? "—"}
Verificação pública: ${escapeHtml(verifyUrl)}
+ + +
+ +`; +} + +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; } + `; +} diff --git a/apps/api/src/signatures/dto/cancel-envelope.dto.ts b/apps/api/src/signatures/dto/cancel-envelope.dto.ts new file mode 100644 index 0000000..3e487be --- /dev/null +++ b/apps/api/src/signatures/dto/cancel-envelope.dto.ts @@ -0,0 +1,5 @@ +import { IsOptional, IsString } from "class-validator"; + +export class CancelEnvelopeDto { + @IsOptional() @IsString() reason?: string; +} diff --git a/apps/api/src/signatures/dto/create-envelope.dto.ts b/apps/api/src/signatures/dto/create-envelope.dto.ts new file mode 100644 index 0000000..7b4a89d --- /dev/null +++ b/apps/api/src/signatures/dto/create-envelope.dto.ts @@ -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[]; +} diff --git a/apps/api/src/signatures/dto/send-invite.dto.ts b/apps/api/src/signatures/dto/send-invite.dto.ts new file mode 100644 index 0000000..a06a8b1 --- /dev/null +++ b/apps/api/src/signatures/dto/send-invite.dto.ts @@ -0,0 +1,6 @@ +import { IsIn } from "class-validator"; + +export class SendInviteDto { + @IsIn(["contratada", "contratante", "both"]) + targets!: "contratada" | "contratante" | "both"; +} diff --git a/apps/api/src/signatures/dto/verify-otp.dto.ts b/apps/api/src/signatures/dto/verify-otp.dto.ts new file mode 100644 index 0000000..38dc3b9 --- /dev/null +++ b/apps/api/src/signatures/dto/verify-otp.dto.ts @@ -0,0 +1,5 @@ +import { IsString, Length } from "class-validator"; + +export class VerifyOtpDto { + @IsString() @Length(6, 6) code!: string; +} diff --git a/apps/api/src/signatures/envelope-hash.ts b/apps/api/src/signatures/envelope-hash.ts new file mode 100644 index 0000000..2984bae --- /dev/null +++ b/apps/api/src/signatures/envelope-hash.ts @@ -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"); +} diff --git a/apps/api/src/signatures/envelope.service.ts b/apps/api/src/signatures/envelope.service.ts new file mode 100644 index 0000000..fae8315 --- /dev/null +++ b/apps/api/src/signatures/envelope.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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: `

Olá, ${signer.full_name}.

Você tem um documento aguardando assinatura eletrônica.

Acessar e assinar

Este link expira em 7 dias.

`, + }); + + 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 { + 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 { + 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); + } +} diff --git a/apps/api/src/signatures/finalize.service.ts b/apps/api/src/signatures/finalize.service.ts new file mode 100644 index 0000000..28de54d --- /dev/null +++ b/apps/api/src/signatures/finalize.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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: `

Olá, ${signer.full_name}.

Seu documento foi assinado eletronicamente por todas as partes. Segue em anexo o PDF final com certificado de autenticidade.

Verificação pública: ${verifyUrl}

`, + attachments: [{ filename: `${envelope.envelope_number}.pdf`, content: mergedBuffer, contentType: "application/pdf" }], + }); + } + await signaturesRepo.recordEvent({ envelopeId: id, eventType: "FINAL_DOCUMENT_EMAILED", ...actor }); + + return completed; + } +} diff --git a/apps/api/src/signatures/otp.ts b/apps/api/src/signatures/otp.ts new file mode 100644 index 0000000..c231138 --- /dev/null +++ b/apps/api/src/signatures/otp.ts @@ -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"); +} diff --git a/apps/api/src/signatures/public-signature.controller.ts b/apps/api/src/signatures/public-signature.controller.ts new file mode 100644 index 0000000..b61e96e --- /dev/null +++ b/apps/api/src/signatures/public-signature.controller.ts @@ -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)); + } +} diff --git a/apps/api/src/signatures/public-signature.service.ts b/apps/api/src/signatures/public-signature.service.ts new file mode 100644 index 0000000..015474c --- /dev/null +++ b/apps/api/src/signatures/public-signature.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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: `

Seu código de verificação:

${code}

Expira em 5 minutos e só pode ser usado uma vez.

`, + }); + 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 { + 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 { + 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), + }; + } +} diff --git a/apps/api/src/signatures/public-verify.controller.ts b/apps/api/src/signatures/public-verify.controller.ts new file mode 100644 index 0000000..5793485 --- /dev/null +++ b/apps/api/src/signatures/public-verify.controller.ts @@ -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); + } +} diff --git a/apps/api/src/signatures/public-verify.service.ts b/apps/api/src/signatures/public-verify.service.ts new file mode 100644 index 0000000..2ab38d7 --- /dev/null +++ b/apps/api/src/signatures/public-verify.service.ts @@ -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", + }; + } +} diff --git a/apps/api/src/signatures/signatures.controller.ts b/apps/api/src/signatures/signatures.controller.ts new file mode 100644 index 0000000..e142ed6 --- /dev/null +++ b/apps/api/src/signatures/signatures.controller.ts @@ -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); + } +} diff --git a/apps/api/src/signatures/signatures.module.ts b/apps/api/src/signatures/signatures.module.ts new file mode 100644 index 0000000..ba3dfc9 --- /dev/null +++ b/apps/api/src/signatures/signatures.module.ts @@ -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 {} diff --git a/apps/api/src/signatures/token.ts b/apps/api/src/signatures/token.ts new file mode 100644 index 0000000..819592f --- /dev/null +++ b/apps/api/src/signatures/token.ts @@ -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"); +} diff --git a/apps/api/src/signatures/user-agent.ts b/apps/api/src/signatures/user-agent.ts new file mode 100644 index 0000000..3ec3806 --- /dev/null +++ b/apps/api/src/signatures/user-agent.ts @@ -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 }; +} diff --git a/apps/api/test/signature-envelope-flow.spec.ts b/apps/api/test/signature-envelope-flow.spec.ts new file mode 100644 index 0000000..2bf303e --- /dev/null +++ b/apps/api/test/signature-envelope-flow.spec.ts @@ -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(); +const sendMailMock = vi.fn(async (_input: MockSendMailInput) => undefined); + +vi.mock("@eden/integrations", async () => { + const actual = await vi.importActual("@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); + }); +}); diff --git a/apps/api/test/signature-hash-chain.spec.ts b/apps/api/test/signature-hash-chain.spec.ts new file mode 100644 index 0000000..df19a62 --- /dev/null +++ b/apps/api/test/signature-hash-chain.spec.ts @@ -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); + }); +}); diff --git a/docs/progress.md b/docs/progress.md index 9cfa1b3..d31fe9d 100644 --- a/docs/progress.md +++ b/docs/progress.md @@ -539,3 +539,33 @@ Teste de integração novo (`test/contracts-edit-lock.spec.ts`, 3 testes, papel ### Pendências `contract_versions` (snapshot-on-signature, invariante nº4 do ADR-0006) segue deliberadamente adiado — precisa do desenho do fluxo de assinatura para não virar uma tabela vazia. `contract_amendments` (forma correta de alterar um contrato já assinado, em vez do override de super_admin) também segue adiado — depende de `contract_versions` primeiro. + +--- + +## Módulo de Assinatura Eletrônica — completo (envelopes, signatários, OTP, hash-chain, finalização, verificação pública) + +Data: 2026-09-04. Usuário pediu para seguir com a "questão da assinatura" — última peça do estudo do OrçaFácil. Dado o tamanho e a sensibilidade jurídica/criptográfica do módulo, os algoritmos de segurança (hash-chain, OTP, máquina de estados) foram re-verificados contra o código-fonte real do legado por um subagente `fork` dedicado, cruzado com `eden.md` módulo 4 (que já documentava o módulo com grande precisão, escrito numa leva anterior) — nenhuma linha de crypto foi escrita de memória. + +### Schema (`packages/database/migrations/1788536022387_signatures.cjs`) + +5 tabelas: `signature_envelopes` (19 estados, `company_id` obrigatório — diferente de `contracts.company_id`, que é opcional — porque `createEnvelope` recusa criar sem representante legal cadastrado), `signature_signers` (snapshot, nunca referência viva), `signature_documents` (cópia congelada + SHA-256), `signature_otp_challenges`, `signature_audit_events` (hash-chain, append-only via a mesma GUC `eden.allow_audit_mutation` que já protege o `audit_log` genérico desde a Fase 1 — reaproveitada, não duplicada). `packages/database/src/hash-chain.ts` novo, compartilhado entre `audit_log` e `signature_audit_events` (a canonicalização recursiva já existia em `audit.ts`, só foi extraída). + +**Três adaptações deliberadas ao EDEN, documentadas no cabeçalho da migration** (tudo o mais — os 19 estados, a fórmula do hash-chain, o algoritmo de OTP — é fidelidade exata ao legado): +1. `signature_documents.source_generation_id` referencia `document_generations` (o pipeline de PDF já construído nesta sessão) em vez do `source_attachment_id` do legado — EDEN não tem um módulo de upload de anexo genérico ainda. +2. `reseller_id` em vez de `reseller_registration_id` — EDEN modela revenda como uma tabela só. +3. Só existe a versão 2 (correta) da canonicalização do hash — a v1 do legado existe lá só para tolerar hashes já gravados antes de uma correção de bug real; EDEN nunca teve essa v1. + +### Certificado de conclusão — server-side em vez de client-side + +O legado desenha o certificado no navegador do backoffice (React → html2canvas → jsPDF) e recebe o PDF pronto via upload multipart em `finalizeEnvelope`. O EDEN já tem Chromium real server-side (construído para Documents/ANATEL) — `apps/api/src/signatures/certificate-template.ts` gera o HTML (conteúdo/texto legal portado verbatim, incluindo o aviso de que **não é certificação ICP-Brasil**) e renderiza no próprio servidor, eliminando o upload multipart inteiramente. Decisão deliberadamente limitada a essa troca técnica: os **dois passos autenticados** de finalização (`seal-checkpoint` depois `finalize`) foram mantidos exatamente como no legado — não virou automático mesmo sendo tecnicamente possível, porque esse checkpoint humano antes de uma finalização irreversível é comportamento de negócio, não um detalhe do desenho client-side do certificado. + +### Fluxo (`apps/api/src/signatures/`) + +`EnvelopeService` (criação — congela documentos com cópia S3 + SHA-256 recalculado, nunca confia cegamente no hash já gravado —, envio de convite, cancelamento, exclusão física só antes de SIGNED, verificação da cadeia), `PublicSignatureService` (fluxo do signatário só por token — nunca por `envelopeId`/`signerId` do corpo da requisição —, cada ação exige o estado anterior correto: confirmar leitura de cada documento → confirmar dados → aceitar termos → solicitar/validar OTP → assinar), `EnvelopeFinalizeService` (os dois passos), `PublicVerifyService` (só para `COMPLETED`, recomputa `envelope_hash` ao vivo e compara, nomes mascarados). Nota de design importante preservada do legado: o **envelope** salta direto de `SENT` para `SIGNED` quando o último signatário termina — o progresso fino (`VIEWED`..`OTP_VERIFIED`) vive inteiramente em `signature_signers.status`, nunca é espelhado passo a passo no envelope. + +### Verificação + +Suíte de testes nova e grande: `signature-hash-chain.spec.ts` (15 testes, puros, sem banco — canonicalização, determinismo do hash, máquina de estados, OTP, token) e `signature-envelope-flow.spec.ts` (13 testes, ciclo de vida completo via HTTP real: criação com signatário `contratada` automático, recusa sem representante legal, convite, os dois signatários passando pelo fluxo público inteiro incluindo código OTP errado/bloqueio/uso único, `SENT`→`SIGNED` no último signatário, cadeia de auditoria válida, `seal-checkpoint`, `finalize` com merge real via pdf-lib — confirmado contando páginas do PDF final —, `public-verify` com integridade válida e nomes mascarados, e proteção contra exclusão pós-assinatura). `renderPdf`/`uploadObject`/`getObjectStream`/`sendMail` mockados (S3 em memória); `mergePdfs`/`getPdfPageCount` NÃO mockados — o merge de PDF de verdade roda contra a saída (pequena, mas válida) do `renderPdf` mockado. Suíte completa: 70/70 verdes (42→70). `pnpm turbo run typecheck build --force` (28/28). + +### Pendências +Rate limiting nas rotas públicas (por IP e por token, eden.md §6.8/§10.3) — lacuna conhecida, mesmo padrão de outros endpoints públicos deste projeto que também não têm ainda (ex. forgot-password). UI de frontend para todo o módulo (criação/envio/acompanhamento de envelope no backoffice, tela pública de assinatura do signatário, página pública de verificação) — não construída nesta leva, só o backend. `DECLINED` (recusa de assinatura) é um estado reservado no schema mas sem rota que o produza — mesma lacuna do legado, nunca foi implementado lá também. diff --git a/packages/database/migrations/1788536022387_signatures.cjs b/packages/database/migrations/1788536022387_signatures.cjs new file mode 100644 index 0000000..2066466 --- /dev/null +++ b/packages/database/migrations/1788536022387_signatures.cjs @@ -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; + `); +}; diff --git a/packages/database/src/hash-chain.ts b/packages/database/src/hash-chain.ts new file mode 100644 index 0000000..a491ceb --- /dev/null +++ b/packages/database/src/hash-chain.ts @@ -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; + 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 { + return createHash("sha256") + .update(previousEventHash + canonicalJson(fields)) + .digest("hex"); +} diff --git a/packages/database/src/index.ts b/packages/database/src/index.ts index a148ef3..9de6af4 100644 --- a/packages/database/src/index.ts +++ b/packages/database/src/index.ts @@ -1,4 +1,5 @@ export { query, withTransaction, closePool } from "./pool.js"; +export { canonicalJson, computeEventHash } from "./hash-chain.js"; export * as rolesRepo from "./repositories/roles.js"; export * as usersRepo from "./repositories/users.js"; export * as sessionsRepo from "./repositories/sessions.js"; @@ -16,6 +17,7 @@ export * as hrTimeclockRepo from "./repositories/hr-timeclock.js"; export * as fiscalRepo from "./repositories/fiscal.js"; export * as billingRepo from "./repositories/billing.js"; export * as documentsRepo from "./repositories/documents.js"; +export * as signaturesRepo from "./repositories/signatures.js"; export { listLeads, getLead, @@ -178,3 +180,23 @@ export type { CreateGenerationInput, ListGenerationsFilter, } from "./repositories/documents.js"; +export type { + EnvelopeStatus, + SignerStatus, + OtpStatus, + ActorType, + PartyRole, + SignatureEnvelope, + SignatureSigner, + SignatureDocument, + SignatureOtpChallenge, + SignatureAuditEvent, + EnvelopeWithDetails, + CreateSignerInput, + CreateDocumentInput, + CreateEnvelopeInput, + ListEnvelopesFilter, + CreateOtpChallengeInput, + RecordEventInput, + VerifyChainResult, +} from "./repositories/signatures.js"; diff --git a/packages/database/src/repositories/audit.ts b/packages/database/src/repositories/audit.ts index b1d6387..b4c74d9 100644 --- a/packages/database/src/repositories/audit.ts +++ b/packages/database/src/repositories/audit.ts @@ -1,5 +1,5 @@ -import { createHash } from "node:crypto"; import { query } from "../pool.js"; +import { computeEventHash } from "../hash-chain.js"; export interface AuditEventInput { actorType: "user" | "system" | "service"; @@ -11,14 +11,6 @@ export interface AuditEventInput { ipAddress?: string | null; } -function canonicalJson(value: unknown): string { - if (value === null || typeof value !== "object") return JSON.stringify(value); - if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`; - const obj = value as Record; - const keys = Object.keys(obj).sort(); - return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`).join(",")}}`; -} - /** * Appends one event to the audit_log hash chain (see baseline-schema * migration and .claude/skills/eden-security/references/audit.md). Never @@ -43,9 +35,7 @@ export async function appendAuditEvent(input: AuditEventInput): Promise { metadata: input.metadata ?? {}, ipAddress: input.ipAddress ?? null, }; - const eventHash = createHash("sha256") - .update(previousEventHash + canonicalJson(fields)) - .digest("hex"); + const eventHash = computeEventHash(previousEventHash, fields); await query( `INSERT INTO audit_log diff --git a/packages/database/src/repositories/signatures.ts b/packages/database/src/repositories/signatures.ts new file mode 100644 index 0000000..54205a0 --- /dev/null +++ b/packages/database/src/repositories/signatures.ts @@ -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 = { + 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; + 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 { + 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( + `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( + `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( + `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( + "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 { + if (filter.scope === "reseller") { + const { rows } = await query( + "SELECT * FROM signature_envelopes WHERE reseller_id = $1 ORDER BY created_at DESC", + [filter.resellerId ?? null], + ); + return rows; + } + const { rows } = await query("SELECT * FROM signature_envelopes ORDER BY created_at DESC"); + return rows; +} + +async function listSigners(envelopeId: string): Promise { + const { rows } = await query( + "SELECT * FROM signature_signers WHERE envelope_id = $1 ORDER BY sort_order ASC", + [envelopeId], + ); + return rows; +} + +async function listDocuments(envelopeId: string): Promise { + const { rows } = await query( + "SELECT * FROM signature_documents WHERE envelope_id = $1 ORDER BY sort_order ASC", + [envelopeId], + ); + return rows; +} + +export async function getEnvelopeById(id: string): Promise { + const { rows } = await query("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 { + const { rows } = await query( + "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 { + const { rows } = await query("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 { + const { rows } = await query("SELECT * FROM signature_signers WHERE secure_token_hash = $1", [ + tokenHash, + ]); + return rows[0] ?? null; +} + +export async function getLatestOtpChallengeForSigner(signerId: string): Promise { + const { rows } = await query( + "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> = { + 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 { + const run = async (client: PoolClient): Promise => { + 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(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 { + const run = async (client: PoolClient): Promise => { + const setSignedAt = toStatus === "SIGNED" ? ", signed_at = now()" : ""; + const { rows } = await client.query( + `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 { + const { rows } = await query( + `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 { + 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 { + 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( + `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 { + 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 { + 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 { + 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; +} + +/** + * 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 { + 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 { + 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 { + 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 { + const run = async (client: PoolClient): Promise => { + 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 { + const { rows } = await query( + "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 { + 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 { + 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( + `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 { + const { rows } = await query( + `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 { + 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 { + 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]); + }); +} diff --git a/packages/integrations/package.json b/packages/integrations/package.json index 6e20ad9..11e11c7 100644 --- a/packages/integrations/package.json +++ b/packages/integrations/package.json @@ -14,6 +14,7 @@ "@aws-sdk/client-s3": "^3.1096.0", "@eden/config": "workspace:*", "nodemailer": "^9.1.1", + "pdf-lib": "^1.17.1", "playwright": "^1.62.1" }, "devDependencies": { diff --git a/packages/integrations/src/index.ts b/packages/integrations/src/index.ts index 9267427..6e22ac5 100644 --- a/packages/integrations/src/index.ts +++ b/packages/integrations/src/index.ts @@ -7,3 +7,4 @@ export * from "./s3/client.js"; export * from "./smtp/mailer.js"; export * from "./pdf/renderer.js"; +export * from "./pdf/merge.js"; diff --git a/packages/integrations/src/pdf/merge.ts b/packages/integrations/src/pdf/merge.ts new file mode 100644 index 0000000..12a38a8 --- /dev/null +++ b/packages/integrations/src/pdf/merge.ts @@ -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 { + try { + const doc = await PDFDocument.load(buffer); + return doc.getPageCount(); + } catch { + return null; + } +} diff --git a/packages/integrations/src/smtp/mailer.ts b/packages/integrations/src/smtp/mailer.ts index 76b4074..3326e98 100644 --- a/packages/integrations/src/smtp/mailer.ts +++ b/packages/integrations/src/smtp/mailer.ts @@ -11,11 +11,18 @@ import nodemailer, { type Transporter } from "nodemailer"; import { loadEnv } from "@eden/config"; +export interface SendMailAttachment { + filename: string; + content: Buffer; + contentType?: string; +} + export interface SendMailInput { to: string; subject: string; html: string; from?: string; + attachments?: SendMailAttachment[]; } let transporter: Transporter | undefined; @@ -55,6 +62,7 @@ export async function sendMail({ subject, html, from, + attachments, }: SendMailInput): Promise { const t = getTransporter(); if (!t) { @@ -69,6 +77,7 @@ export async function sendMail({ to, subject, html, + attachments, }); } catch (err) { console.error(`[mailer] falha ao enviar e-mail para ${to}:`, err); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 39c0a30..ce4aa92 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -62,6 +62,9 @@ importers: otplib: specifier: ^13.5.0 version: 13.5.0 + qrcode: + specifier: ^1.5.4 + version: 1.5.4 reflect-metadata: specifier: ^0.2.2 version: 0.2.2 @@ -84,9 +87,15 @@ importers: '@types/node': specifier: ^22.10.5 version: 22.20.1 + '@types/qrcode': + specifier: ^1.5.6 + version: 1.5.6 '@types/supertest': specifier: ^6.0.2 version: 6.0.3 + pdf-lib: + specifier: ^1.17.1 + version: 1.17.1 supertest: specifier: ^7.0.0 version: 7.2.2(supports-color@7.2.0) @@ -306,6 +315,9 @@ importers: nodemailer: specifier: ^9.1.1 version: 9.1.1 + pdf-lib: + specifier: ^1.17.1 + version: 1.17.1 playwright: specifier: ^1.62.1 version: 1.62.1 @@ -1166,6 +1178,12 @@ packages: '@paralleldrive/cuid2@2.3.1': resolution: {integrity: sha512-XO7cAxhnTZl0Yggq6jOgjiOHhbgcO4NqFqwSmQpjK3b6TEE6Uj/jfSk6wzYyemh3+I0sHirKSetjQwn5cZktFw==} + '@pdf-lib/standard-fonts@1.0.0': + resolution: {integrity: sha512-hU30BK9IUN/su0Mn9VdlVKsWBS6GyhVfqjwl1FjZN4TxP6cCw0jP2w7V3Hf5uX7M0AZJ16vey9yE0ny7Sa59ZA==} + + '@pdf-lib/upng@1.0.1': + resolution: {integrity: sha512-dQK2FUMQtowVP00mtIksrlZhdFXQZPC+taih1q4CvPZ5vqdxR/LKBaFg0oAfzd1GlHZXXSPdQfzQnt+ViGvEIQ==} + '@phc/format@1.0.0': resolution: {integrity: sha512-m7X9U6BG2+J+R1lSOdCiITLLrxm+cWlNI3HUFA92oLO77ObGNzaKdh8pMLqdZcshtkKuV84olNNXDfMc4FezBQ==} engines: {node: '>=10'} @@ -1538,6 +1556,9 @@ packages: '@types/prop-types@15.7.15': resolution: {integrity: sha512-F6bEyamV9jKGAFBEmlQnesRPGOQqS2+Uwi0Em15xenOxHaf2hv6L8YCVn3rPdPJOiJfPiCnLIRyvwVaqMY3MIw==} + '@types/qrcode@1.5.6': + resolution: {integrity: sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==} + '@types/qs@6.15.1': resolution: {integrity: sha512-GZHUBZR9hckSUhrxmp1nG6NwdpM9fCunJwyThLW1X3AyHgd9IlHb6VANpQQqDr2o/qQp6McZ3y/IA2rVzKzSbw==} @@ -1728,6 +1749,10 @@ packages: resolution: {integrity: sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==} engines: {node: '>= 6'} + camelcase@5.3.1: + resolution: {integrity: sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==} + engines: {node: '>=6'} + camelcase@6.3.0: resolution: {integrity: sha512-Gmy6FhYlCY7uOElZUSbxo2UCDH8owEk996gkbrpsgGtrJLM3J7jGxl9Ic7Qwwj4ivOE5AWZWRMecDdF7hqGjFA==} engines: {node: '>=10'} @@ -1765,6 +1790,9 @@ packages: resolution: {integrity: sha512-y4coMcylgSCdVinjiDBuR8PCC2bLjyGTwEmPb9NHR/QaNU6EUOXcTY/s6VjGMD6ENSEaeQYHCY0GNGS5jfMwPw==} engines: {node: '>=6'} + cliui@6.0.0: + resolution: {integrity: sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==} + cliui@8.0.1: resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} engines: {node: '>=12'} @@ -1852,6 +1880,10 @@ packages: supports-color: optional: true + decamelize@1.2.0: + resolution: {integrity: sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==} + engines: {node: '>=0.10.0'} + deep-eql@5.0.2: resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} engines: {node: '>=6'} @@ -1874,6 +1906,9 @@ packages: didyoumean@1.2.2: resolution: {integrity: sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==} + dijkstrajs@1.0.3: + resolution: {integrity: sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==} + dlv@1.1.3: resolution: {integrity: sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==} @@ -1994,6 +2029,10 @@ packages: resolution: {integrity: sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==} engines: {node: '>= 0.8'} + find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + foreground-child@3.3.1: resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} engines: {node: '>=14'} @@ -2178,6 +2217,10 @@ packages: resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + lodash.includes@4.3.0: resolution: {integrity: sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==} @@ -2359,13 +2402,32 @@ packages: otplib@13.5.0: resolution: {integrity: sha512-RpcC6aq4rANX6MverMuU3pqHVLgMPO7vl6qR8ga7YzoEHlPJknJ58cSVAaf3jBYnasiQVQHXXl2mj/4pYlsVEQ==} + p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + + p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + + p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + package-json-from-dist@1.0.1: resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + pako@1.0.11: + resolution: {integrity: sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==} + parseurl@1.3.3: resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} engines: {node: '>= 0.8'} + path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + path-key@3.1.1: resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} engines: {node: '>=8'} @@ -2390,6 +2452,9 @@ packages: resolution: {integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==} engines: {node: '>= 14.16'} + pdf-lib@1.17.1: + resolution: {integrity: sha512-V/mpyJAoTsN4cnP31vc0wfNA1+p20evqqnap0KLoRUN0Yk/p3wN52DOEsL4oBFcLdb76hlpKPtzJIgo67j/XLw==} + pg-cloudflare@1.4.0: resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} @@ -2449,6 +2514,10 @@ packages: engines: {node: '>=20'} hasBin: true + pngjs@5.0.0: + resolution: {integrity: sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==} + engines: {node: '>=10.13.0'} + postcss-import@15.1.0: resolution: {integrity: sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==} engines: {node: '>=14.0.0'} @@ -2516,6 +2585,11 @@ packages: resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} engines: {node: '>= 0.10'} + qrcode@1.5.4: + resolution: {integrity: sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==} + engines: {node: '>=10.13.0'} + hasBin: true + qs@6.14.2: resolution: {integrity: sha512-V/yCWTTF7VJ9hIh18Ugr2zhJMP01MY7c5kh4J870L7imm6/DIzBsNLTXzMwUA3yZ5b/KBqLx8Kp3uRvd7xSe3Q==} engines: {node: '>=0.6'} @@ -2579,6 +2653,9 @@ packages: resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} engines: {node: '>=0.10.0'} + require-main-filename@2.0.0: + resolution: {integrity: sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==} + resolve@1.22.12: resolution: {integrity: sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==} engines: {node: '>= 0.4'} @@ -2625,6 +2702,9 @@ packages: resolution: {integrity: sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==} engines: {node: '>= 0.8.0'} + set-blocking@2.0.0: + resolution: {integrity: sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==} + setprototypeof@1.2.0: resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} @@ -2772,6 +2852,9 @@ packages: ts-interface-checker@0.1.13: resolution: {integrity: sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==} + tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} @@ -2984,6 +3067,9 @@ packages: whatwg-url@5.0.0: resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + which-module@2.0.1: + resolution: {integrity: sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -2998,6 +3084,10 @@ packages: resolution: {integrity: sha512-NsmoXalsWVDMGupxZ5R08ka9flZjjiLvHVAWYOKtiKM8ujtZWr9cRffak+uSE48+Ob8ObalXpwyeUiyDD6QFgg==} engines: {node: '>=8'} + wrap-ansi@6.2.0: + resolution: {integrity: sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==} + engines: {node: '>=8'} + wrap-ansi@7.0.0: resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} engines: {node: '>=10'} @@ -3009,6 +3099,9 @@ packages: resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} engines: {node: '>=0.4'} + y18n@4.0.3: + resolution: {integrity: sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==} + y18n@5.0.8: resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} engines: {node: '>=10'} @@ -3016,10 +3109,18 @@ packages: yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + yargs-parser@18.1.3: + resolution: {integrity: sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==} + engines: {node: '>=6'} + yargs-parser@21.1.1: resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} engines: {node: '>=12'} + yargs@15.4.1: + resolution: {integrity: sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==} + engines: {node: '>=8'} + yargs@17.7.3: resolution: {integrity: sha512-GZtjxm/J/4TSxuL3FNYjCmLktBTnIw/rVmKSIyKeYAZpmJB2ig9VauCC5xsa82GNKVKDAqpOn3KVzNt0zmrU0g==} engines: {node: '>=12'} @@ -3700,6 +3801,14 @@ snapshots: dependencies: '@noble/hashes': 1.8.0 + '@pdf-lib/standard-fonts@1.0.0': + dependencies: + pako: 1.0.11 + + '@pdf-lib/upng@1.0.1': + dependencies: + pako: 1.0.11 + '@phc/format@1.0.0': {} '@remix-run/router@1.23.4': {} @@ -3988,6 +4097,10 @@ snapshots: '@types/prop-types@15.7.15': {} + '@types/qrcode@1.5.6': + dependencies: + '@types/node': 22.20.1 + '@types/qs@6.15.1': {} '@types/range-parser@1.2.7': {} @@ -4220,6 +4333,8 @@ snapshots: camelcase-css@2.0.1: {} + camelcase@5.3.1: {} + camelcase@6.3.0: {} caniuse-lite@1.0.30001810: {} @@ -4263,6 +4378,12 @@ snapshots: cli-boxes@2.2.1: {} + cliui@6.0.0: + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 6.2.0 + cliui@8.0.1: dependencies: string-width: 4.2.3 @@ -4335,6 +4456,8 @@ snapshots: optionalDependencies: supports-color: 7.2.0 + decamelize@1.2.0: {} + deep-eql@5.0.2: {} delayed-stream@1.0.0: {} @@ -4350,6 +4473,8 @@ snapshots: didyoumean@1.2.2: {} + dijkstrajs@1.0.3: {} + dlv@1.1.3: {} dunder-proto@1.0.1: @@ -4602,6 +4727,11 @@ snapshots: transitivePeerDependencies: - supports-color + find-up@4.1.0: + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + foreground-child@3.3.1: dependencies: cross-spawn: 7.0.6 @@ -4776,6 +4906,10 @@ snapshots: load-tsconfig@0.2.5: {} + locate-path@5.0.0: + dependencies: + p-locate: 4.1.0 + lodash.includes@4.3.0: {} lodash.isboolean@3.0.3: {} @@ -4914,10 +5048,24 @@ snapshots: '@otplib/totp': 13.5.0 '@otplib/uri': 13.5.0 + p-limit@2.3.0: + dependencies: + p-try: 2.2.0 + + p-locate@4.1.0: + dependencies: + p-limit: 2.3.0 + + p-try@2.2.0: {} + package-json-from-dist@1.0.1: {} + pako@1.0.11: {} + parseurl@1.3.3: {} + path-exists@4.0.0: {} + path-key@3.1.1: {} path-parse@1.0.7: {} @@ -4935,6 +5083,13 @@ snapshots: pathval@2.0.1: {} + pdf-lib@1.17.1: + dependencies: + '@pdf-lib/standard-fonts': 1.0.0 + '@pdf-lib/upng': 1.0.1 + pako: 1.0.11 + tslib: 1.14.1 + pg-cloudflare@1.4.0: optional: true @@ -4986,6 +5141,8 @@ snapshots: optionalDependencies: fsevents: 2.3.2 + pngjs@5.0.0: {} + postcss-import@15.1.0(postcss@8.5.26): dependencies: postcss: 8.5.26 @@ -5039,6 +5196,12 @@ snapshots: forwarded: 0.2.0 ipaddr.js: 1.9.1 + qrcode@1.5.4: + dependencies: + dijkstrajs: 1.0.3 + pngjs: 5.0.0 + yargs: 15.4.1 + qs@6.14.2: dependencies: side-channel: 1.1.1 @@ -5099,6 +5262,8 @@ snapshots: require-directory@2.1.1: {} + require-main-filename@2.0.0: {} + resolve@1.22.12: dependencies: es-errors: 1.3.0 @@ -5187,6 +5352,8 @@ snapshots: transitivePeerDependencies: - supports-color + set-blocking@2.0.0: {} + setprototypeof@1.2.0: {} shebang-command@2.0.0: @@ -5366,6 +5533,8 @@ snapshots: ts-interface-checker@0.1.13: {} + tslib@1.14.1: {} + tslib@2.8.1: {} tsx@4.23.13: @@ -5530,6 +5699,8 @@ snapshots: tr46: 0.0.3 webidl-conversions: 3.0.1 + which-module@2.0.1: {} + which@2.0.2: dependencies: isexe: 2.0.0 @@ -5543,6 +5714,12 @@ snapshots: dependencies: string-width: 4.2.3 + wrap-ansi@6.2.0: + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi@7.0.0: dependencies: ansi-styles: 4.3.0 @@ -5553,12 +5730,33 @@ snapshots: xtend@4.0.2: {} + y18n@4.0.3: {} + y18n@5.0.8: {} yallist@3.1.1: {} + yargs-parser@18.1.3: + dependencies: + camelcase: 5.3.1 + decamelize: 1.2.0 + yargs-parser@21.1.1: {} + yargs@15.4.1: + dependencies: + cliui: 6.0.0 + decamelize: 1.2.0 + find-up: 4.1.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + require-main-filename: 2.0.0 + set-blocking: 2.0.0 + string-width: 4.2.3 + which-module: 2.0.1 + y18n: 4.0.3 + yargs-parser: 18.1.3 + yargs@17.7.3: dependencies: cliui: 8.0.1