Consolidate audit-fixes branch: webhooks, integrations, and deploy hardening
Batch commit of the pending working tree on security/audit-fixes-2026-07. Major areas: - Outbound webhooks / Zapier: schema + signed delivery with retries, public v1 API (REST-hook subscribe/unsubscribe), settings UI, cron drain. - Deploy hardening: email via SMTP2GO (Resend fully removed), verified DB TLS (DATABASE_SSL=require + DATABASE_CA), storage fails loud in production when Spaces is unconfigured instead of silently using ephemeral disk. - Integrations & features (concurrent work): accounting (QuickBooks/Xero), e-signature (DocuSign/Dropbox Sign), PayPal, geocoding/maps, onboarding, expanded legal pages. - DB migrations 0006–0009. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
969d5d4c8a
commit
c9968531e4
@@ -0,0 +1,65 @@
|
||||
import type { ESignAdapter, SendParams, WebhookResult } from "./types"
|
||||
|
||||
// DocuSign eSignature REST API. Uses a pre-obtained access token (via JWT grant
|
||||
// or OAuth) — set DOCUSIGN_ACCESS_TOKEN / DOCUSIGN_ACCOUNT_ID / DOCUSIGN_BASE_URI.
|
||||
// Docs: https://developers.docusign.com/docs/esign-rest-api/reference/envelopes/envelopes/create/
|
||||
const ACCESS_TOKEN = process.env.DOCUSIGN_ACCESS_TOKEN ?? ""
|
||||
const ACCOUNT_ID = process.env.DOCUSIGN_ACCOUNT_ID ?? ""
|
||||
const BASE_URI = (process.env.DOCUSIGN_BASE_URI ?? "https://demo.docusign.net").replace(/\/+$/, "")
|
||||
|
||||
function extOf(name: string): string {
|
||||
const e = name.split(".").pop()?.toLowerCase()
|
||||
return e && /^(pdf|docx?|png|jpe?g)$/.test(e) ? e : "pdf"
|
||||
}
|
||||
|
||||
export const docusign: ESignAdapter = {
|
||||
id: "docusign",
|
||||
label: "DocuSign",
|
||||
configured: () => Boolean(ACCESS_TOKEN && ACCOUNT_ID),
|
||||
|
||||
async send({ document, documentName, signerEmail, signerName, subject }: SendParams) {
|
||||
const envelope = {
|
||||
emailSubject: subject,
|
||||
status: "sent",
|
||||
documents: [{ documentBase64: document.toString("base64"), name: documentName, fileExtension: extOf(documentName), documentId: "1" }],
|
||||
recipients: {
|
||||
signers: [
|
||||
{
|
||||
email: signerEmail,
|
||||
name: signerName,
|
||||
recipientId: "1",
|
||||
routingOrder: "1",
|
||||
// Default sign placement (bottom of page 1). Use a template/anchor
|
||||
// string for precise field placement in production.
|
||||
tabs: { signHereTabs: [{ documentId: "1", pageNumber: "1", xPosition: "100", yPosition: "650" }] },
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
const res = await fetch(`${BASE_URI}/restapi/v2.1/accounts/${ACCOUNT_ID}/envelopes`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${ACCESS_TOKEN}`, "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 }
|
||||
},
|
||||
|
||||
parseWebhook(body): WebhookResult | null {
|
||||
// DocuSign Connect (JSON format) payload.
|
||||
try {
|
||||
const j = JSON.parse(body) as { event?: string; data?: { envelopeId?: string; envelopeSummary?: { status?: string } } }
|
||||
const externalId = j.data?.envelopeId
|
||||
const status = (j.data?.envelopeSummary?.status ?? j.event ?? "").toLowerCase()
|
||||
if (!externalId) return null
|
||||
if (status.includes("completed") || status.includes("signed")) return { externalId, status: "signed" }
|
||||
if (status.includes("declined")) return { externalId, status: "declined" }
|
||||
if (status.includes("voided")) return { externalId, status: "voided" }
|
||||
return null
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { ESignAdapter, SendParams, WebhookResult } from "./types"
|
||||
|
||||
// Dropbox Sign (formerly HelloSign). API-key auth. Docs:
|
||||
// https://developers.hellosign.com/api/reference/operation/signatureRequestSend/
|
||||
const API_KEY = process.env.DROPBOX_SIGN_API_KEY ?? ""
|
||||
const TEST_MODE = process.env.DROPBOX_SIGN_TEST_MODE === "true" ? "1" : "0"
|
||||
const BASE = "https://api.hellosign.com/v3"
|
||||
|
||||
function auth() {
|
||||
return "Basic " + Buffer.from(`${API_KEY}:`).toString("base64")
|
||||
}
|
||||
|
||||
export const dropboxSign: ESignAdapter = {
|
||||
id: "dropbox_sign",
|
||||
label: "Dropbox Sign",
|
||||
configured: () => Boolean(API_KEY),
|
||||
|
||||
async send({ document, documentName, signerEmail, signerName, subject, message }: SendParams) {
|
||||
const fd = new FormData()
|
||||
fd.append("subject", subject)
|
||||
fd.append("message", message)
|
||||
fd.append("test_mode", TEST_MODE)
|
||||
fd.append("signers[0][email_address]", signerEmail)
|
||||
fd.append("signers[0][name]", signerName)
|
||||
fd.append("file[0]", new Blob([new Uint8Array(document)], { type: "application/pdf" }), documentName)
|
||||
|
||||
const res = await fetch(`${BASE}/signature_request/send`, { method: "POST", headers: { Authorization: auth() }, body: fd })
|
||||
if (!res.ok) throw new Error(`Dropbox Sign ${res.status}: ${(await res.text()).slice(0, 300)}`)
|
||||
const j = (await res.json()) as { signature_request?: { signature_request_id?: string } }
|
||||
const id = j.signature_request?.signature_request_id
|
||||
if (!id) throw new Error("Dropbox Sign did not return a signature_request_id")
|
||||
return { externalId: id }
|
||||
},
|
||||
|
||||
parseWebhook(body): WebhookResult | null {
|
||||
// Dropbox Sign posts multipart form-data with a `json` field.
|
||||
let event: { event?: { event_type?: string }; signature_request?: { signature_request_id?: string } }
|
||||
try {
|
||||
// Extract the JSON payload whether sent raw or as a form field.
|
||||
const m = body.match(/name="json"\r?\n\r?\n([\s\S]*?)\r?\n--/) ?? body.match(/^(\{[\s\S]*\})\s*$/)
|
||||
event = JSON.parse(m ? m[1] : body)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
const type = event.event?.event_type
|
||||
const externalId = event.signature_request?.signature_request_id
|
||||
if (!externalId || !type) return null
|
||||
if (type === "signature_request_all_signed") return { externalId, status: "signed" }
|
||||
if (type === "signature_request_declined") return { externalId, status: "declined" }
|
||||
if (type === "signature_request_canceled") return { externalId, status: "voided" }
|
||||
return null
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
import { and, desc, eq } from "drizzle-orm"
|
||||
import { db } from "@/lib/db"
|
||||
import { leases, signature_requests } from "@/lib/db/schema"
|
||||
import { readFile } from "@/lib/storage"
|
||||
import { docusign } from "./docusign"
|
||||
import { dropboxSign } from "./dropbox-sign"
|
||||
import type { ESignAdapter, ESignProvider } from "./types"
|
||||
|
||||
export type { ESignProvider } from "./types"
|
||||
|
||||
const ADAPTERS: Record<ESignProvider, ESignAdapter> = { docusign, dropbox_sign: dropboxSign }
|
||||
|
||||
export function getAdapter(id: string): ESignAdapter | null {
|
||||
return id === "docusign" || id === "dropbox_sign" ? ADAPTERS[id] : null
|
||||
}
|
||||
|
||||
export function listAdapters() {
|
||||
return (Object.keys(ADAPTERS) as ESignProvider[]).map((id) => ({ id, label: ADAPTERS[id].label, configured: ADAPTERS[id].configured() }))
|
||||
}
|
||||
|
||||
export function anyEsignConfigured(): boolean {
|
||||
return listAdapters().some((a) => a.configured)
|
||||
}
|
||||
|
||||
async function getDocumentBytes(documentUrl: string): Promise<{ bytes: Buffer; name: string }> {
|
||||
const prefix = "/api/files/"
|
||||
if (documentUrl.startsWith(prefix)) {
|
||||
const key = documentUrl.slice(prefix.length)
|
||||
return { bytes: await readFile(key), name: key.split("/").pop() ?? "lease.pdf" }
|
||||
}
|
||||
const res = await fetch(documentUrl)
|
||||
if (!res.ok) throw new Error("Could not fetch the lease document")
|
||||
return { bytes: Buffer.from(await res.arrayBuffer()), name: documentUrl.split("/").pop()?.split("?")[0] ?? "lease.pdf" }
|
||||
}
|
||||
|
||||
export async function sendLeaseForSignature(ownerId: string, leaseId: string, provider: ESignProvider) {
|
||||
const adapter = getAdapter(provider)
|
||||
if (!adapter) throw new Error("Unknown provider")
|
||||
if (!adapter.configured()) throw new Error(`${adapter.label} is not configured`)
|
||||
|
||||
const lease = await db.query.leases.findFirst({
|
||||
where: and(eq(leases.id, leaseId), eq(leases.user_id, ownerId)),
|
||||
with: { tenant: { columns: { first_name: true, last_name: true, email: true } } },
|
||||
})
|
||||
if (!lease) throw new Error("Lease not found")
|
||||
if (!lease.document_url) throw new Error("Upload a lease document before sending it for signature")
|
||||
const email = lease.tenant?.email
|
||||
if (!email) throw new Error("The tenant has no email address on file")
|
||||
const signerName = `${lease.tenant?.first_name ?? ""} ${lease.tenant?.last_name ?? ""}`.trim() || "Tenant"
|
||||
|
||||
const { bytes, name } = await getDocumentBytes(lease.document_url)
|
||||
|
||||
try {
|
||||
const { externalId } = await adapter.send({
|
||||
document: bytes,
|
||||
documentName: name,
|
||||
signerEmail: email,
|
||||
signerName,
|
||||
subject: "Please sign your lease agreement",
|
||||
message: "Your landlord has sent your lease agreement for electronic signature.",
|
||||
})
|
||||
const [row] = await db
|
||||
.insert(signature_requests)
|
||||
.values({ user_id: ownerId, lease_id: leaseId, provider, external_id: externalId, status: "sent", signer_email: email, signer_name: signerName, document_name: name })
|
||||
.returning()
|
||||
return row
|
||||
} catch (e) {
|
||||
const msg = (e as Error).message.slice(0, 500)
|
||||
await db
|
||||
.insert(signature_requests)
|
||||
.values({ user_id: ownerId, lease_id: leaseId, provider, status: "error", signer_email: email, signer_name: signerName, document_name: name, last_error: msg })
|
||||
throw new Error(msg)
|
||||
}
|
||||
}
|
||||
|
||||
export async function listRequestsForLease(ownerId: string, leaseId: string) {
|
||||
return db.query.signature_requests.findMany({
|
||||
where: and(eq(signature_requests.user_id, ownerId), eq(signature_requests.lease_id, leaseId)),
|
||||
orderBy: desc(signature_requests.sent_at),
|
||||
})
|
||||
}
|
||||
|
||||
/** Update a request's status from an inbound provider webhook. */
|
||||
export async function handleEsignWebhook(provider: string, body: string, headers: Headers) {
|
||||
const adapter = getAdapter(provider)
|
||||
if (!adapter) return
|
||||
const result = adapter.parseWebhook(body, headers)
|
||||
if (!result) return
|
||||
await db
|
||||
.update(signature_requests)
|
||||
.set({
|
||||
status: result.status,
|
||||
completed_at: result.status === "signed" ? new Date().toISOString() : null,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.where(eq(signature_requests.external_id, result.externalId))
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
export type ESignProvider = "docusign" | "dropbox_sign"
|
||||
|
||||
export interface SendParams {
|
||||
document: Buffer
|
||||
documentName: string
|
||||
signerEmail: string
|
||||
signerName: string
|
||||
subject: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface WebhookResult {
|
||||
externalId: string
|
||||
status: "signed" | "declined" | "voided"
|
||||
}
|
||||
|
||||
export interface ESignAdapter {
|
||||
id: ESignProvider
|
||||
label: string
|
||||
/** True when this provider's credentials are configured in env. */
|
||||
configured(): boolean
|
||||
/** Send a document for signature; returns the provider's request/envelope id. */
|
||||
send(p: SendParams): Promise<{ externalId: string }>
|
||||
/** Parse an inbound webhook body into a status update (or null to ignore). */
|
||||
parseWebhook(body: string, headers: Headers): WebhookResult | null
|
||||
}
|
||||
Reference in New Issue
Block a user