import type { ESignAdapter, ESignCredentials, ESignTokens, SendParams } from "./types" import { esignRedirectUri } from "./types" // DocuSign eSignature via per-landlord OAuth (Authorization Code Grant). // The OPERATOR registers one DocuSign app and sets these; each LANDLORD then // connects their own DocuSign account through it. // DOCUSIGN_CLIENT_ID / DOCUSIGN_CLIENT_SECRET — the app's integration key + secret // DOCUSIGN_OAUTH_BASE — "account-d.docusign.com" (demo) or "account.docusign.com" (prod) const CLIENT_ID = process.env.DOCUSIGN_CLIENT_ID ?? "" const CLIENT_SECRET = process.env.DOCUSIGN_CLIENT_SECRET ?? "" const OAUTH_BASE = (process.env.DOCUSIGN_OAUTH_BASE ?? "account-d.docusign.com").replace(/^https?:\/\//, "").replace(/\/+$/, "") function basicAuth() { return "Basic " + Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64") } function extOf(name: string): string { const e = name.split(".").pop()?.toLowerCase() return e && /^(pdf|docx?|png|jpe?g)$/.test(e) ? e : "pdf" } /** Map a DocuSign envelope/event status string to one of our terminal statuses. */ function mapStatus(raw: string): "signed" | "declined" | "voided" | null { const s = raw.toLowerCase() if (s.includes("completed") || s.includes("signed")) return "signed" if (s.includes("declined")) return "declined" if (s.includes("voided")) return "voided" return null } async function tokenRequest(form: Record): Promise<{ access_token: string; refresh_token: string; expires_in: number }> { const res = await fetch(`https://${OAUTH_BASE}/oauth/token`, { method: "POST", headers: { Authorization: basicAuth(), "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" }, body: new URLSearchParams(form), }) if (!res.ok) throw new Error(`DocuSign token error ${res.status}: ${(await res.text()).slice(0, 300)}`) return res.json() } async function userInfo(accessToken: string): Promise<{ accountId: string | null; baseUri: string | null; accountName: string | null }> { const res = await fetch(`https://${OAUTH_BASE}/oauth/userinfo`, { headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json" }, }) if (!res.ok) throw new Error(`DocuSign userinfo ${res.status}`) const j = (await res.json()) as { accounts?: { account_id: string; base_uri: string; account_name: string; is_default: boolean }[] } const acct = j.accounts?.find((a) => a.is_default) ?? j.accounts?.[0] return { accountId: acct?.account_id ?? null, baseUri: acct?.base_uri ?? null, accountName: acct?.account_name ?? null } } /** REST API base for the envelopes API, e.g. https://na3.docusign.net/restapi/v2.1/accounts/ */ function apiBase(creds: ESignCredentials): string { return `${(creds.baseUri ?? "").replace(/\/+$/, "")}/restapi/v2.1/accounts/${creds.accountId}` } export const docusign: ESignAdapter = { id: "docusign", label: "DocuSign", kind: "oauth", available: () => Boolean(CLIENT_ID && CLIENT_SECRET), getAuthUrl(state) { const p = new URLSearchParams({ response_type: "code", // `extended` is required to receive a refresh token. scope: "signature extended", client_id: CLIENT_ID, redirect_uri: esignRedirectUri("docusign"), state, }) return `https://${OAUTH_BASE}/oauth/auth?${p.toString()}` }, async exchangeCode(code): Promise { const t = await tokenRequest({ grant_type: "authorization_code", code }) const info = await userInfo(t.access_token) return { accessToken: t.access_token, refreshToken: t.refresh_token, expiresAt: new Date(Date.now() + t.expires_in * 1000).toISOString(), accountId: info.accountId, baseUri: info.baseUri, accountName: info.accountName, } }, async refresh(refreshToken): Promise { const t = await tokenRequest({ grant_type: "refresh_token", refresh_token: refreshToken }) // Account id / base uri are stable across refreshes; the resolver re-attaches them. return { accessToken: t.access_token, refreshToken: t.refresh_token, expiresAt: new Date(Date.now() + t.expires_in * 1000).toISOString(), accountId: null, baseUri: null, accountName: null, } }, async connectApiKey(): Promise { throw new Error("DocuSign connects via OAuth, not an API key") }, async send(creds, p: SendParams) { const envelope = { emailSubject: p.subject, status: "sent", documents: [{ documentBase64: p.document.toString("base64"), name: p.documentName, fileExtension: extOf(p.documentName), documentId: "1" }], recipients: { signers: [ { email: p.signerEmail, name: p.signerName, recipientId: "1", routingOrder: "1", tabs: { signHereTabs: [{ documentId: "1", pageNumber: "1", xPosition: "100", yPosition: "650" }] }, }, ], }, // Envelope-level Connect: DocuSign pings our webhook on completion so we // pull the authoritative status. No account-level Connect config needed. eventNotification: { url: p.webhookUrl, loggingEnabled: "true", requireAcknowledgment: "true", envelopeEvents: [ { envelopeEventStatusCode: "completed" }, { envelopeEventStatusCode: "declined" }, { envelopeEventStatusCode: "voided" }, ], eventData: { version: "restv2.1" }, }, } const res = await fetch(`${apiBase(creds)}/envelopes`, { method: "POST", headers: { Authorization: `Bearer ${creds.accessToken}`, "Content-Type": "application/json" }, body: JSON.stringify(envelope), }) if (!res.ok) throw new Error(`DocuSign ${res.status}: ${(await res.text()).slice(0, 300)}`) const j = (await res.json()) as { envelopeId?: string } if (!j.envelopeId) throw new Error("DocuSign did not return an envelopeId") return { externalId: j.envelopeId } }, peekExternalId(body): string | null { try { const j = JSON.parse(body) as { data?: { envelopeId?: string } } return j.data?.envelopeId ?? null } catch { return null } }, // We never trust the webhook body's status. Instead we fetch the envelope from // DocuSign with the owner's own OAuth token — a forged webhook can at most make // us re-read the real status, never fabricate a "signed". async verifyAndGetStatus(creds, externalId) { const res = await fetch(`${apiBase(creds)}/envelopes/${externalId}`, { headers: { Authorization: `Bearer ${creds.accessToken}`, Accept: "application/json" }, }) if (!res.ok) return null const j = (await res.json()) as { status?: string } return j.status ? mapStatus(j.status) : null }, async getSignedDocument(creds, externalId): Promise { const res = await fetch(`${apiBase(creds)}/envelopes/${externalId}/documents/combined`, { headers: { Authorization: `Bearer ${creds.accessToken}`, Accept: "application/pdf" }, }) if (!res.ok) return null return Buffer.from(await res.arrayBuffer()) }, }