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:
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