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