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:
283
packages/database/migrations/1788536022387_signatures.cjs
Normal file
283
packages/database/migrations/1788536022387_signatures.cjs
Normal file
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* Assinatura Eletrônica — eden.md módulo 4, §§1.4-1.9 e 5-10. Deliberadamente
|
||||
* adiado desde 1788468521075_documents.cjs ("cryptographically/legally
|
||||
* sensitive... needs its own dedicated migration and review"). Ported com
|
||||
* fidelidade algorítmica exata (máquina de estados, hash-chain, OTP) — as
|
||||
* únicas divergências deliberadas do legado, documentadas caso a caso
|
||||
* abaixo, são adaptações à própria arquitetura do EDEN, nunca ao conteúdo
|
||||
* de segurança/negócio em si:
|
||||
*
|
||||
* - `signature_documents.source_generation_id` referencia
|
||||
* `document_generations(id)` em vez do `source_attachment_id` do
|
||||
* legado — EDEN ainda não tem um módulo de upload de anexo genérico
|
||||
* (client_registration_attachments/reseller_registration_attachments
|
||||
* nunca foram portados), mas já tem `document_generations` (PDFs reais,
|
||||
* gerados via Chromium, com SHA-256 e S3 desde a leva anterior desta
|
||||
* sessão) — a fonte natural de "documento já gerado, hasheado, pronto
|
||||
* para congelar" no EDEN. Igual ao legado, é só referência histórica:
|
||||
* uma vez congelado em `signature_documents`, a cópia S3 própria e o
|
||||
* hash já congelados são a fonte da verdade, não a FK.
|
||||
* - `reseller_id` (FK para `resellers`) em vez de `reseller_registration_id`
|
||||
* — o EDEN não tem uma entidade "cadastro de revenda" separada da
|
||||
* revenda em si (eden.md documenta `reseller_registration_id` como a
|
||||
* origem alternativa a `client_registration_id`, mas o EDEN modela
|
||||
* revenda como uma única tabela `resellers`, não duas). Os dados do
|
||||
* signatário `contratante` são sempre informados explicitamente na
|
||||
* criação do envelope em ambos os sistemas (nunca derivados
|
||||
* automaticamente do cadastro de origem) — só o signatário
|
||||
* `contratada` é auto-preenchido, de `companies.legal_rep_*` — então
|
||||
* essa troca não perde nenhum dado necessário.
|
||||
* - `hash_algorithm_version` existe mas só a versão 2 (a fórmula correta,
|
||||
* canonicalização recursiva em profundidade) é usada — ver o
|
||||
* doc-comment de packages/database/src/hash-chain.ts para o porquê da
|
||||
* v1 do legado (compat com hashes já gravados antes de uma correção de
|
||||
* bug real) não fazer sentido aqui: o EDEN nunca teve uma v1 buggy.
|
||||
* - Certificado de conclusão: o legado desenha client-side (React →
|
||||
* html2canvas → jsPDF, recebido via upload multipart em
|
||||
* `finalizeEnvelope`). O EDEN já tem Chromium server-side real
|
||||
* (packages/integrations/src/pdf/renderer.ts, construído nesta mesma
|
||||
* sessão) — o certificado é renderizado no servidor a partir dos
|
||||
* mesmos dados já gravados (evidence_checkpoint_hash/verification_id/
|
||||
* envelope_hash), eliminando o upload multipart do passo 2 inteiramente
|
||||
* (o servidor nunca precisa confiar num PDF vindo de fora como "o
|
||||
* certificado"). Ver apps/api/src/signatures/.
|
||||
*
|
||||
* Tudo o mais — os 19 estados do envelope, os 8 estados do signatário, o
|
||||
* algoritmo de hash-chain (SHA256(previous + canonical_json(campos)),
|
||||
* `seq` bigserial definindo a ordem, não timestamp), o OTP (6 dígitos via
|
||||
* crypto.randomInt, HMAC-SHA256 com SIGNATURE_OTP_SECRET, TTL de 5min,
|
||||
* máx. 5 tentativas, um único código ativo por vez), o token de link
|
||||
* público (32 bytes aleatórios, só o SHA-256 persistido, 7 dias de
|
||||
* validade), o processo de finalização em dois passos (selar checkpoint
|
||||
* ANTES do certificado existir, depois consolidar via pdf-lib) — tudo isso
|
||||
* é fidelidade exata ao legado, verificado contra o código-fonte real
|
||||
* (não reconstruído de memória).
|
||||
*/
|
||||
|
||||
exports.shorthands = undefined;
|
||||
|
||||
exports.up = (pgm) => {
|
||||
pgm.sql(`
|
||||
-- =====================================================================
|
||||
-- signature_envelopes — o agregado raiz
|
||||
-- =====================================================================
|
||||
CREATE SEQUENCE signature_envelope_seq START 1;
|
||||
|
||||
CREATE TABLE signature_envelopes (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
created_by UUID REFERENCES users(id),
|
||||
envelope_number TEXT UNIQUE NOT NULL,
|
||||
company_id UUID NOT NULL REFERENCES companies(id),
|
||||
client_registration_id UUID REFERENCES client_registrations(id),
|
||||
reseller_id UUID REFERENCES resellers(id),
|
||||
status TEXT NOT NULL DEFAULT 'DRAFT' CHECK (status IN (
|
||||
'DRAFT', 'READY', 'SENT', 'VIEWED', 'IDENTITY_PENDING', 'CONSENT_PENDING',
|
||||
'OTP_PENDING', 'OTP_SENT', 'OTP_VERIFIED', 'READY_TO_SIGN', 'SIGNING', 'SIGNED',
|
||||
'FINALIZING', 'COMPLETED', 'DECLINED', 'EXPIRED', 'CANCELLED', 'SUPERSEDED', 'ERROR'
|
||||
)),
|
||||
verification_id TEXT UNIQUE,
|
||||
final_s3_key TEXT,
|
||||
final_pdf_sha256 TEXT,
|
||||
certificate_s3_key TEXT,
|
||||
evidence_checkpoint_hash TEXT,
|
||||
envelope_hash TEXT,
|
||||
sent_at TIMESTAMPTZ,
|
||||
viewed_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ,
|
||||
declined_at TIMESTAMPTZ,
|
||||
cancelled_at TIMESTAMPTZ,
|
||||
expired_at TIMESTAMPTZ,
|
||||
signed_at TIMESTAMPTZ,
|
||||
CONSTRAINT chk_signature_envelopes_one_source CHECK (
|
||||
(client_registration_id IS NOT NULL)::int + (reseller_id IS NOT NULL)::int = 1
|
||||
)
|
||||
);
|
||||
COMMENT ON TABLE signature_envelopes IS
|
||||
'Pacote de assinatura — conjunto de documentos + signatários passando por uma máquina de estados única (eden.md §1.4, §5.1). envelope_number formato SIG-{ano}-{seq6digitos}, gerado pela camada de repositório (mesmo padrão de contracts.contract_number).';
|
||||
COMMENT ON COLUMN signature_envelopes.company_id IS
|
||||
'Empresa emissora (contratada) — resolve o signatário party=contratada automático a partir de companies.legal_rep_{name,cpf,email,role}. Obrigatório (NOT NULL, diferente de contracts.company_id que é opcional): createEnvelope recusa criar o envelope se essa empresa não tiver representante legal cadastrado, eden.md §1.5/§5.4.';
|
||||
COMMENT ON COLUMN signature_envelopes.status IS
|
||||
'Máquina de estados centralizada no backend, nunca confiada ao frontend — ver ALLOWED_TRANSITIONS em apps/api/src/signatures/state-machine.ts. SENT->SIGNED é um salto direto deliberado: o progresso fino (VIEWED..OTP_VERIFIED) é rastreado por signatário (signature_signers.status), não replicado passo a passo no envelope.';
|
||||
COMMENT ON COLUMN signature_envelopes.verification_id IS
|
||||
'16 bytes aleatórios em hex (crypto.randomBytes(16)), gerado só na finalização (sealEvidenceCheckpoint) — usado na URL pública de verificação /verify/{verificationId}.';
|
||||
COMMENT ON COLUMN signature_envelopes.evidence_checkpoint_hash IS
|
||||
'event_hash do último evento de auditoria do envelope no momento do selamento (sealEvidenceCheckpoint) — NÃO é o hash final absoluto da auditoria inteira, a cadeia continua recebendo eventos depois (ex. CERTIFICATE_GENERATED).';
|
||||
COMMENT ON COLUMN signature_envelopes.envelope_hash IS
|
||||
'SHA-256 dos fatos lógicos do envelope (envelope_number, evidence_checkpoint_hash, documentos e signatários ordenados por sort_order) — terceira camada de integridade, independente do PDF em bytes. Ver computeEnvelopeHash em apps/api/src/signatures/finalize.service.ts.';
|
||||
|
||||
CREATE TRIGGER trg_signature_envelopes_updated_at BEFORE UPDATE ON signature_envelopes
|
||||
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
CREATE INDEX idx_signature_envelopes_status ON signature_envelopes(status);
|
||||
CREATE INDEX idx_signature_envelopes_client_registration ON signature_envelopes(client_registration_id);
|
||||
CREATE INDEX idx_signature_envelopes_reseller ON signature_envelopes(reseller_id);
|
||||
|
||||
ALTER SEQUENCE signature_envelope_seq OWNED BY NONE;
|
||||
|
||||
-- =====================================================================
|
||||
-- signature_signers — snapshot, nunca referência viva (eden.md §1.5)
|
||||
-- =====================================================================
|
||||
CREATE TABLE signature_signers (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
envelope_id UUID NOT NULL REFERENCES signature_envelopes(id) ON DELETE CASCADE,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
party TEXT NOT NULL DEFAULT 'contratante' CHECK (party IN ('contratada', 'contratante')),
|
||||
person_type TEXT CHECK (person_type IS NULL OR person_type IN ('pf', 'pj')),
|
||||
full_name TEXT NOT NULL,
|
||||
cpf TEXT,
|
||||
email TEXT NOT NULL,
|
||||
phone TEXT,
|
||||
company_name TEXT,
|
||||
cnpj TEXT,
|
||||
role_title TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'PENDING' CHECK (status IN (
|
||||
'PENDING', 'LINK_SENT', 'VIEWED', 'DATA_CONFIRMED', 'CONSENTED',
|
||||
'OTP_VERIFIED', 'SIGNED', 'DECLINED'
|
||||
)),
|
||||
signed_at TIMESTAMPTZ,
|
||||
secure_token_hash TEXT UNIQUE,
|
||||
secure_token_created_at TIMESTAMPTZ,
|
||||
secure_token_expires_at TIMESTAMPTZ,
|
||||
secure_token_revoked_at TIMESTAMPTZ,
|
||||
secure_token_first_used_at TIMESTAMPTZ,
|
||||
secure_token_last_used_at TIMESTAMPTZ
|
||||
);
|
||||
COMMENT ON TABLE signature_signers IS
|
||||
'Um signatário dentro de um envelope — SEMPRE um snapshot (nome/CPF/e-mail congelados no momento da criação do envelope), nunca FK viva para o cadastro do cliente. Todo envelope tem exatamente um party=''contratada'' (auto-preenchido de companies.legal_rep_*) e um ou mais party=''contratante'' (informados explicitamente na criação, eden.md §5.3).';
|
||||
COMMENT ON COLUMN signature_signers.secure_token_hash IS
|
||||
'SHA-256 simples (não HMAC) do token do link público — o "segredo" aqui é a entropia do token (32 bytes/256 bits via crypto.randomBytes), diferente do OTP (HMAC porque o espaço de 6 dígitos é pequeno). Nunca busca pelo token em claro.';
|
||||
COMMENT ON COLUMN signature_signers.status IS
|
||||
'PENDING -> LINK_SENT -> VIEWED -> DATA_CONFIRMED -> CONSENTED -> OTP_VERIFIED -> SIGNED (ou DECLINED a qualquer momento — reservado, sem rota que o produza ainda, igual ao legado). Toda escrita usa guarda AND status NOT IN (...) para nunca regredir um signatário já avançado.';
|
||||
|
||||
CREATE INDEX idx_signature_signers_envelope ON signature_signers(envelope_id);
|
||||
|
||||
-- =====================================================================
|
||||
-- signature_documents — documento congelado (cópia imutável)
|
||||
-- =====================================================================
|
||||
CREATE TABLE signature_documents (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
envelope_id UUID NOT NULL REFERENCES signature_envelopes(id) ON DELETE CASCADE,
|
||||
source_generation_id UUID REFERENCES document_generations(id),
|
||||
document_type TEXT,
|
||||
title TEXT NOT NULL,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
document_version INTEGER NOT NULL DEFAULT 1,
|
||||
source_filename TEXT,
|
||||
source_mime_type TEXT,
|
||||
source_s3_key TEXT NOT NULL,
|
||||
original_sha256 TEXT NOT NULL,
|
||||
page_count INTEGER,
|
||||
status TEXT NOT NULL DEFAULT 'FROZEN' CHECK (status IN ('FROZEN', 'SUPERSEDED')),
|
||||
superseded_by_id UUID REFERENCES signature_documents(id),
|
||||
frozen_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
COMMENT ON TABLE signature_documents IS
|
||||
'Cópia congelada e imutável de um documento dentro do envelope — source_s3_key é SEMPRE uma cópia própria (nunca o objeto S3 mutável de document_generations), original_sha256 calculado no momento do congelamento. source_generation_id é só referência histórica, nunca usado operacionalmente depois de congelado (eden.md §1.6, mesma filosofia do source_attachment_id do legado).';
|
||||
|
||||
CREATE INDEX idx_signature_documents_envelope ON signature_documents(envelope_id);
|
||||
|
||||
-- =====================================================================
|
||||
-- signature_otp_challenges — ver eden.md §6 para o algoritmo completo
|
||||
-- =====================================================================
|
||||
CREATE TABLE signature_otp_challenges (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
envelope_id UUID NOT NULL REFERENCES signature_envelopes(id) ON DELETE CASCADE,
|
||||
signer_id UUID NOT NULL REFERENCES signature_signers(id) ON DELETE CASCADE,
|
||||
channel TEXT NOT NULL DEFAULT 'EMAIL',
|
||||
email_snapshot TEXT,
|
||||
otp_protected_value TEXT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
expires_at TIMESTAMPTZ NOT NULL,
|
||||
sent_at TIMESTAMPTZ,
|
||||
verified_at TIMESTAMPTZ,
|
||||
invalidated_at TIMESTAMPTZ,
|
||||
attempts_count INTEGER NOT NULL DEFAULT 0,
|
||||
max_attempts INTEGER NOT NULL DEFAULT 5,
|
||||
request_ip INET,
|
||||
verification_ip INET,
|
||||
request_user_agent TEXT,
|
||||
verification_user_agent TEXT,
|
||||
status TEXT NOT NULL DEFAULT 'CREATED' CHECK (status IN (
|
||||
'CREATED', 'SENT', 'VERIFIED', 'EXPIRED', 'INVALIDATED', 'BLOCKED'
|
||||
))
|
||||
);
|
||||
COMMENT ON TABLE signature_otp_challenges IS
|
||||
'otp_protected_value é SEMPRE HMAC-SHA256(código, SIGNATURE_OTP_SECRET) — o código de 6 dígitos puro nunca é gravado, logado, ou devolvido em resposta de API. TTL de 5 minutos fixo (não configurável). Um novo OTP invalida (status=INVALIDATED) qualquer challenge CREATED/SENT anterior do mesmo signatário antes de criar outro — eden.md §6.5.';
|
||||
COMMENT ON COLUMN signature_otp_challenges.max_attempts IS
|
||||
'Default 5 — ao atingir attempts_count >= max_attempts, status vira BLOCKED e nenhuma tentativa adicional é aceita nesse challenge; o signatário precisa pedir um novo OTP.';
|
||||
|
||||
CREATE INDEX idx_signature_otp_challenges_signer ON signature_otp_challenges(signer_id, created_at DESC);
|
||||
|
||||
-- =====================================================================
|
||||
-- signature_audit_events — cadeia de auditoria hash-chained, append-only
|
||||
-- =====================================================================
|
||||
CREATE TABLE signature_audit_events (
|
||||
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||
seq BIGSERIAL NOT NULL UNIQUE,
|
||||
envelope_id UUID NOT NULL REFERENCES signature_envelopes(id) ON DELETE CASCADE,
|
||||
document_id UUID REFERENCES signature_documents(id),
|
||||
signer_id UUID REFERENCES signature_signers(id),
|
||||
event_type TEXT NOT NULL,
|
||||
actor_type TEXT NOT NULL CHECK (actor_type IN (
|
||||
'SIGNER', 'BACKOFFICE', 'ADMIN', 'SUPER_ADMIN', 'SYSTEM', 'SERVICE'
|
||||
)),
|
||||
actor_id UUID REFERENCES users(id),
|
||||
actor_role TEXT,
|
||||
timestamp_utc TIMESTAMPTZ NOT NULL,
|
||||
ip_address INET,
|
||||
user_agent TEXT,
|
||||
browser TEXT,
|
||||
operating_system TEXT,
|
||||
device_type TEXT,
|
||||
session_id TEXT,
|
||||
correlation_id TEXT,
|
||||
metadata_json JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
previous_event_hash TEXT,
|
||||
event_hash TEXT NOT NULL,
|
||||
hash_algorithm_version INTEGER NOT NULL DEFAULT 2
|
||||
);
|
||||
COMMENT ON TABLE signature_audit_events IS
|
||||
'Cadeia de auditoria hash-chained POR ENVELOPE (distinta e complementar ao audit_log genérico do sistema, que cobre toda ação crítica — as duas coexistem deliberadamente, eden.md §1.8). seq (bigserial), não timestamp_utc, define a ordem real: dentro de uma mesma transação o Postgres congela now(), então vários eventos gravados juntos podem ter timestamp idêntico. event_hash = SHA256(previous_event_hash + canonical_json({envelopeId,documentId,signerId,eventType,actorType,actorId,actorRole,timestampUtc,ipAddress,userAgent,metadata})) — note que browser/operatingSystem/deviceType/sessionId/correlationId ficam gravados na linha mas NÃO entram no hash. previous_event_hash encadeia com o evento anterior DO MESMO envelope, string vazia se for o primeiro.';
|
||||
COMMENT ON COLUMN signature_audit_events.hash_algorithm_version IS
|
||||
'Sempre 2 neste sistema (não existe v1 aqui — ver doc-comment de packages/database/src/hash-chain.ts). Mantido para o mesmo padrão defensivo do legado: se um dia a fórmula precisar mudar de novo, a verificação continua sabendo qual fórmula cada evento antigo usou.';
|
||||
|
||||
CREATE INDEX idx_signature_audit_events_envelope ON signature_audit_events(envelope_id, seq);
|
||||
|
||||
-- Append-only, reforçado em nível de banco — mesmo padrão e mesma GUC
|
||||
-- (eden.allow_audit_mutation) já usada por audit_log
|
||||
-- (1788430575858_baseline-schema.cjs), reaproveitada aqui de propósito:
|
||||
-- é a mesma classe de escotilha administrativa deliberada, não uma
|
||||
-- flag nova por tabela. Bloqueia inclusive super_admin via SQL bruto.
|
||||
CREATE OR REPLACE FUNCTION prevent_signature_audit_mutation() RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
IF current_setting('eden.allow_audit_mutation', true) IS DISTINCT FROM 'true' THEN
|
||||
RAISE EXCEPTION 'signature_audit_events é append-only — mutação bloqueada (ver docs/security/threat-model.md)';
|
||||
END IF;
|
||||
IF TG_OP = 'DELETE' THEN
|
||||
RETURN OLD;
|
||||
END IF;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
CREATE TRIGGER trg_signature_audit_events_no_update BEFORE UPDATE ON signature_audit_events
|
||||
FOR EACH ROW EXECUTE FUNCTION prevent_signature_audit_mutation();
|
||||
CREATE TRIGGER trg_signature_audit_events_no_delete BEFORE DELETE ON signature_audit_events
|
||||
FOR EACH ROW EXECUTE FUNCTION prevent_signature_audit_mutation();
|
||||
`);
|
||||
};
|
||||
|
||||
exports.down = (pgm) => {
|
||||
pgm.sql(`
|
||||
DROP TABLE IF EXISTS signature_audit_events;
|
||||
DROP FUNCTION IF EXISTS prevent_signature_audit_mutation();
|
||||
DROP TABLE IF EXISTS signature_otp_challenges;
|
||||
DROP TABLE IF EXISTS signature_documents;
|
||||
DROP TABLE IF EXISTS signature_signers;
|
||||
DROP TABLE IF EXISTS signature_envelopes;
|
||||
DROP SEQUENCE IF EXISTS signature_envelope_seq;
|
||||
`);
|
||||
};
|
||||
37
packages/database/src/hash-chain.ts
Normal file
37
packages/database/src/hash-chain.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
/**
|
||||
* Canonical JSON — recursive deep key-sort (objects at every depth,
|
||||
* arrays mapped element-wise), so a JSONB round-trip through Postgres
|
||||
* (which does NOT preserve original key order — it reorders by key
|
||||
* length then alphabetically) never changes the string a hash was
|
||||
* computed over. Shared by audit_log (repositories/audit.ts) and the
|
||||
* e-signature module's signature_audit_events (repositories/
|
||||
* signatures.ts) — both hash-chained tables, same algorithm.
|
||||
*
|
||||
* The legacy OrçaFácil this was ported from carries a v1/v2 version
|
||||
* split here: v1 only sorted top-level keys, which broke against
|
||||
* Postgres's jsonb key reordering for anything nested (e.g. inside a
|
||||
* `metadata` object) — a real bug discovered after already-hashed
|
||||
* production events existed. EDEN has no pre-existing hash-chain rows
|
||||
* from before this fix, so there is no v1 to port — every event this
|
||||
* system ever writes uses this (the legacy's "v2") formula from day
|
||||
* one. `hash_algorithm_version` is still recorded per-event (see the
|
||||
* signature_audit_events schema) purely so a future algorithm change
|
||||
* has the same version-aware escape hatch, not because v1 exists here.
|
||||
*/
|
||||
export function canonicalJson(value: unknown): string {
|
||||
if (value === undefined) return "null";
|
||||
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
||||
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
||||
const obj = value as Record<string, unknown>;
|
||||
const keys = Object.keys(obj).sort();
|
||||
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`).join(",")}}`;
|
||||
}
|
||||
|
||||
/** `event_hash = SHA256(previousEventHash + canonicalJson(fields))` — the one formula every hash-chained table in this codebase uses. */
|
||||
export function computeEventHash(previousEventHash: string, fields: Record<string, unknown>): string {
|
||||
return createHash("sha256")
|
||||
.update(previousEventHash + canonicalJson(fields))
|
||||
.digest("hex");
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
export { query, withTransaction, closePool } from "./pool.js";
|
||||
export { canonicalJson, computeEventHash } from "./hash-chain.js";
|
||||
export * as rolesRepo from "./repositories/roles.js";
|
||||
export * as usersRepo from "./repositories/users.js";
|
||||
export * as sessionsRepo from "./repositories/sessions.js";
|
||||
@@ -16,6 +17,7 @@ export * as hrTimeclockRepo from "./repositories/hr-timeclock.js";
|
||||
export * as fiscalRepo from "./repositories/fiscal.js";
|
||||
export * as billingRepo from "./repositories/billing.js";
|
||||
export * as documentsRepo from "./repositories/documents.js";
|
||||
export * as signaturesRepo from "./repositories/signatures.js";
|
||||
export {
|
||||
listLeads,
|
||||
getLead,
|
||||
@@ -178,3 +180,23 @@ export type {
|
||||
CreateGenerationInput,
|
||||
ListGenerationsFilter,
|
||||
} from "./repositories/documents.js";
|
||||
export type {
|
||||
EnvelopeStatus,
|
||||
SignerStatus,
|
||||
OtpStatus,
|
||||
ActorType,
|
||||
PartyRole,
|
||||
SignatureEnvelope,
|
||||
SignatureSigner,
|
||||
SignatureDocument,
|
||||
SignatureOtpChallenge,
|
||||
SignatureAuditEvent,
|
||||
EnvelopeWithDetails,
|
||||
CreateSignerInput,
|
||||
CreateDocumentInput,
|
||||
CreateEnvelopeInput,
|
||||
ListEnvelopesFilter,
|
||||
CreateOtpChallengeInput,
|
||||
RecordEventInput,
|
||||
VerifyChainResult,
|
||||
} from "./repositories/signatures.js";
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { query } from "../pool.js";
|
||||
import { computeEventHash } from "../hash-chain.js";
|
||||
|
||||
export interface AuditEventInput {
|
||||
actorType: "user" | "system" | "service";
|
||||
@@ -11,14 +11,6 @@ export interface AuditEventInput {
|
||||
ipAddress?: string | null;
|
||||
}
|
||||
|
||||
function canonicalJson(value: unknown): string {
|
||||
if (value === null || typeof value !== "object") return JSON.stringify(value);
|
||||
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
||||
const obj = value as Record<string, unknown>;
|
||||
const keys = Object.keys(obj).sort();
|
||||
return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`).join(",")}}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Appends one event to the audit_log hash chain (see baseline-schema
|
||||
* migration and .claude/skills/eden-security/references/audit.md). Never
|
||||
@@ -43,9 +35,7 @@ export async function appendAuditEvent(input: AuditEventInput): Promise<void> {
|
||||
metadata: input.metadata ?? {},
|
||||
ipAddress: input.ipAddress ?? null,
|
||||
};
|
||||
const eventHash = createHash("sha256")
|
||||
.update(previousEventHash + canonicalJson(fields))
|
||||
.digest("hex");
|
||||
const eventHash = computeEventHash(previousEventHash, fields);
|
||||
|
||||
await query(
|
||||
`INSERT INTO audit_log
|
||||
|
||||
912
packages/database/src/repositories/signatures.ts
Normal file
912
packages/database/src/repositories/signatures.ts
Normal file
@@ -0,0 +1,912 @@
|
||||
import type { PoolClient } from "pg";
|
||||
import { query, withTransaction } from "../pool.js";
|
||||
import { computeEventHash } from "../hash-chain.js";
|
||||
|
||||
/**
|
||||
* Assinatura Eletrônica — eden.md módulo 4 §§1.4-1.9, 5-10. See the
|
||||
* migration's header comment (1788536022387_signatures.cjs) for the
|
||||
* handful of deliberate EDEN-specific adaptations (document source,
|
||||
* reseller reference, hash_algorithm_version, server-side certificate).
|
||||
* Everything else here — the 19 envelope states, the exact hash-chain
|
||||
* formula, OTP timing/attempt limits — is algorithmically identical to
|
||||
* the legacy, verified against its actual source, not reconstructed from
|
||||
* memory. HMAC/crypto for OTP and secure tokens live in apps/api's
|
||||
* services (need @eden/config's env access, out of scope for this
|
||||
* package) — this file only stores/queries the already-computed opaque
|
||||
* values.
|
||||
*/
|
||||
|
||||
export const ENVELOPE_STATUSES = [
|
||||
"DRAFT",
|
||||
"READY",
|
||||
"SENT",
|
||||
"VIEWED",
|
||||
"IDENTITY_PENDING",
|
||||
"CONSENT_PENDING",
|
||||
"OTP_PENDING",
|
||||
"OTP_SENT",
|
||||
"OTP_VERIFIED",
|
||||
"READY_TO_SIGN",
|
||||
"SIGNING",
|
||||
"SIGNED",
|
||||
"FINALIZING",
|
||||
"COMPLETED",
|
||||
"DECLINED",
|
||||
"EXPIRED",
|
||||
"CANCELLED",
|
||||
"SUPERSEDED",
|
||||
"ERROR",
|
||||
] as const;
|
||||
export type EnvelopeStatus = (typeof ENVELOPE_STATUSES)[number];
|
||||
|
||||
export const SIGNER_STATUSES = [
|
||||
"PENDING",
|
||||
"LINK_SENT",
|
||||
"VIEWED",
|
||||
"DATA_CONFIRMED",
|
||||
"CONSENTED",
|
||||
"OTP_VERIFIED",
|
||||
"SIGNED",
|
||||
"DECLINED",
|
||||
] as const;
|
||||
export type SignerStatus = (typeof SIGNER_STATUSES)[number];
|
||||
|
||||
export type OtpStatus = "CREATED" | "SENT" | "VERIFIED" | "EXPIRED" | "INVALIDATED" | "BLOCKED";
|
||||
export type ActorType = "SIGNER" | "BACKOFFICE" | "ADMIN" | "SUPER_ADMIN" | "SYSTEM" | "SERVICE";
|
||||
export type PartyRole = "contratada" | "contratante";
|
||||
|
||||
/**
|
||||
* `envelopeService.js`'s `ALLOWED_TRANSITIONS`, ported verbatim (fork-
|
||||
* verified against source). SENT -> SIGNED is a deliberate direct jump:
|
||||
* per-signer granularity (VIEWED..OTP_VERIFIED) lives entirely on
|
||||
* signature_signers.status, never mirrored step-by-step on the envelope.
|
||||
*/
|
||||
export const ENVELOPE_TRANSITIONS: Record<EnvelopeStatus, EnvelopeStatus[]> = {
|
||||
DRAFT: ["READY", "CANCELLED"],
|
||||
READY: ["SENT", "CANCELLED"],
|
||||
SENT: ["VIEWED", "SIGNED", "EXPIRED", "CANCELLED"],
|
||||
VIEWED: ["IDENTITY_PENDING", "EXPIRED", "CANCELLED"],
|
||||
IDENTITY_PENDING: ["CONSENT_PENDING", "EXPIRED", "CANCELLED"],
|
||||
CONSENT_PENDING: ["OTP_PENDING", "EXPIRED", "CANCELLED"],
|
||||
OTP_PENDING: ["OTP_SENT", "EXPIRED", "CANCELLED"],
|
||||
OTP_SENT: ["OTP_VERIFIED", "OTP_PENDING", "EXPIRED", "CANCELLED"],
|
||||
OTP_VERIFIED: ["READY_TO_SIGN", "EXPIRED", "CANCELLED"],
|
||||
READY_TO_SIGN: ["SIGNING", "EXPIRED", "CANCELLED"],
|
||||
SIGNING: ["SIGNED", "ERROR"],
|
||||
SIGNED: ["FINALIZING"],
|
||||
FINALIZING: ["COMPLETED", "ERROR"],
|
||||
COMPLETED: [],
|
||||
DECLINED: [],
|
||||
EXPIRED: [],
|
||||
CANCELLED: [],
|
||||
SUPERSEDED: [],
|
||||
ERROR: [],
|
||||
};
|
||||
|
||||
export class InvalidEnvelopeTransitionError extends Error {
|
||||
constructor(
|
||||
public readonly fromStatus: EnvelopeStatus,
|
||||
public readonly toStatus: EnvelopeStatus,
|
||||
) {
|
||||
super(`Invalid signature envelope transition: '${fromStatus}' -> '${toStatus}'`);
|
||||
this.name = "InvalidEnvelopeTransitionError";
|
||||
}
|
||||
}
|
||||
|
||||
export function assertEnvelopeTransition(from: EnvelopeStatus, to: EnvelopeStatus): void {
|
||||
if (!(ENVELOPE_TRANSITIONS[from] ?? []).includes(to)) {
|
||||
throw new InvalidEnvelopeTransitionError(from, to);
|
||||
}
|
||||
}
|
||||
|
||||
export class EnvelopeNotFoundError extends Error {
|
||||
constructor(public readonly envelopeId: string) {
|
||||
super(`Signature envelope ${envelopeId} not found`);
|
||||
this.name = "EnvelopeNotFoundError";
|
||||
}
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Row types
|
||||
// ===========================================================================
|
||||
|
||||
export interface SignatureEnvelope {
|
||||
id: string;
|
||||
created_at: Date;
|
||||
updated_at: Date;
|
||||
created_by: string | null;
|
||||
envelope_number: string;
|
||||
company_id: string;
|
||||
client_registration_id: string | null;
|
||||
reseller_id: string | null;
|
||||
status: EnvelopeStatus;
|
||||
verification_id: string | null;
|
||||
final_s3_key: string | null;
|
||||
final_pdf_sha256: string | null;
|
||||
certificate_s3_key: string | null;
|
||||
evidence_checkpoint_hash: string | null;
|
||||
envelope_hash: string | null;
|
||||
sent_at: Date | null;
|
||||
viewed_at: Date | null;
|
||||
completed_at: Date | null;
|
||||
declined_at: Date | null;
|
||||
cancelled_at: Date | null;
|
||||
expired_at: Date | null;
|
||||
signed_at: Date | null;
|
||||
}
|
||||
|
||||
export interface SignatureSigner {
|
||||
id: string;
|
||||
created_at: Date;
|
||||
envelope_id: string;
|
||||
sort_order: number;
|
||||
party: PartyRole;
|
||||
person_type: "pf" | "pj" | null;
|
||||
full_name: string;
|
||||
cpf: string | null;
|
||||
email: string;
|
||||
phone: string | null;
|
||||
company_name: string | null;
|
||||
cnpj: string | null;
|
||||
role_title: string | null;
|
||||
status: SignerStatus;
|
||||
signed_at: Date | null;
|
||||
secure_token_hash: string | null;
|
||||
secure_token_created_at: Date | null;
|
||||
secure_token_expires_at: Date | null;
|
||||
secure_token_revoked_at: Date | null;
|
||||
secure_token_first_used_at: Date | null;
|
||||
secure_token_last_used_at: Date | null;
|
||||
}
|
||||
|
||||
export interface SignatureDocument {
|
||||
id: string;
|
||||
envelope_id: string;
|
||||
source_generation_id: string | null;
|
||||
document_type: string | null;
|
||||
title: string;
|
||||
sort_order: number;
|
||||
document_version: number;
|
||||
source_filename: string | null;
|
||||
source_mime_type: string | null;
|
||||
source_s3_key: string;
|
||||
original_sha256: string;
|
||||
page_count: number | null;
|
||||
status: "FROZEN" | "SUPERSEDED";
|
||||
superseded_by_id: string | null;
|
||||
frozen_at: Date;
|
||||
}
|
||||
|
||||
export interface SignatureOtpChallenge {
|
||||
id: string;
|
||||
envelope_id: string;
|
||||
signer_id: string;
|
||||
channel: string;
|
||||
email_snapshot: string | null;
|
||||
otp_protected_value: string;
|
||||
created_at: Date;
|
||||
expires_at: Date;
|
||||
sent_at: Date | null;
|
||||
verified_at: Date | null;
|
||||
invalidated_at: Date | null;
|
||||
attempts_count: number;
|
||||
max_attempts: number;
|
||||
request_ip: string | null;
|
||||
verification_ip: string | null;
|
||||
request_user_agent: string | null;
|
||||
verification_user_agent: string | null;
|
||||
status: OtpStatus;
|
||||
}
|
||||
|
||||
export interface SignatureAuditEvent {
|
||||
id: string;
|
||||
seq: string;
|
||||
envelope_id: string;
|
||||
document_id: string | null;
|
||||
signer_id: string | null;
|
||||
event_type: string;
|
||||
actor_type: ActorType;
|
||||
actor_id: string | null;
|
||||
actor_role: string | null;
|
||||
timestamp_utc: Date;
|
||||
ip_address: string | null;
|
||||
user_agent: string | null;
|
||||
browser: string | null;
|
||||
operating_system: string | null;
|
||||
device_type: string | null;
|
||||
session_id: string | null;
|
||||
correlation_id: string | null;
|
||||
metadata_json: Record<string, unknown>;
|
||||
previous_event_hash: string | null;
|
||||
event_hash: string;
|
||||
hash_algorithm_version: number;
|
||||
}
|
||||
|
||||
export interface EnvelopeWithDetails extends SignatureEnvelope {
|
||||
signers: SignatureSigner[];
|
||||
documents: SignatureDocument[];
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Envelope creation
|
||||
// ===========================================================================
|
||||
|
||||
function formatEnvelopeNumber(seq: string | number): string {
|
||||
const year = new Date().getFullYear();
|
||||
return `SIG-${year}-${String(seq).padStart(6, "0")}`;
|
||||
}
|
||||
|
||||
export interface CreateSignerInput {
|
||||
party: PartyRole;
|
||||
personType?: "pf" | "pj" | null;
|
||||
fullName: string;
|
||||
cpf?: string | null;
|
||||
email: string;
|
||||
phone?: string | null;
|
||||
companyName?: string | null;
|
||||
cnpj?: string | null;
|
||||
roleTitle?: string | null;
|
||||
}
|
||||
|
||||
export interface CreateDocumentInput {
|
||||
sourceGenerationId?: string | null;
|
||||
documentType?: string | null;
|
||||
title: string;
|
||||
sourceFilename?: string | null;
|
||||
sourceMimeType?: string | null;
|
||||
sourceS3Key: string;
|
||||
originalSha256: string;
|
||||
pageCount?: number | null;
|
||||
}
|
||||
|
||||
export interface CreateEnvelopeInput {
|
||||
companyId: string;
|
||||
clientRegistrationId?: string | null;
|
||||
resellerId?: string | null;
|
||||
createdBy?: string | null;
|
||||
signers: CreateSignerInput[];
|
||||
documents: CreateDocumentInput[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Single transaction: allocates envelope_number, inserts the envelope
|
||||
* (status DRAFT), its documents (already frozen — S3 copy + SHA-256 done
|
||||
* by the caller before this, same as the legacy's freezeAttachment
|
||||
* running before the INSERT), its signers, transitions DRAFT->READY, and
|
||||
* records ENVELOPE_CREATED + one DOCUMENT_FROZEN per document + one
|
||||
* ENVELOPE_READY audit event — all inside one transaction (eden.md §5.4:
|
||||
* "todo o processo é uma única transação... se qualquer etapa falhar,
|
||||
* nada fica gravado").
|
||||
*/
|
||||
export async function createEnvelope(
|
||||
input: CreateEnvelopeInput,
|
||||
actor: { actorType: ActorType; actorId: string | null; actorRole: string | null; ipAddress: string | null; userAgent: string | null },
|
||||
): Promise<EnvelopeWithDetails> {
|
||||
return withTransaction(async (client) => {
|
||||
const { rows: seqRows } = await client.query<{ n: string }>("SELECT nextval('signature_envelope_seq') AS n");
|
||||
const seq = seqRows[0]?.n;
|
||||
if (seq === undefined) throw new Error("failed to allocate envelope_number");
|
||||
const envelopeNumber = formatEnvelopeNumber(seq);
|
||||
|
||||
const { rows } = await client.query<SignatureEnvelope>(
|
||||
`INSERT INTO signature_envelopes (envelope_number, company_id, client_registration_id, reseller_id, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5)
|
||||
RETURNING *`,
|
||||
[
|
||||
envelopeNumber,
|
||||
input.companyId,
|
||||
input.clientRegistrationId ?? null,
|
||||
input.resellerId ?? null,
|
||||
input.createdBy ?? null,
|
||||
],
|
||||
);
|
||||
const envelope = rows[0];
|
||||
if (!envelope) throw new Error("failed to create signature envelope");
|
||||
|
||||
let previousEventHash = "";
|
||||
previousEventHash = await recordEventInternal(client, previousEventHash, {
|
||||
envelopeId: envelope.id,
|
||||
documentId: null,
|
||||
signerId: null,
|
||||
eventType: "ENVELOPE_CREATED",
|
||||
...actor,
|
||||
metadata: { envelopeNumber },
|
||||
});
|
||||
|
||||
const documents: SignatureDocument[] = [];
|
||||
for (const [index, doc] of input.documents.entries()) {
|
||||
const { rows: docRows } = await client.query<SignatureDocument>(
|
||||
`INSERT INTO signature_documents
|
||||
(envelope_id, source_generation_id, document_type, title, sort_order, source_filename, source_mime_type, source_s3_key, original_sha256, page_count)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
|
||||
RETURNING *`,
|
||||
[
|
||||
envelope.id,
|
||||
doc.sourceGenerationId ?? null,
|
||||
doc.documentType ?? null,
|
||||
doc.title,
|
||||
index,
|
||||
doc.sourceFilename ?? null,
|
||||
doc.sourceMimeType ?? null,
|
||||
doc.sourceS3Key,
|
||||
doc.originalSha256,
|
||||
doc.pageCount ?? null,
|
||||
],
|
||||
);
|
||||
const row = docRows[0];
|
||||
if (!row) throw new Error("failed to freeze signature document");
|
||||
documents.push(row);
|
||||
previousEventHash = await recordEventInternal(client, previousEventHash, {
|
||||
envelopeId: envelope.id,
|
||||
documentId: row.id,
|
||||
signerId: null,
|
||||
eventType: "DOCUMENT_FROZEN",
|
||||
...actor,
|
||||
metadata: { title: row.title, sha256: row.original_sha256 },
|
||||
});
|
||||
previousEventHash = await recordEventInternal(client, previousEventHash, {
|
||||
envelopeId: envelope.id,
|
||||
documentId: row.id,
|
||||
signerId: null,
|
||||
eventType: "DOCUMENT_HASHED",
|
||||
...actor,
|
||||
metadata: { sha256: row.original_sha256 },
|
||||
});
|
||||
}
|
||||
|
||||
const signers: SignatureSigner[] = [];
|
||||
for (const [index, signer] of input.signers.entries()) {
|
||||
const { rows: signerRows } = await client.query<SignatureSigner>(
|
||||
`INSERT INTO signature_signers
|
||||
(envelope_id, sort_order, party, person_type, full_name, cpf, email, phone, company_name, cnpj, role_title)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11)
|
||||
RETURNING *`,
|
||||
[
|
||||
envelope.id,
|
||||
index,
|
||||
signer.party,
|
||||
signer.personType ?? null,
|
||||
signer.fullName,
|
||||
signer.cpf ?? null,
|
||||
signer.email,
|
||||
signer.phone ?? null,
|
||||
signer.companyName ?? null,
|
||||
signer.cnpj ?? null,
|
||||
signer.roleTitle ?? null,
|
||||
],
|
||||
);
|
||||
const row = signerRows[0];
|
||||
if (!row) throw new Error("failed to create signature signer");
|
||||
signers.push(row);
|
||||
}
|
||||
|
||||
assertEnvelopeTransition("DRAFT", "READY");
|
||||
const { rows: updatedRows } = await client.query<SignatureEnvelope>(
|
||||
"UPDATE signature_envelopes SET status = 'READY' WHERE id = $1 RETURNING *",
|
||||
[envelope.id],
|
||||
);
|
||||
const updated = updatedRows[0];
|
||||
if (!updated) throw new Error(`envelope ${envelope.id} disappeared mid-transaction`);
|
||||
|
||||
await recordEventInternal(client, previousEventHash, {
|
||||
envelopeId: envelope.id,
|
||||
documentId: null,
|
||||
signerId: null,
|
||||
eventType: "ENVELOPE_READY",
|
||||
...actor,
|
||||
metadata: {},
|
||||
});
|
||||
|
||||
return { ...updated, signers, documents };
|
||||
});
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Queries
|
||||
// ===========================================================================
|
||||
|
||||
export interface ListEnvelopesFilter {
|
||||
scope: "all" | "reseller";
|
||||
resellerId?: string | null;
|
||||
}
|
||||
|
||||
export async function listEnvelopes(filter: ListEnvelopesFilter): Promise<SignatureEnvelope[]> {
|
||||
if (filter.scope === "reseller") {
|
||||
const { rows } = await query<SignatureEnvelope>(
|
||||
"SELECT * FROM signature_envelopes WHERE reseller_id = $1 ORDER BY created_at DESC",
|
||||
[filter.resellerId ?? null],
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
const { rows } = await query<SignatureEnvelope>("SELECT * FROM signature_envelopes ORDER BY created_at DESC");
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function listSigners(envelopeId: string): Promise<SignatureSigner[]> {
|
||||
const { rows } = await query<SignatureSigner>(
|
||||
"SELECT * FROM signature_signers WHERE envelope_id = $1 ORDER BY sort_order ASC",
|
||||
[envelopeId],
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
async function listDocuments(envelopeId: string): Promise<SignatureDocument[]> {
|
||||
const { rows } = await query<SignatureDocument>(
|
||||
"SELECT * FROM signature_documents WHERE envelope_id = $1 ORDER BY sort_order ASC",
|
||||
[envelopeId],
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export async function getEnvelopeById(id: string): Promise<EnvelopeWithDetails | null> {
|
||||
const { rows } = await query<SignatureEnvelope>("SELECT * FROM signature_envelopes WHERE id = $1", [id]);
|
||||
const envelope = rows[0];
|
||||
if (!envelope) return null;
|
||||
const [signers, documents] = await Promise.all([listSigners(id), listDocuments(id)]);
|
||||
return { ...envelope, signers, documents };
|
||||
}
|
||||
|
||||
export async function getEnvelopeByVerificationId(verificationId: string): Promise<EnvelopeWithDetails | null> {
|
||||
const { rows } = await query<SignatureEnvelope>(
|
||||
"SELECT * FROM signature_envelopes WHERE verification_id = $1",
|
||||
[verificationId],
|
||||
);
|
||||
const envelope = rows[0];
|
||||
if (!envelope) return null;
|
||||
const [signers, documents] = await Promise.all([listSigners(envelope.id), listDocuments(envelope.id)]);
|
||||
return { ...envelope, signers, documents };
|
||||
}
|
||||
|
||||
export async function getSignerById(id: string): Promise<SignatureSigner | null> {
|
||||
const { rows } = await query<SignatureSigner>("SELECT * FROM signature_signers WHERE id = $1", [id]);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
/** Never look up by plaintext token — only by its SHA-256, computed by the caller (apps/api/src/signatures/token.ts). */
|
||||
export async function getSignerByTokenHash(tokenHash: string): Promise<SignatureSigner | null> {
|
||||
const { rows } = await query<SignatureSigner>("SELECT * FROM signature_signers WHERE secure_token_hash = $1", [
|
||||
tokenHash,
|
||||
]);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function getLatestOtpChallengeForSigner(signerId: string): Promise<SignatureOtpChallenge | null> {
|
||||
const { rows } = await query<SignatureOtpChallenge>(
|
||||
"SELECT * FROM signature_otp_challenges WHERE signer_id = $1 ORDER BY created_at DESC LIMIT 1",
|
||||
[signerId],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Envelope status transitions
|
||||
// ===========================================================================
|
||||
|
||||
const MILESTONE_COLUMN: Partial<Record<EnvelopeStatus, string>> = {
|
||||
SENT: "sent_at",
|
||||
VIEWED: "viewed_at",
|
||||
COMPLETED: "completed_at",
|
||||
DECLINED: "declined_at",
|
||||
CANCELLED: "cancelled_at",
|
||||
EXPIRED: "expired_at",
|
||||
SIGNED: "signed_at",
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates against ENVELOPE_TRANSITIONS and, if valid, updates status —
|
||||
* setting the matching milestone timestamp via COALESCE the first time
|
||||
* the envelope enters that status (never overwritten afterwards, same
|
||||
* semantics as Contracts' activated_at). Accepts an optional `client` to
|
||||
* participate in a caller's own transaction (e.g. sealEvidenceCheckpoint
|
||||
* writes several columns + this status change atomically).
|
||||
*/
|
||||
export async function updateEnvelopeStatus(
|
||||
envelopeId: string,
|
||||
toStatus: EnvelopeStatus,
|
||||
clientOverride?: PoolClient,
|
||||
): Promise<SignatureEnvelope> {
|
||||
const run = async (client: PoolClient): Promise<SignatureEnvelope> => {
|
||||
const { rows } = await client.query<{ status: EnvelopeStatus }>(
|
||||
"SELECT status FROM signature_envelopes WHERE id = $1 FOR UPDATE",
|
||||
[envelopeId],
|
||||
);
|
||||
const current = rows[0];
|
||||
if (!current) throw new EnvelopeNotFoundError(envelopeId);
|
||||
assertEnvelopeTransition(current.status, toStatus);
|
||||
|
||||
const milestoneColumn = MILESTONE_COLUMN[toStatus];
|
||||
const sql = milestoneColumn
|
||||
? `UPDATE signature_envelopes SET status = $2, ${milestoneColumn} = COALESCE(${milestoneColumn}, now()) WHERE id = $1 RETURNING *`
|
||||
: "UPDATE signature_envelopes SET status = $2 WHERE id = $1 RETURNING *";
|
||||
const { rows: updatedRows } = await client.query<SignatureEnvelope>(sql, [envelopeId, toStatus]);
|
||||
const updated = updatedRows[0];
|
||||
if (!updated) throw new Error(`envelope ${envelopeId} disappeared mid-transaction`);
|
||||
return updated;
|
||||
};
|
||||
|
||||
if (clientOverride) return run(clientOverride);
|
||||
return withTransaction(run);
|
||||
}
|
||||
|
||||
/** Guarded so a replayed/out-of-order request can never regress a signer's progress backward — same discipline as the legacy (eden.md §10.2). `fromStatuses` lists the only statuses this write is allowed to apply from. */
|
||||
export async function updateSignerStatus(
|
||||
signerId: string,
|
||||
toStatus: SignerStatus,
|
||||
fromStatuses: SignerStatus[],
|
||||
clientOverride?: PoolClient,
|
||||
): Promise<SignatureSigner | null> {
|
||||
const run = async (client: PoolClient): Promise<SignatureSigner | null> => {
|
||||
const setSignedAt = toStatus === "SIGNED" ? ", signed_at = now()" : "";
|
||||
const { rows } = await client.query<SignatureSigner>(
|
||||
`UPDATE signature_signers SET status = $2 ${setSignedAt}
|
||||
WHERE id = $1 AND status = ANY($3::text[])
|
||||
RETURNING *`,
|
||||
[signerId, toStatus, fromStatuses],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
};
|
||||
if (clientOverride) return run(clientOverride);
|
||||
return withTransaction(run);
|
||||
}
|
||||
|
||||
export interface IssueSignerTokenResult {
|
||||
signer: SignatureSigner;
|
||||
plaintextToken: string;
|
||||
}
|
||||
|
||||
/** Caller computes the token/hash (crypto lives in apps/api) — this just persists it, revoking any previous token implicitly (only the newest hash is ever queryable). */
|
||||
export async function setSignerToken(
|
||||
signerId: string,
|
||||
tokenHash: string,
|
||||
expiresAt: Date,
|
||||
): Promise<SignatureSigner | null> {
|
||||
const { rows } = await query<SignatureSigner>(
|
||||
`UPDATE signature_signers SET
|
||||
secure_token_hash = $2,
|
||||
secure_token_created_at = now(),
|
||||
secure_token_expires_at = $3,
|
||||
secure_token_revoked_at = NULL
|
||||
WHERE id = $1
|
||||
RETURNING *`,
|
||||
[signerId, tokenHash, expiresAt],
|
||||
);
|
||||
return rows[0] ?? null;
|
||||
}
|
||||
|
||||
export async function touchSignerTokenUsage(signerId: string): Promise<void> {
|
||||
await query(
|
||||
`UPDATE signature_signers SET
|
||||
secure_token_first_used_at = COALESCE(secure_token_first_used_at, now()),
|
||||
secure_token_last_used_at = now()
|
||||
WHERE id = $1`,
|
||||
[signerId],
|
||||
);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// OTP challenges
|
||||
// ===========================================================================
|
||||
|
||||
export interface CreateOtpChallengeInput {
|
||||
envelopeId: string;
|
||||
signerId: string;
|
||||
emailSnapshot: string;
|
||||
otpProtectedValue: string;
|
||||
expiresAt: Date;
|
||||
requestIp?: string | null;
|
||||
requestUserAgent?: string | null;
|
||||
}
|
||||
|
||||
/** Invalidates any prior CREATED/SENT challenge for this signer first (eden.md §6.5: "novo OTP invalida o antigo" is explicit product behavior, not incidental). */
|
||||
export async function createOtpChallenge(input: CreateOtpChallengeInput): Promise<SignatureOtpChallenge> {
|
||||
return withTransaction(async (client) => {
|
||||
await client.query(
|
||||
`UPDATE signature_otp_challenges SET status = 'INVALIDATED', invalidated_at = now()
|
||||
WHERE signer_id = $1 AND status IN ('CREATED', 'SENT')`,
|
||||
[input.signerId],
|
||||
);
|
||||
const { rows } = await client.query<SignatureOtpChallenge>(
|
||||
`INSERT INTO signature_otp_challenges
|
||||
(envelope_id, signer_id, email_snapshot, otp_protected_value, expires_at, request_ip, request_user_agent)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7)
|
||||
RETURNING *`,
|
||||
[
|
||||
input.envelopeId,
|
||||
input.signerId,
|
||||
input.emailSnapshot,
|
||||
input.otpProtectedValue,
|
||||
input.expiresAt,
|
||||
input.requestIp ?? null,
|
||||
input.requestUserAgent ?? null,
|
||||
],
|
||||
);
|
||||
const row = rows[0];
|
||||
if (!row) throw new Error("failed to create OTP challenge");
|
||||
return row;
|
||||
});
|
||||
}
|
||||
|
||||
export async function markOtpSent(id: string): Promise<void> {
|
||||
await query("UPDATE signature_otp_challenges SET status = 'SENT', sent_at = now() WHERE id = $1", [id]);
|
||||
}
|
||||
|
||||
export async function markOtpVerified(id: string, verificationIp: string | null, verificationUserAgent: string | null): Promise<void> {
|
||||
await query(
|
||||
`UPDATE signature_otp_challenges SET
|
||||
status = 'VERIFIED', verified_at = now(), verification_ip = $2, verification_user_agent = $3
|
||||
WHERE id = $1`,
|
||||
[id, verificationIp, verificationUserAgent],
|
||||
);
|
||||
}
|
||||
|
||||
export async function markOtpExpired(id: string): Promise<void> {
|
||||
await query("UPDATE signature_otp_challenges SET status = 'EXPIRED' WHERE id = $1", [id]);
|
||||
}
|
||||
|
||||
/** Returns the new attempts_count and whether this attempt blocked the challenge. */
|
||||
export async function recordOtpFailedAttempt(id: string, maxAttempts: number): Promise<{ attempts: number; blocked: boolean }> {
|
||||
const { rows } = await query<{ attempts_count: number }>(
|
||||
`UPDATE signature_otp_challenges SET
|
||||
attempts_count = attempts_count + 1,
|
||||
status = CASE WHEN attempts_count + 1 >= $2 THEN 'BLOCKED' ELSE status END
|
||||
WHERE id = $1
|
||||
RETURNING attempts_count`,
|
||||
[id, maxAttempts],
|
||||
);
|
||||
const attempts = rows[0]?.attempts_count ?? maxAttempts;
|
||||
return { attempts, blocked: attempts >= maxAttempts };
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Hash-chain audit events
|
||||
// ===========================================================================
|
||||
|
||||
export interface RecordEventInput {
|
||||
envelopeId: string;
|
||||
documentId?: string | null;
|
||||
signerId?: string | null;
|
||||
eventType: string;
|
||||
actorType: ActorType;
|
||||
actorId?: string | null;
|
||||
actorRole?: string | null;
|
||||
ipAddress?: string | null;
|
||||
userAgent?: string | null;
|
||||
browser?: string | null;
|
||||
operatingSystem?: string | null;
|
||||
deviceType?: string | null;
|
||||
sessionId?: string | null;
|
||||
metadata?: Record<string, unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* The exact set of fields that enter the hash (`eventFields` in the
|
||||
* legacy) — deliberately NOT the full row: browser/operatingSystem/
|
||||
* deviceType/sessionId/correlationId are stored but excluded, fork-
|
||||
* verified against auditChain.js.
|
||||
*/
|
||||
function hashedFields(input: RecordEventInput, timestampUtc: string) {
|
||||
return {
|
||||
envelopeId: input.envelopeId,
|
||||
documentId: input.documentId ?? null,
|
||||
signerId: input.signerId ?? null,
|
||||
eventType: input.eventType,
|
||||
actorType: input.actorType,
|
||||
actorId: input.actorId ?? null,
|
||||
actorRole: input.actorRole ?? null,
|
||||
timestampUtc,
|
||||
ipAddress: input.ipAddress ?? null,
|
||||
userAgent: input.userAgent ?? null,
|
||||
metadata: input.metadata ?? {},
|
||||
};
|
||||
}
|
||||
|
||||
async function recordEventInternal(client: PoolClient, previousEventHash: string, input: RecordEventInput): Promise<string> {
|
||||
const timestampUtc = new Date().toISOString();
|
||||
const eventHash = computeEventHash(previousEventHash, hashedFields(input, timestampUtc));
|
||||
|
||||
await client.query(
|
||||
`INSERT INTO signature_audit_events
|
||||
(envelope_id, document_id, signer_id, event_type, actor_type, actor_id, actor_role,
|
||||
timestamp_utc, ip_address, user_agent, browser, operating_system, device_type, session_id,
|
||||
metadata_json, previous_event_hash, event_hash, hash_algorithm_version)
|
||||
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,2)`,
|
||||
[
|
||||
input.envelopeId,
|
||||
input.documentId ?? null,
|
||||
input.signerId ?? null,
|
||||
input.eventType,
|
||||
input.actorType,
|
||||
input.actorId ?? null,
|
||||
input.actorRole ?? null,
|
||||
timestampUtc,
|
||||
input.ipAddress ?? null,
|
||||
input.userAgent ?? null,
|
||||
input.browser ?? null,
|
||||
input.operatingSystem ?? null,
|
||||
input.deviceType ?? null,
|
||||
input.sessionId ?? null,
|
||||
JSON.stringify(input.metadata ?? {}),
|
||||
previousEventHash || null,
|
||||
eventHash,
|
||||
],
|
||||
);
|
||||
return eventHash;
|
||||
}
|
||||
|
||||
async function tailEventHashInternal(client: PoolClient, envelopeId: string): Promise<string> {
|
||||
const { rows } = await client.query<{ event_hash: string }>(
|
||||
"SELECT event_hash FROM signature_audit_events WHERE envelope_id = $1 ORDER BY seq DESC LIMIT 1",
|
||||
[envelopeId],
|
||||
);
|
||||
return rows[0]?.event_hash ?? "";
|
||||
}
|
||||
|
||||
/** Public read of the chain tail — used by sealEvidenceCheckpoint's caller, which needs this value BEFORE computing envelope_hash (envelope_hash's own canonical payload includes evidence_checkpoint_hash, so the two can't be derived independently — see finalize.service.ts). */
|
||||
export async function peekChainTail(envelopeId: string, clientOverride?: PoolClient): Promise<string> {
|
||||
if (clientOverride) return tailEventHashInternal(clientOverride, envelopeId);
|
||||
return withTransaction((client) => tailEventHashInternal(client, envelopeId));
|
||||
}
|
||||
|
||||
/** Reads the chain tail for this envelope and appends one event — the single-event equivalent of what createEnvelope's transaction does inline for several events at once. Accepts an optional client to participate in a caller's transaction. */
|
||||
export async function recordEvent(input: RecordEventInput, clientOverride?: PoolClient): Promise<string> {
|
||||
const run = async (client: PoolClient): Promise<string> => {
|
||||
const previousEventHash = await tailEventHashInternal(client, input.envelopeId);
|
||||
return recordEventInternal(client, previousEventHash, input);
|
||||
};
|
||||
if (clientOverride) return run(clientOverride);
|
||||
return withTransaction(run);
|
||||
}
|
||||
|
||||
export async function listAuditEvents(envelopeId: string): Promise<SignatureAuditEvent[]> {
|
||||
const { rows } = await query<SignatureAuditEvent>(
|
||||
"SELECT * FROM signature_audit_events WHERE envelope_id = $1 ORDER BY seq ASC",
|
||||
[envelopeId],
|
||||
);
|
||||
return rows;
|
||||
}
|
||||
|
||||
export interface VerifyChainResult {
|
||||
valid: boolean;
|
||||
eventsChecked: number;
|
||||
failedAt?: string;
|
||||
reason?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Two independent checks per event (eden.md §7.5): linkage (previous_
|
||||
* event_hash matches the prior row's event_hash — proves nothing was
|
||||
* inserted/removed/reordered, always a hard failure if it doesn't) and
|
||||
* content (recomputed event_hash matches, using that row's own
|
||||
* hash_algorithm_version). Since EDEN never wrote a v1 event, content
|
||||
* verification only ever needs the v2 formula — see hash-chain.ts.
|
||||
*/
|
||||
export async function verifyChain(envelopeId: string): Promise<VerifyChainResult> {
|
||||
const events = await listAuditEvents(envelopeId);
|
||||
let expectedPrevious = "";
|
||||
for (const event of events) {
|
||||
if ((event.previous_event_hash ?? "") !== expectedPrevious) {
|
||||
return {
|
||||
valid: false,
|
||||
eventsChecked: events.indexOf(event),
|
||||
failedAt: event.id,
|
||||
reason: "previous_event_hash não confere com o evento anterior — indica adulteração",
|
||||
};
|
||||
}
|
||||
const recomputed = computeEventHash(expectedPrevious, hashedFields(eventToInput(event), event.timestamp_utc.toISOString()));
|
||||
if (recomputed !== event.event_hash) {
|
||||
return {
|
||||
valid: false,
|
||||
eventsChecked: events.indexOf(event),
|
||||
failedAt: event.id,
|
||||
reason: "event_hash não confere com os dados gravados — indica adulteração",
|
||||
};
|
||||
}
|
||||
expectedPrevious = event.event_hash;
|
||||
}
|
||||
return { valid: true, eventsChecked: events.length };
|
||||
}
|
||||
|
||||
function eventToInput(event: SignatureAuditEvent): RecordEventInput {
|
||||
return {
|
||||
envelopeId: event.envelope_id,
|
||||
documentId: event.document_id,
|
||||
signerId: event.signer_id,
|
||||
eventType: event.event_type,
|
||||
actorType: event.actor_type,
|
||||
actorId: event.actor_id,
|
||||
actorRole: event.actor_role,
|
||||
ipAddress: event.ip_address,
|
||||
userAgent: event.user_agent,
|
||||
metadata: event.metadata_json,
|
||||
};
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Finalization
|
||||
// ===========================================================================
|
||||
|
||||
/**
|
||||
* Writes verification_id/evidence_checkpoint_hash/envelope_hash and
|
||||
* transitions SIGNED->FINALIZING atomically. Both hash values are
|
||||
* computed by the CALLER (apps/api's finalize.service.ts) before this
|
||||
* call: `evidenceCheckpointHash` from `peekChainTail`, `envelopeHash`
|
||||
* from the exact formula in eden.md §8.4 — which itself embeds
|
||||
* `evidenceCheckpointHash`, so the two cannot be derived independently
|
||||
* inside this function without duplicating that ordering logic here.
|
||||
*/
|
||||
export async function sealEvidenceCheckpoint(
|
||||
envelopeId: string,
|
||||
verificationId: string,
|
||||
evidenceCheckpointHash: string,
|
||||
envelopeHash: string,
|
||||
): Promise<SignatureEnvelope> {
|
||||
return withTransaction(async (client) => {
|
||||
const { rows: statusRows } = await client.query<{ status: EnvelopeStatus }>(
|
||||
"SELECT status FROM signature_envelopes WHERE id = $1 FOR UPDATE",
|
||||
[envelopeId],
|
||||
);
|
||||
const current = statusRows[0];
|
||||
if (!current) throw new EnvelopeNotFoundError(envelopeId);
|
||||
assertEnvelopeTransition(current.status, "FINALIZING");
|
||||
|
||||
const { rows } = await client.query<SignatureEnvelope>(
|
||||
`UPDATE signature_envelopes SET
|
||||
status = 'FINALIZING', verification_id = $2, evidence_checkpoint_hash = $3, envelope_hash = $4
|
||||
WHERE id = $1
|
||||
RETURNING *`,
|
||||
[envelopeId, verificationId, evidenceCheckpointHash, envelopeHash],
|
||||
);
|
||||
const envelope = rows[0];
|
||||
if (!envelope) throw new Error(`envelope ${envelopeId} disappeared mid-transaction`);
|
||||
return envelope;
|
||||
});
|
||||
}
|
||||
|
||||
export async function setFinalizationFiles(
|
||||
envelopeId: string,
|
||||
fields: { certificateS3Key?: string; finalS3Key?: string; finalPdfSha256?: string },
|
||||
): Promise<SignatureEnvelope> {
|
||||
const { rows } = await query<SignatureEnvelope>(
|
||||
`UPDATE signature_envelopes SET
|
||||
certificate_s3_key = COALESCE($2, certificate_s3_key),
|
||||
final_s3_key = COALESCE($3, final_s3_key),
|
||||
final_pdf_sha256 = COALESCE($4, final_pdf_sha256)
|
||||
WHERE id = $1
|
||||
RETURNING *`,
|
||||
[envelopeId, fields.certificateS3Key ?? null, fields.finalS3Key ?? null, fields.finalPdfSha256 ?? null],
|
||||
);
|
||||
const envelope = rows[0];
|
||||
if (!envelope) throw new EnvelopeNotFoundError(envelopeId);
|
||||
return envelope;
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Cancel / delete
|
||||
// ===========================================================================
|
||||
|
||||
export async function cancelEnvelope(envelopeId: string, reason: string | null): Promise<SignatureEnvelope> {
|
||||
return updateEnvelopeStatus(envelopeId, "CANCELLED").then(async (envelope) => {
|
||||
await recordEvent({
|
||||
envelopeId,
|
||||
eventType: "ENVELOPE_CANCELLED",
|
||||
actorType: "BACKOFFICE",
|
||||
metadata: { reason },
|
||||
});
|
||||
return envelope;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Physical delete — only reachable pre-SIGNED (enforced by the caller,
|
||||
* apps/api's service, checking envelope.status before calling this).
|
||||
* Uses the same `eden.allow_audit_mutation` escape hatch as audit_log,
|
||||
* deliberately: this is the one legitimate case where the audit trail
|
||||
* itself must be removable, because the whole envelope it belongs to
|
||||
* never produced a completed legal artifact.
|
||||
*/
|
||||
export async function deleteEnvelope(envelopeId: string): Promise<void> {
|
||||
await withTransaction(async (client) => {
|
||||
await client.query("SET LOCAL eden.allow_audit_mutation = 'true'");
|
||||
await client.query("DELETE FROM signature_envelopes WHERE id = $1", [envelopeId]);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user