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,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]);
});
}