mirror of
https://github.com/renee-png/acmcc.git
synced 2026-06-21 09:50:01 +00:00
Add ACMCC app source, Supabase backend, and project config
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,431 @@
|
||||
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" },
|
||||
});
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user