Files
admin b15ed4bff8 Avria Sign: add SHA-256 document hash to certificate of completion
The Certificate of Validation claimed "cryptographic proof ... and integrity"
but showed no hash. Add a real SHA-256 of the original document to the
certificate page so the integrity claim is backed by a verifiable digest.
(Deploy via MCP alongside the base-flow fix.)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 22:58:27 -04:00

275 lines
12 KiB
TypeScript

// Avria Sign — stamp signed PDF (using placed fields when available) and append validation certificate
import { createClient } from "https://esm.sh/@supabase/supabase-js@2.45.0";
import { PDFDocument, StandardFonts, rgb } from "https://esm.sh/pdf-lib@1.17.1";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers": "authorization, x-client-info, apikey, content-type",
};
function toSmtpSenderConfig(sender: any) {
const port = Number(sender?.smtp_port ?? 587);
const isImplicitSslPort = port === 465;
const isStartTlsPort = port === 587;
return {
host: sender.smtp_host,
port,
username: sender.smtp_username,
password: sender.smtp_password,
use_ssl: isImplicitSslPort ? true : sender.use_ssl ?? false,
use_tls: isImplicitSslPort ? false : sender.use_tls ?? isStartTlsPort,
from: sender.sender_name
? `${sender.sender_name} <${sender.email_address}>`
: sender.email_address,
fromEmail: sender.email_address,
fromName: sender.sender_name,
envelopeFrom: sender.smtp_username || sender.email_address,
signature_html: sender.signature_html || "",
};
}
async function sha256Hex(bytes: Uint8Array): Promise<string> {
const digest = await crypto.subtle.digest("SHA-256", bytes);
return Array.from(new Uint8Array(digest)).map((b) => b.toString(16).padStart(2, "0")).join("");
}
Deno.serve(async (req) => {
if (req.method === "OPTIONS") return new Response(null, { headers: corsHeaders });
const admin = createClient(Deno.env.get("SUPABASE_URL")!, Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!);
try {
const { envelope_id } = await req.json();
if (!envelope_id) return json({ error: "missing envelope_id" }, 400);
const { data: env } = await admin
.from("signature_envelopes").select("*").eq("id", envelope_id).maybeSingle();
if (!env) return json({ error: "envelope not found" }, 404);
const { data: recipients } = await admin
.from("signature_recipients").select("*").eq("envelope_id", envelope_id).order("signing_order");
const { data: allFields } = await admin
.from("signature_fields").select("*").eq("envelope_id", envelope_id);
const pdfRes = await fetch(env.document_url);
if (!pdfRes.ok) throw new Error("Could not fetch original document");
const pdfBytes = new Uint8Array(await pdfRes.arrayBuffer());
const docHash = await sha256Hex(pdfBytes);
let pdfDoc: PDFDocument;
try {
pdfDoc = await PDFDocument.load(pdfBytes, { ignoreEncryption: true });
} catch {
pdfDoc = await PDFDocument.create();
const p = pdfDoc.addPage();
p.drawText("Original document could not be embedded.", { x: 50, y: 700, size: 12 });
}
const helv = await pdfDoc.embedFont(StandardFonts.Helvetica);
const helvBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
const pages = pdfDoc.getPages();
// Cache embedded signature images per recipient
const sigImageCache = new Map<string, any>();
for (const r of recipients || []) {
if (!r.signature_data_url) continue;
try {
const isPng = r.signature_data_url.includes("image/png");
const base64 = r.signature_data_url.split(",")[1] || "";
const sigBytes = Uint8Array.from(atob(base64), c => c.charCodeAt(0));
const img = isPng ? await pdfDoc.embedPng(sigBytes) : await pdfDoc.embedJpg(sigBytes);
sigImageCache.set(r.id, img);
} catch (e) { console.warn("sig embed failed:", r.id, e); }
}
const fieldsByRecipient = new Map<string, any[]>();
(allFields || []).forEach(f => {
const arr = fieldsByRecipient.get(f.recipient_id) || [];
arr.push(f); fieldsByRecipient.set(f.recipient_id, arr);
});
const hasPlacedFields = (allFields || []).length > 0;
if (hasPlacedFields) {
// Stamp at each placed field location
for (const r of recipients || []) {
const fields = fieldsByRecipient.get(r.id) || [];
const sigImg = sigImageCache.get(r.id);
const signedDate = r.signed_at ? new Date(r.signed_at) : new Date();
const dateStr = signedDate.toLocaleDateString("en-US", { timeZone: "America/New_York" });
for (const f of fields) {
const pageIdx = Math.min((f.page_number || 1) - 1, pages.length - 1);
const page = pages[pageIdx];
const { width: pw, height: ph } = page.getSize();
// UI uses top-left origin; pdf-lib uses bottom-left
const x = f.x_ratio * pw;
const y = ph - (f.y_ratio + f.height_ratio) * ph;
const w = f.width_ratio * pw;
const h = f.height_ratio * ph;
if (f.field_type === "signature" && sigImg) {
// Fit signature into box preserving aspect ratio
const ratio = Math.min(w / sigImg.width, h / sigImg.height);
const drawW = sigImg.width * ratio;
const drawH = sigImg.height * ratio;
page.drawImage(sigImg, {
x: x + (w - drawW) / 2,
y: y + (h - drawH) / 2,
width: drawW, height: drawH,
});
} else if (f.field_type === "date") {
page.drawText(dateStr, { x: x + 4, y: y + h / 2 - 4, size: Math.min(11, h * 0.7), font: helv, color: rgb(0, 0, 0) });
} else if (f.field_type === "name") {
page.drawText(r.name, { x: x + 4, y: y + h / 2 - 4, size: Math.min(11, h * 0.7), font: helvBold, color: rgb(0, 0, 0) });
}
}
}
} else {
// Fallback: stamp on bottom of last page
const lastPage = pages[pages.length - 1];
let stampY = 80;
for (const r of recipients || []) {
const sigImg = sigImageCache.get(r.id);
if (!sigImg) continue;
const sigDims = sigImg.scale(120 / sigImg.width);
lastPage.drawImage(sigImg, { x: 50, y: stampY, width: sigDims.width, height: sigDims.height });
lastPage.drawText(`${r.name}`, { x: 180, y: stampY + 25, size: 9, font: helvBold, color: rgb(0, 0, 0) });
lastPage.drawText(`Signed: ${new Date(r.signed_at).toLocaleString("en-US", { timeZone: "America/New_York" })} EST`, {
x: 180, y: stampY + 12, size: 8, font: helv, color: rgb(0.3, 0.3, 0.3),
});
lastPage.drawText(`ID: ${r.id.slice(0, 8).toUpperCase()}`, {
x: 180, y: stampY, size: 7, font: helv, color: rgb(0.5, 0.5, 0.5),
});
stampY += 60;
if (stampY > 250) break;
}
}
// Append Certificate of Validation page
const cert = pdfDoc.addPage();
const { width, height } = cert.getSize();
cert.drawRectangle({ x: 30, y: 30, width: width - 60, height: height - 60, borderColor: rgb(0, 0, 0), borderWidth: 2 });
cert.drawRectangle({ x: 40, y: 40, width: width - 80, height: height - 80, borderColor: rgb(0, 0, 0), borderWidth: 0.5 });
cert.drawText("CERTIFICATE OF VALIDATION", { x: width / 2 - 165, y: height - 110, size: 22, font: helvBold });
cert.drawText("Cryptographic proof of document signing and integrity.", {
x: width / 2 - 175, y: height - 135, size: 10, font: helv, color: rgb(0.4, 0.4, 0.4),
});
let y = height - 200;
const drawRow = (label: string, value: string) => {
cert.drawText(label, { x: 70, y, size: 11, font: helvBold });
cert.drawText(value, { x: 230, y, size: 11, font: helv });
cert.drawLine({ start: { x: 70, y: y - 5 }, end: { x: width - 70, y: y - 5 }, thickness: 0.3, color: rgb(0.7, 0.7, 0.7) });
y -= 30;
};
drawRow("Document Name:", env.document_name);
drawRow("Envelope ID:", env.id);
drawRow("Issued By:", "Avria Community Management");
drawRow("Completed:", new Date(env.completed_at || Date.now()).toLocaleString("en-US", { timeZone: "America/New_York" }) + " EST");
drawRow("Status:", "COMPLETED");
// Real cryptographic integrity proof: SHA-256 of the original document.
cert.drawText("Document SHA-256 (original):", { x: 70, y, size: 11, font: helvBold });
y -= 14;
cert.drawText(docHash, { x: 70, y, size: 8, font: helv, color: rgb(0.3, 0.3, 0.3) });
cert.drawLine({ start: { x: 70, y: y - 5 }, end: { x: width - 70, y: y - 5 }, thickness: 0.3, color: rgb(0.7, 0.7, 0.7) });
y -= 26;
cert.drawText("SIGNERS", { x: 70, y, size: 12, font: helvBold });
y -= 20;
for (const r of recipients || []) {
cert.drawText(`• ${r.name} <${r.email}>`, { x: 80, y, size: 10, font: helv });
y -= 14;
if (r.signed_at) {
cert.drawText(
` Signed ${new Date(r.signed_at).toLocaleString("en-US", { timeZone: "America/New_York" })} EST` +
(r.signed_ip ? ` from ${r.signed_ip}` : "") +
` (${r.signature_method || "draw"})`,
{ x: 80, y, size: 8, font: helv, color: rgb(0.4, 0.4, 0.4) }
);
y -= 12;
}
cert.drawText(` Signature ID: ${r.id}`, { x: 80, y, size: 7, font: helv, color: rgb(0.5, 0.5, 0.5) });
y -= 16;
}
cert.drawCircle({ x: width - 110, y: 110, size: 45, borderColor: rgb(0.2, 0.4, 0.7), borderWidth: 3, opacity: 0.6 });
cert.drawText("OFFICIAL", { x: width - 130, y: 110, size: 9, font: helvBold, color: rgb(0.2, 0.4, 0.7) });
cert.drawText("SEAL", { x: width - 122, y: 98, size: 9, font: helvBold, color: rgb(0.2, 0.4, 0.7) });
const finalBytes = await pdfDoc.save();
const path = `signature-envelopes/${envelope_id}/signed.pdf`;
const { error: upErr } = await admin.storage.from("files").upload(path, finalBytes, {
contentType: "application/pdf", upsert: true,
});
if (upErr) throw upErr;
const { data: pub } = admin.storage.from("files").getPublicUrl(path);
await admin.from("signature_envelopes").update({ signed_document_url: pub.publicUrl }).eq("id", envelope_id);
// Notify all signers + creator
const { data: envFull } = await admin.from("signature_envelopes").select("created_by, document_name").eq("id", envelope_id).single();
const recipientEmails = (recipients || []).map(r => r.email);
let creatorEmail: string | null = null;
if (envFull?.created_by) {
const { data: creator } = await admin.auth.admin.getUserById(envFull.created_by);
creatorEmail = creator?.user?.email || null;
}
const allEmails = Array.from(new Set([...recipientEmails, ...(creatorEmail ? [creatorEmail] : [])]));
const { data: defaultSender } = await admin
.from("email_senders").select("smtp_host, smtp_port, smtp_username, smtp_password, use_tls, use_ssl, email_address, sender_name, signature_html").eq("is_active", true).eq("is_default", true)
.order("updated_at", { ascending: false }).limit(1).maybeSingle();
const smtpSender = defaultSender ? toSmtpSenderConfig(defaultSender) : null;
for (const email of allEmails) {
try {
if (smtpSender) {
const html = `
<div style="font-family:Arial,sans-serif;max-width:600px;margin:0 auto;padding:20px;">
<h2 style="color:#059669;">Document Signed</h2>
<p>The document <strong>${envFull?.document_name || "Document"}</strong> has been signed by all parties.</p>
<p style="margin:20px 0;">
<a href="${pub.publicUrl}" style="background:#1e3a8a;color:#fff;padding:12px 24px;text-decoration:none;border-radius:6px;display:inline-block;">Download Signed Copy</a>
</p>
<hr style="margin-top:30px;border:none;border-top:1px solid #eee;"/>
<p style="font-size:11px;color:#999;">Avria Sign — Secure Electronic Signatures</p>
</div>`;
await admin.functions.invoke("send-smtp-email", {
body: {
sender: smtpSender,
recipient: email,
subject: `Signed: ${envFull?.document_name || "Document"}`,
html,
body: html,
fallback_user_id: envFull?.created_by || null,
feature_type: "signature_completed",
},
});
}
} catch (e) {
console.warn("Completion email failed for", email, e);
}
}
return json({ ok: true, signed_document_url: pub.publicUrl });
} catch (err: any) {
console.error("avria-sign-stamp error:", err);
return json({ error: err.message || String(err) }, 500);
}
});
function json(payload: unknown, status = 200) {
return new Response(JSON.stringify(payload), {
status,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}