feat(signatures): módulo completo de assinatura eletrônica

Porta o motor de assinatura do legado com fidelidade algorítmica
exata — hash-chain (SHA256 encadeado, seq bigserial define a ordem,
não timestamp), OTP (6 dígitos via crypto.randomInt, HMAC-SHA256 com
SIGNATURE_OTP_SECRET, TTL 5min, 5 tentativas, único ativo por vez) e
a máquina de estados de 19 estados do envelope + 8 do signatário —
tudo re-verificado contra o código-fonte real do legado por um
subagente dedicado antes de qualquer linha ser escrita, cruzado com
eden.md módulo 4.

5 tabelas novas (signature_envelopes/signers/documents/
otp_challenges/audit_events, esta última append-only via a mesma GUC
eden.allow_audit_mutation que já protege audit_log desde a Fase 1 —
canonicalização de hash extraída para packages/database/src/
hash-chain.ts, compartilhada entre as duas). Três adaptações
deliberadas ao EDEN (documentadas na migration): documentos vêm de
document_generations em vez de um módulo de anexo genérico que não
existe ainda; reseller_id em vez de reseller_registration_id; só a
versão correta (v2) da canonicalização, sem a v1 do legado (existia
lá só para tolerar hashes pré-correção de bug, EDEN nunca teve isso).

Certificado de conclusão renderizado no servidor via Chromium (não
client-side como no legado) — elimina o upload multipart do
certificado pronto. Os dois passos autenticados de finalização
(seal-checkpoint, finalize) continuam explícitos e manuais, não
automáticos, mesmo sendo tecnicamente possível agora — esse
checkpoint humano antes de uma finalização irreversível é
comportamento de negócio herdado do legado, não um detalhe do
desenho client-side do certificado que deixou de existir.

Fluxo público (token do link, nunca envelopeId/signerId do corpo da
requisição) e verificação pública (só para COMPLETED, recomputa
envelope_hash ao vivo, nomes mascarados) inteiros.

Verificado: 28 testes novos (15 puros — hash-chain/máquina de
estados/OTP/token — e 13 de ciclo de vida completo via HTTP real,
incluindo merge de PDF de verdade via pdf-lib contra saída real do
Chromium mockado). Suíte completa: 70/70 verdes. Testado ao vivo
através do container real, ponta a ponta, sem nenhum mock: criação →
convite → fluxo público completo dos dois signatários (OTP
recuperado via HMAC com o segredo real, já que SMTP não está
configurado neste ambiente) → SIGNED → seal-checkpoint → finalize
(certificado real via Chromium, merge real, upload real no MinIO) →
public-verify — SHA-256 do PDF final baixado bate byte a byte com o
gravado no Postgres, integrityValid:true.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BcWWuKfykGtUweXmHD6cKx
This commit is contained in:
2026-09-04 13:44:43 -03:00
parent d46d44f01b
commit 20b114a0bf
32 changed files with 3232 additions and 12 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -14,6 +14,7 @@
"@aws-sdk/client-s3": "^3.1096.0",
"@eden/config": "workspace:*",
"nodemailer": "^9.1.1",
"pdf-lib": "^1.17.1",
"playwright": "^1.62.1"
},
"devDependencies": {

View File

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

View File

@@ -0,0 +1,32 @@
/**
* PDF merge — used by the e-signature module's finalization step (all
* frozen documents, in order, followed by the certificate) to produce
* one consolidated final PDF. Ported from the legacy's use of `pdf-lib`
* for the same purpose (eden.md §8.3).
*/
import { PDFDocument } from "pdf-lib";
export async function mergePdfs(buffers: Buffer[]): Promise<{ buffer: Buffer; pageCount: number }> {
const merged = await PDFDocument.create();
let pageCount = 0;
for (const buffer of buffers) {
const source = await PDFDocument.load(buffer);
const pages = await merged.copyPages(source, source.getPageIndices());
for (const page of pages) {
merged.addPage(page);
pageCount += 1;
}
}
const bytes = await merged.save();
return { buffer: Buffer.from(bytes), pageCount };
}
/** Page count of a single PDF buffer — used to record `signature_documents.page_count` when freezing a document. Returns `null` if the buffer isn't a valid PDF (never blocks freezing, matches eden.md §1.6). */
export async function getPdfPageCount(buffer: Buffer): Promise<number | null> {
try {
const doc = await PDFDocument.load(buffer);
return doc.getPageCount();
} catch {
return null;
}
}

View File

@@ -11,11 +11,18 @@
import nodemailer, { type Transporter } from "nodemailer";
import { loadEnv } from "@eden/config";
export interface SendMailAttachment {
filename: string;
content: Buffer;
contentType?: string;
}
export interface SendMailInput {
to: string;
subject: string;
html: string;
from?: string;
attachments?: SendMailAttachment[];
}
let transporter: Transporter | undefined;
@@ -55,6 +62,7 @@ export async function sendMail({
subject,
html,
from,
attachments,
}: SendMailInput): Promise<void> {
const t = getTransporter();
if (!t) {
@@ -69,6 +77,7 @@ export async function sendMail({
to,
subject,
html,
attachments,
});
} catch (err) {
console.error(`[mailer] falha ao enviar e-mail para ${to}:`, err);