Files
acmcc/supabase/functions/docusign-send/index.ts
T
2026-06-01 20:19:26 -04:00

432 lines
13 KiB
TypeScript

import { serve } from "https://deno.land/std@0.168.0/http/server.ts";
import { createClient } from "https://esm.sh/@supabase/supabase-js@2";
const corsHeaders = {
"Access-Control-Allow-Origin": "*",
"Access-Control-Allow-Headers":
"authorization, x-client-info, apikey, content-type, x-supabase-client-platform, x-supabase-client-platform-version, x-supabase-client-runtime, x-supabase-client-runtime-version",
};
class DocuSignConsentRequiredError extends Error {
consentUrl: string;
constructor(consentUrl: string) {
super("DocuSign consent required");
this.name = "DocuSignConsentRequiredError";
this.consentUrl = consentUrl;
}
}
function concatBytes(...arrays: Uint8Array[]) {
const totalLength = arrays.reduce((sum, array) => sum + array.length, 0);
const result = new Uint8Array(totalLength);
let offset = 0;
for (const array of arrays) {
result.set(array, offset);
offset += array.length;
}
return result;
}
function encodeDerLength(length: number) {
if (length < 0x80) {
return Uint8Array.of(length);
}
const bytes: number[] = [];
let remaining = length;
while (remaining > 0) {
bytes.unshift(remaining & 0xff);
remaining >>= 8;
}
return Uint8Array.of(0x80 | bytes.length, ...bytes);
}
function wrapPkcs1InPkcs8(pkcs1Der: Uint8Array) {
const version = Uint8Array.of(0x02, 0x01, 0x00);
const rsaEncryptionOid = Uint8Array.of(
0x06, 0x09,
0x2a, 0x86, 0x48, 0x86, 0xf7, 0x0d, 0x01, 0x01, 0x01,
);
const nullParams = Uint8Array.of(0x05, 0x00);
const algorithmIdentifier = concatBytes(
Uint8Array.of(0x30),
encodeDerLength(rsaEncryptionOid.length + nullParams.length),
rsaEncryptionOid,
nullParams,
);
const privateKey = concatBytes(
Uint8Array.of(0x04),
encodeDerLength(pkcs1Der.length),
pkcs1Der,
);
const body = concatBytes(version, algorithmIdentifier, privateKey);
return concatBytes(
Uint8Array.of(0x30),
encodeDerLength(body.length),
body,
);
}
function normalizeSecretValue(rawValue: string) {
return rawValue
.trim()
.replace(/^['"]/, "")
.replace(/['"]$/, "")
.replace(/\\r/g, "")
.replace(/\\n/g, "\n")
.trim();
}
function extractPemParts(rawValue: string) {
const normalizedValue = normalizeSecretValue(rawValue);
const pemMatch = normalizedValue.match(
/-----BEGIN\s+([A-Z ]+PRIVATE KEY)-----([\s\S]*?)-----END\s+\1-----/
);
if (pemMatch) {
return {
pemType: pemMatch[1],
pemBody: pemMatch[2].replace(/\s/g, ""),
};
}
if (/^[A-Za-z0-9+/=\s]+$/.test(normalizedValue)) {
return {
pemType: "RAW_BASE64_PRIVATE_KEY",
pemBody: normalizedValue.replace(/\s/g, ""),
};
}
throw new Error("DocuSign private key secret must contain a valid PEM or base64 private key");
}
function getDocuSignConsentUrl() {
const integrationKey = Deno.env.get("DOCUSIGN_INTEGRATION_KEY");
const redirectUri =
Deno.env.get("DOCUSIGN_REDIRECT_URI") ||
"https://developers.docusign.com/platform/auth/consent";
if (!integrationKey) {
throw new Error("DOCUSIGN_INTEGRATION_KEY not configured");
}
const params = new URLSearchParams({
response_type: "code",
scope: "signature impersonation",
client_id: integrationKey,
redirect_uri: redirectUri,
});
return `https://account-d.docusign.com/oauth/auth?${params.toString()}`;
}
// DocuSign JWT Grant: get access token using RSA key
async function getDocuSignToken(): Promise<string> {
const integrationKey = Deno.env.get("DOCUSIGN_INTEGRATION_KEY");
const userId = Deno.env.get("DOCUSIGN_USER_ID");
const rsaPrivateKey = Deno.env.get("DOCUSIGN_RSA_PRIVATE_KEY");
if (!integrationKey || !userId || !rsaPrivateKey) {
throw new Error("DocuSign credentials not configured");
}
const now = Math.floor(Date.now() / 1000);
const header = { alg: "RS256", typ: "JWT" };
const payload = {
iss: integrationKey,
sub: userId,
aud: "account-d.docusign.com",
iat: now,
exp: now + 3600,
scope: "signature impersonation",
};
const encode = (obj: unknown) =>
btoa(JSON.stringify(obj))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
const headerB64 = encode(header);
const payloadB64 = encode(payload);
const signingInput = `${headerB64}.${payloadB64}`;
const { pemType, pemBody } = extractPemParts(rsaPrivateKey);
const isEncryptedPem = pemType === "ENCRYPTED PRIVATE KEY";
const isPkcs1Pem = pemType === "RSA PRIVATE KEY";
if (isEncryptedPem) {
throw new Error("DocuSign private key must be an unencrypted PEM key");
}
const { decode: decodeBase64 } = await import("https://deno.land/std@0.168.0/encoding/base64.ts");
const rawKeyBytes = decodeBase64(pemBody);
const keyBytes = isPkcs1Pem ? wrapPkcs1InPkcs8(rawKeyBytes) : rawKeyBytes;
let cryptoKey: CryptoKey;
try {
cryptoKey = await crypto.subtle.importKey(
"pkcs8",
keyBytes,
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
false,
["sign"]
);
} catch (primaryError) {
if (isPkcs1Pem) {
throw new Error(`Invalid DocuSign RSA private key: ${primaryError instanceof Error ? primaryError.message : String(primaryError)}`);
}
try {
cryptoKey = await crypto.subtle.importKey(
"pkcs8",
wrapPkcs1InPkcs8(rawKeyBytes),
{ name: "RSASSA-PKCS1-v1_5", hash: "SHA-256" },
false,
["sign"]
);
} catch {
throw new Error(`Invalid DocuSign private key format: ${primaryError instanceof Error ? primaryError.message : String(primaryError)}`);
}
}
const signature = await crypto.subtle.sign(
"RSASSA-PKCS1-v1_5",
cryptoKey,
new TextEncoder().encode(signingInput)
);
const signatureB64 = btoa(String.fromCharCode(...new Uint8Array(signature)))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
const jwt = `${signingInput}.${signatureB64}`;
// Exchange JWT for access token
const tokenRes = await fetch("https://account-d.docusign.com/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: `grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer&assertion=${jwt}`,
});
if (!tokenRes.ok) {
const err = await tokenRes.text();
if (err.includes("consent_required")) {
throw new DocuSignConsentRequiredError(getDocuSignConsentUrl());
}
throw new Error(`DocuSign token error: ${err}`);
}
const tokenData = await tokenRes.json();
return tokenData.access_token;
}
serve(async (req) => {
if (req.method === "OPTIONS") {
return new Response(null, { headers: corsHeaders });
}
try {
// Validate user auth using getClaims for reliability
const authHeader = req.headers.get("authorization");
if (!authHeader?.startsWith("Bearer ")) throw new Error("Missing authorization header");
const supabaseUrl = Deno.env.get("SUPABASE_URL")!;
const supabaseAnonKey = Deno.env.get("SUPABASE_ANON_KEY")!;
const serviceRoleKey = Deno.env.get("SUPABASE_SERVICE_ROLE_KEY")!;
const authClient = createClient(supabaseUrl, supabaseAnonKey, {
global: { headers: { Authorization: authHeader } },
});
const token = authHeader.replace("Bearer ", "");
const { data: claimsData, error: claimsError } = await authClient.auth.getClaims(token);
if (claimsError || !claimsData?.claims) throw new Error("Unauthorized");
const userId = claimsData.claims.sub as string;
const serviceClient = createClient(supabaseUrl, serviceRoleKey);
// Check admin/manager role
const { data: roles } = await serviceClient
.from("user_roles")
.select("role")
.eq("user_id", userId);
const hasAccess = roles?.some((r: any) =>
["admin", "manager"].includes(r.role)
);
if (!hasAccess) throw new Error("Insufficient permissions");
const body = await req.json();
const { action } = body;
if (action === "send") {
const {
association_id,
document_name,
document_base64,
file_extension,
recipients,
email_subject,
email_body,
} = body;
if (!association_id || !document_name || !document_base64 || !recipients?.length) {
throw new Error("Missing required fields: association_id, document_name, document_base64, recipients");
}
const accountId = Deno.env.get("DOCUSIGN_ACCOUNT_ID");
if (!accountId) throw new Error("DOCUSIGN_ACCOUNT_ID not configured");
const accessToken = await getDocuSignToken();
// Build signers with tabs (signature + date fields)
const signers = recipients.map((r: any, idx: number) => ({
email: r.email,
name: r.name,
recipientId: String(idx + 1),
routingOrder: String(idx + 1),
tabs: {
signHereTabs: [
{
anchorString: "/sig/",
anchorXOffset: "0",
anchorYOffset: "0",
anchorUnits: "pixels",
},
],
dateSignedTabs: [
{
anchorString: "/date/",
anchorXOffset: "0",
anchorYOffset: "0",
anchorUnits: "pixels",
},
],
},
}));
const envelopeDefinition = {
emailSubject: email_subject || `Please sign: ${document_name}`,
emailBlurb: email_body || "Please review and sign the attached document.",
documents: [
{
documentBase64: document_base64,
name: document_name,
fileExtension: file_extension || "pdf",
documentId: "1",
},
],
recipients: { signers },
status: "sent",
};
const envelopeRes = await fetch(
`https://demo.docusign.net/restapi/v2.1/accounts/${accountId}/envelopes`,
{
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify(envelopeDefinition),
}
);
if (!envelopeRes.ok) {
const errText = await envelopeRes.text();
throw new Error(`DocuSign API error [${envelopeRes.status}]: ${errText}`);
}
const envelopeData = await envelopeRes.json();
// Record in database
await serviceClient.from("docusign_envelopes").insert({
association_id,
envelope_id: envelopeData.envelopeId,
document_name,
status: "sent",
recipients: JSON.stringify(recipients),
sent_by: userId,
sent_at: new Date().toISOString(),
});
return new Response(
JSON.stringify({
success: true,
envelope_id: envelopeData.envelopeId,
status: envelopeData.status,
}),
{ headers: { ...corsHeaders, "Content-Type": "application/json" } }
);
}
if (action === "status") {
const { envelope_id } = body;
if (!envelope_id) throw new Error("Missing envelope_id");
const accountId = Deno.env.get("DOCUSIGN_ACCOUNT_ID");
if (!accountId) throw new Error("DOCUSIGN_ACCOUNT_ID not configured");
const accessToken = await getDocuSignToken();
const statusRes = await fetch(
`https://demo.docusign.net/restapi/v2.1/accounts/${accountId}/envelopes/${envelope_id}`,
{
headers: { Authorization: `Bearer ${accessToken}` },
}
);
if (!statusRes.ok) {
const errText = await statusRes.text();
throw new Error(`DocuSign status error [${statusRes.status}]: ${errText}`);
}
const statusData = await statusRes.json();
// Update local record
await serviceClient
.from("docusign_envelopes")
.update({
status: statusData.status,
completed_at: statusData.status === "completed" ? new Date().toISOString() : null,
})
.eq("envelope_id", envelope_id);
return new Response(JSON.stringify({ success: true, ...statusData }), {
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
throw new Error(`Unknown action: ${action}`);
} catch (error: unknown) {
console.error("DocuSign function error:", error);
if (error instanceof DocuSignConsentRequiredError) {
return new Response(JSON.stringify({
success: false,
error: error.message,
code: "consent_required",
consent_url: error.consentUrl,
}), {
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
const message = error instanceof Error ? error.message : "Unknown error";
return new Response(JSON.stringify({ success: false, error: message }), {
status: 400,
headers: { ...corsHeaders, "Content-Type": "application/json" },
});
}
});