Deploy config: - .do/app.yaml: build the Dockerfile directly from GitHub (deploy_on_push) instead of a pre-built DOCR image; NEXT_PUBLIC_* set RUN_AND_BUILD_TIME with the propertymanagement.network domain so they bake into the client bundle; add custom domains block (apex + www); wire Sentry DSN (server + browser). Included pending work from the audit-fixes branch: - AI provider abstraction (OpenAI/Anthropic, admin-selectable; Anthropic default) - Per-landlord e-signature (DocuSign OAuth + Dropbox Sign) + migration 0010 - Outbound webhooks / Zapier integration - PayPal removal (Stripe-only billing) - Storage hardening (fail-loud when Spaces unconfigured), security fixes Verified: full production Docker build (same build-args as DO) passes clean. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
174 lines
7.0 KiB
TypeScript
174 lines
7.0 KiB
TypeScript
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<string, string>): 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/<id> */
|
|
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<ESignTokens> {
|
|
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<ESignTokens> {
|
|
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<ESignTokens> {
|
|
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<Buffer | null> {
|
|
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())
|
|
},
|
|
}
|