mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 01:40:01 +00:00
183fe0a93c
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
432 lines
13 KiB
TypeScript
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" },
|
|
});
|
|
}
|
|
});
|