Deploy on DigitalOcean App Platform (GitHub-source build) + consolidate audit-fixes
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>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
917a06ee85
commit
5495b94924
@@ -0,0 +1,104 @@
|
||||
import { and, eq } from "drizzle-orm"
|
||||
import { db } from "@/lib/db"
|
||||
import { esign_connections } from "@/lib/db/schema"
|
||||
import { encrypt, decrypt } from "@/lib/crypto"
|
||||
import { getAdapter } from "./registry"
|
||||
import type { ESignCredentials, ESignProvider, ESignTokens } from "./types"
|
||||
|
||||
// Per-owner e-sign connection storage + credential resolution. Mirrors
|
||||
// lib/accounting/index.ts: tokens are AES-256-GCM encrypted at rest, decrypted
|
||||
// on demand, and DocuSign access tokens are transparently refreshed near expiry.
|
||||
|
||||
/** Upsert an encrypted connection for (owner, provider). */
|
||||
export async function saveEsignConnection(ownerId: string, provider: ESignProvider, tokens: ESignTokens) {
|
||||
const values = {
|
||||
user_id: ownerId,
|
||||
provider,
|
||||
access_token: encrypt(tokens.accessToken),
|
||||
refresh_token: tokens.refreshToken ? encrypt(tokens.refreshToken) : null,
|
||||
expires_at: tokens.expiresAt,
|
||||
account_id: tokens.accountId,
|
||||
base_uri: tokens.baseUri,
|
||||
account_name: tokens.accountName,
|
||||
status: "active" as const,
|
||||
last_error: null,
|
||||
}
|
||||
const existing = await db.query.esign_connections.findFirst({
|
||||
where: and(eq(esign_connections.user_id, ownerId), eq(esign_connections.provider, provider)),
|
||||
columns: { id: true },
|
||||
})
|
||||
if (existing) {
|
||||
await db
|
||||
.update(esign_connections)
|
||||
.set({ ...values, updated_at: new Date().toISOString() })
|
||||
.where(eq(esign_connections.id, existing.id))
|
||||
} else {
|
||||
await db.insert(esign_connections).values(values)
|
||||
}
|
||||
}
|
||||
|
||||
export async function getEsignConnection(ownerId: string, provider: ESignProvider) {
|
||||
return db.query.esign_connections.findFirst({
|
||||
where: and(eq(esign_connections.user_id, ownerId), eq(esign_connections.provider, provider)),
|
||||
})
|
||||
}
|
||||
|
||||
/** Owner-facing list — never leaks tokens. */
|
||||
export async function listEsignConnections(ownerId: string) {
|
||||
const rows = await db.query.esign_connections.findMany({ where: eq(esign_connections.user_id, ownerId) })
|
||||
return rows.map((r) => ({
|
||||
provider: r.provider,
|
||||
accountName: r.account_name,
|
||||
status: r.status,
|
||||
lastError: r.last_error,
|
||||
}))
|
||||
}
|
||||
|
||||
export async function disconnectEsign(ownerId: string, provider: ESignProvider) {
|
||||
await db
|
||||
.delete(esign_connections)
|
||||
.where(and(eq(esign_connections.user_id, ownerId), eq(esign_connections.provider, provider)))
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve ready-to-use credentials for a connected account, refreshing the
|
||||
* DocuSign access token first if it's near expiry. Returns null when the owner
|
||||
* hasn't connected this provider.
|
||||
*/
|
||||
export async function resolveEsignCreds(ownerId: string, provider: ESignProvider): Promise<ESignCredentials | null> {
|
||||
const conn = await getEsignConnection(ownerId, provider)
|
||||
if (!conn || conn.status === "revoked") return null
|
||||
|
||||
let accessToken = decrypt(conn.access_token)
|
||||
const refreshToken = conn.refresh_token ? decrypt(conn.refresh_token) : null
|
||||
let accountId = conn.account_id
|
||||
let baseUri = conn.base_uri
|
||||
|
||||
const nearExpiry = conn.expires_at && new Date(conn.expires_at).getTime() - Date.now() < 5 * 60_000
|
||||
if (nearExpiry && refreshToken) {
|
||||
const adapter = getAdapter(provider)
|
||||
if (adapter) {
|
||||
const next = await adapter.refresh(refreshToken)
|
||||
// Account id / base uri are stable across refresh — keep the stored ones.
|
||||
await saveEsignConnection(ownerId, provider, {
|
||||
...next,
|
||||
accountId: conn.account_id,
|
||||
baseUri: conn.base_uri,
|
||||
accountName: conn.account_name,
|
||||
})
|
||||
accessToken = next.accessToken
|
||||
accountId = conn.account_id
|
||||
baseUri = conn.base_uri
|
||||
}
|
||||
}
|
||||
|
||||
return { provider, accessToken, refreshToken, accountId, baseUri }
|
||||
}
|
||||
|
||||
/** Flag a connection as errored (e.g. after a failed send/refresh). */
|
||||
export async function markEsignError(ownerId: string, provider: ESignProvider, message: string) {
|
||||
await db
|
||||
.update(esign_connections)
|
||||
.set({ status: "error", last_error: message.slice(0, 500), updated_at: new Date().toISOString() })
|
||||
.where(and(eq(esign_connections.user_id, ownerId), eq(esign_connections.provider, provider)))
|
||||
}
|
||||
+135
-27
@@ -1,44 +1,139 @@
|
||||
import type { ESignAdapter, SendParams, WebhookResult } from "./types"
|
||||
import type { ESignAdapter, ESignCredentials, ESignTokens, SendParams } from "./types"
|
||||
import { esignRedirectUri } 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(/\/+$/, "")
|
||||
// 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",
|
||||
configured: () => Boolean(ACCESS_TOKEN && ACCOUNT_ID),
|
||||
kind: "oauth",
|
||||
available: () => Boolean(CLIENT_ID && CLIENT_SECRET),
|
||||
|
||||
async send({ document, documentName, signerEmail, signerName, subject }: SendParams) {
|
||||
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: subject,
|
||||
emailSubject: p.subject,
|
||||
status: "sent",
|
||||
documents: [{ documentBase64: document.toString("base64"), name: documentName, fileExtension: extOf(documentName), documentId: "1" }],
|
||||
documents: [{ documentBase64: p.document.toString("base64"), name: p.documentName, fileExtension: extOf(p.documentName), documentId: "1" }],
|
||||
recipients: {
|
||||
signers: [
|
||||
{
|
||||
email: signerEmail,
|
||||
name: signerName,
|
||||
email: p.signerEmail,
|
||||
name: p.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" }] },
|
||||
},
|
||||
],
|
||||
},
|
||||
// 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(`${BASE_URI}/restapi/v2.1/accounts/${ACCOUNT_ID}/envelopes`, {
|
||||
const res = await fetch(`${apiBase(creds)}/envelopes`, {
|
||||
method: "POST",
|
||||
headers: { Authorization: `Bearer ${ACCESS_TOKEN}`, "Content-Type": "application/json" },
|
||||
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)}`)
|
||||
@@ -47,19 +142,32 @@ export const docusign: ESignAdapter = {
|
||||
return { externalId: j.envelopeId }
|
||||
},
|
||||
|
||||
parseWebhook(body): WebhookResult | null {
|
||||
// DocuSign Connect (JSON format) payload.
|
||||
peekExternalId(body): string | null {
|
||||
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
|
||||
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())
|
||||
},
|
||||
}
|
||||
|
||||
+92
-31
@@ -1,30 +1,83 @@
|
||||
import type { ESignAdapter, SendParams, WebhookResult } from "./types"
|
||||
import crypto from "crypto"
|
||||
import type { ESignAdapter, ESignTokens, SendParams } 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"
|
||||
// Dropbox Sign (formerly HelloSign). Per-landlord API-key auth — each landlord
|
||||
// pastes their own API key (no platform app credentials needed). Docs:
|
||||
// https://developers.hellosign.com/api/reference/
|
||||
const BASE = "https://api.hellosign.com/v3"
|
||||
const TEST_MODE = process.env.DROPBOX_SIGN_TEST_MODE === "true" ? "1" : "0"
|
||||
|
||||
function auth() {
|
||||
return "Basic " + Buffer.from(`${API_KEY}:`).toString("base64")
|
||||
function authFor(apiKey: string) {
|
||||
return "Basic " + Buffer.from(`${apiKey}:`).toString("base64")
|
||||
}
|
||||
|
||||
/** Verify the `event_hash` (hex HMAC-SHA256 of event_time+event_type, key = API key). */
|
||||
function verifyEventHash(apiKey: string, ev?: { event_type?: string; event_time?: string; event_hash?: string }): boolean {
|
||||
if (!apiKey || !ev?.event_type || !ev?.event_time || !ev?.event_hash) return false
|
||||
const expected = crypto.createHmac("sha256", apiKey).update(ev.event_time + ev.event_type).digest("hex")
|
||||
const a = Buffer.from(ev.event_hash)
|
||||
const b = Buffer.from(expected)
|
||||
return a.length === b.length && crypto.timingSafeEqual(a, b)
|
||||
}
|
||||
|
||||
type DbxEvent = {
|
||||
event?: { event_type?: string; event_time?: string; event_hash?: string }
|
||||
signature_request?: { signature_request_id?: string }
|
||||
}
|
||||
|
||||
function parseBody(body: string): DbxEvent | null {
|
||||
try {
|
||||
// Dropbox Sign posts multipart form-data with a `json` field (or raw JSON).
|
||||
const m = body.match(/name="json"\r?\n\r?\n([\s\S]*?)\r?\n--/) ?? body.match(/^(\{[\s\S]*\})\s*$/)
|
||||
return JSON.parse(m ? m[1] : body)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export const dropboxSign: ESignAdapter = {
|
||||
id: "dropbox_sign",
|
||||
label: "Dropbox Sign",
|
||||
configured: () => Boolean(API_KEY),
|
||||
kind: "apikey",
|
||||
available: () => true, // landlord brings their own key; no operator setup required
|
||||
|
||||
async send({ document, documentName, signerEmail, signerName, subject, message }: SendParams) {
|
||||
getAuthUrl(): string {
|
||||
throw new Error("Dropbox Sign connects with an API key, not OAuth")
|
||||
},
|
||||
async exchangeCode(): Promise<ESignTokens> {
|
||||
throw new Error("Dropbox Sign connects with an API key, not OAuth")
|
||||
},
|
||||
async refresh(): Promise<ESignTokens> {
|
||||
throw new Error("Dropbox Sign API keys don't expire")
|
||||
},
|
||||
|
||||
async connectApiKey(apiKey): Promise<ESignTokens> {
|
||||
const key = apiKey.trim()
|
||||
if (!key) throw new Error("Enter your Dropbox Sign API key")
|
||||
const res = await fetch(`${BASE}/account`, { headers: { Authorization: authFor(key) } })
|
||||
if (res.status === 401 || res.status === 403) throw new Error("That API key was rejected by Dropbox Sign")
|
||||
if (!res.ok) throw new Error(`Dropbox Sign ${res.status}: could not validate the API key`)
|
||||
const j = (await res.json()) as { account?: { email_address?: string } }
|
||||
return {
|
||||
accessToken: key,
|
||||
refreshToken: null,
|
||||
expiresAt: null,
|
||||
accountId: null,
|
||||
baseUri: null,
|
||||
accountName: j.account?.email_address ?? "Dropbox Sign account",
|
||||
}
|
||||
},
|
||||
|
||||
async send(creds, p: SendParams) {
|
||||
const fd = new FormData()
|
||||
fd.append("subject", subject)
|
||||
fd.append("message", message)
|
||||
fd.append("subject", p.subject)
|
||||
fd.append("message", p.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)
|
||||
fd.append("signers[0][email_address]", p.signerEmail)
|
||||
fd.append("signers[0][name]", p.signerName)
|
||||
fd.append("file[0]", new Blob([new Uint8Array(p.document)], { type: "application/pdf" }), p.documentName)
|
||||
|
||||
const res = await fetch(`${BASE}/signature_request/send`, { method: "POST", headers: { Authorization: auth() }, body: fd })
|
||||
const res = await fetch(`${BASE}/signature_request/send`, { method: "POST", headers: { Authorization: authFor(creds.accessToken) }, 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
|
||||
@@ -32,22 +85,30 @@ export const dropboxSign: ESignAdapter = {
|
||||
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
|
||||
peekExternalId(body): string | null {
|
||||
return parseBody(body)?.signature_request?.signature_request_id ?? null
|
||||
},
|
||||
|
||||
async verifyAndGetStatus(creds, _externalId, body): Promise<"signed" | "declined" | "voided" | null> {
|
||||
const ev = parseBody(body)
|
||||
if (!ev || !verifyEventHash(creds.accessToken, ev.event)) return null
|
||||
switch (ev.event?.event_type) {
|
||||
case "signature_request_all_signed":
|
||||
return "signed"
|
||||
case "signature_request_declined":
|
||||
return "declined"
|
||||
case "signature_request_canceled":
|
||||
return "voided"
|
||||
default:
|
||||
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
|
||||
},
|
||||
|
||||
async getSignedDocument(creds, externalId): Promise<Buffer | null> {
|
||||
const res = await fetch(`${BASE}/signature_request/files/${externalId}?file_type=pdf`, {
|
||||
headers: { Authorization: authFor(creds.accessToken) },
|
||||
})
|
||||
if (!res.ok) return null
|
||||
return Buffer.from(await res.arrayBuffer())
|
||||
},
|
||||
}
|
||||
|
||||
+73
-30
@@ -1,42 +1,48 @@
|
||||
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"
|
||||
import { readFile, saveBuffer } from "@/lib/storage"
|
||||
import { getAdapter } from "./registry"
|
||||
import { resolveEsignCreds, markEsignError } from "./credentials"
|
||||
import type { ESignProvider } from "./types"
|
||||
|
||||
export type { ESignProvider } from "./types"
|
||||
export { getAdapter, listEsignAdapters } from "./registry"
|
||||
export {
|
||||
resolveEsignCreds,
|
||||
listEsignConnections,
|
||||
getEsignConnection,
|
||||
saveEsignConnection,
|
||||
disconnectEsign,
|
||||
} from "./credentials"
|
||||
|
||||
const ADAPTERS: Record<ESignProvider, ESignAdapter> = { docusign, dropbox_sign: dropboxSign }
|
||||
const FILES_PREFIX = "/api/files/"
|
||||
|
||||
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)
|
||||
/** The webhook URL a provider should call back — used for DocuSign envelope-level Connect. */
|
||||
function webhookUrl(provider: ESignProvider): string {
|
||||
const base = (process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000").replace(/\/+$/, "")
|
||||
return `${base}/api/esign/${provider}/webhook`
|
||||
}
|
||||
|
||||
/** Read the lease's stored document. Only /api/files keys are allowed (no SSRF). */
|
||||
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" }
|
||||
if (!documentUrl.startsWith(FILES_PREFIX)) {
|
||||
throw new Error("Lease document must be an uploaded file")
|
||||
}
|
||||
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" }
|
||||
const key = documentUrl.slice(FILES_PREFIX.length)
|
||||
return { bytes: await readFile(key), name: key.split("/").pop() ?? "lease.pdf" }
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a lease for signature through the owner's OWN connected account.
|
||||
* Requires the provider to be connected (per-user OAuth / API key).
|
||||
*/
|
||||
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 creds = await resolveEsignCreds(ownerId, provider)
|
||||
if (!creds) throw new Error(`Connect your ${adapter.label} account in Settings → Integrations first`)
|
||||
|
||||
const lease = await db.query.leases.findFirst({
|
||||
where: and(eq(leases.id, leaseId), eq(leases.user_id, ownerId)),
|
||||
@@ -51,13 +57,14 @@ export async function sendLeaseForSignature(ownerId: string, leaseId: string, pr
|
||||
const { bytes, name } = await getDocumentBytes(lease.document_url)
|
||||
|
||||
try {
|
||||
const { externalId } = await adapter.send({
|
||||
const { externalId } = await adapter.send(creds, {
|
||||
document: bytes,
|
||||
documentName: name,
|
||||
signerEmail: email,
|
||||
signerName,
|
||||
subject: "Please sign your lease agreement",
|
||||
message: "Your landlord has sent your lease agreement for electronic signature.",
|
||||
webhookUrl: webhookUrl(provider),
|
||||
})
|
||||
const [row] = await db
|
||||
.insert(signature_requests)
|
||||
@@ -66,6 +73,7 @@ export async function sendLeaseForSignature(ownerId: string, leaseId: string, pr
|
||||
return row
|
||||
} catch (e) {
|
||||
const msg = (e as Error).message.slice(0, 500)
|
||||
await markEsignError(ownerId, provider, msg)
|
||||
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 })
|
||||
@@ -80,18 +88,53 @@ export async function listRequestsForLease(ownerId: string, leaseId: string) {
|
||||
})
|
||||
}
|
||||
|
||||
/** Update a request's status from an inbound provider webhook. */
|
||||
/**
|
||||
* Process an inbound provider webhook. The body is UNTRUSTED: we use it only to
|
||||
* find which signature request (and therefore which owner + credentials) it
|
||||
* concerns, then authenticate the event via the adapter (DocuSign pull-verify /
|
||||
* Dropbox HMAC) before updating status and archiving the signed document.
|
||||
*/
|
||||
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
|
||||
|
||||
const externalId = adapter.peekExternalId(body)
|
||||
if (!externalId) return
|
||||
|
||||
const reqRow = await db.query.signature_requests.findFirst({
|
||||
where: eq(signature_requests.external_id, externalId),
|
||||
columns: { id: true, user_id: true, status: true },
|
||||
})
|
||||
if (!reqRow) return
|
||||
|
||||
const creds = await resolveEsignCreds(reqRow.user_id, provider as ESignProvider)
|
||||
if (!creds) return
|
||||
|
||||
const status = await adapter.verifyAndGetStatus(creds, externalId, body, headers)
|
||||
if (!status) return
|
||||
|
||||
await db
|
||||
.update(signature_requests)
|
||||
.set({
|
||||
status: result.status,
|
||||
completed_at: result.status === "signed" ? new Date().toISOString() : null,
|
||||
status,
|
||||
completed_at: status === "signed" ? new Date().toISOString() : null,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.where(eq(signature_requests.external_id, result.externalId))
|
||||
.where(eq(signature_requests.id, reqRow.id))
|
||||
|
||||
// Archive the executed document so the landlord can download the signed copy.
|
||||
if (status === "signed") {
|
||||
try {
|
||||
const bytes = await adapter.getSignedDocument(creds, externalId)
|
||||
if (bytes && bytes.length) {
|
||||
const { key } = await saveBuffer(bytes, { userId: reqRow.user_id, scope: "esign", ext: "pdf" })
|
||||
await db
|
||||
.update(signature_requests)
|
||||
.set({ signed_document_url: `${FILES_PREFIX}${key}` })
|
||||
.where(eq(signature_requests.id, reqRow.id))
|
||||
}
|
||||
} catch {
|
||||
// Best-effort — status is already recorded.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
import { docusign } from "./docusign"
|
||||
import { dropboxSign } from "./dropbox-sign"
|
||||
import type { ESignAdapter, ESignProvider } from "./types"
|
||||
|
||||
// Adapter registry — dependency-free (no db) so it can be imported anywhere,
|
||||
// including the credential resolver, without creating import cycles.
|
||||
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
|
||||
}
|
||||
|
||||
/** Providers the platform can offer, with their connect style + availability. */
|
||||
export function listEsignAdapters() {
|
||||
return (Object.keys(ADAPTERS) as ESignProvider[]).map((id) => ({
|
||||
id,
|
||||
label: ADAPTERS[id].label,
|
||||
kind: ADAPTERS[id].kind,
|
||||
available: ADAPTERS[id].available(),
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import crypto from "crypto"
|
||||
|
||||
// Signed OAuth `state` for the e-sign connect flow — carries the initiating
|
||||
// owner + provider + a random nonce (bound to a cookie by the connect route),
|
||||
// plus an issued-at so a leaked state can't be replayed. Mirrors the hardened
|
||||
// accounting OAuth state; fails closed if BETTER_AUTH_SECRET is missing.
|
||||
|
||||
const STATE_TTL_MS = 10 * 60 * 1000 // 10 minutes
|
||||
|
||||
export const ESIGN_NONCE_COOKIE = "esign_oauth_nonce"
|
||||
|
||||
function secret(): string {
|
||||
const s = process.env.BETTER_AUTH_SECRET
|
||||
if (!s) throw new Error("BETTER_AUTH_SECRET is not set — required to sign OAuth state")
|
||||
return s
|
||||
}
|
||||
|
||||
export type EsignOAuthState = { ownerId: string; provider: string; nonce: string }
|
||||
|
||||
export function signState(data: EsignOAuthState): string {
|
||||
const payload = Buffer.from(JSON.stringify({ ...data, iat: Date.now() })).toString("base64url")
|
||||
const sig = crypto.createHmac("sha256", secret()).update(payload).digest("base64url")
|
||||
return `${payload}.${sig}`
|
||||
}
|
||||
|
||||
export function verifyState(state: string): EsignOAuthState | null {
|
||||
const [payload, sig] = state.split(".")
|
||||
if (!payload || !sig) return null
|
||||
const expect = crypto.createHmac("sha256", secret()).update(payload).digest("base64url")
|
||||
if (sig.length !== expect.length || !crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expect))) return null
|
||||
try {
|
||||
const obj = JSON.parse(Buffer.from(payload, "base64url").toString("utf8")) as EsignOAuthState & { iat?: number }
|
||||
if (!obj.iat || Date.now() - obj.iat > STATE_TTL_MS) return null
|
||||
if (!obj.ownerId || !obj.provider || !obj.nonce) return null
|
||||
return { ownerId: obj.ownerId, provider: obj.provider, nonce: obj.nonce }
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
+67
-11
@@ -1,5 +1,27 @@
|
||||
export type ESignProvider = "docusign" | "dropbox_sign"
|
||||
|
||||
export type ESignStatus = "signed" | "declined" | "voided"
|
||||
|
||||
/** Result of connecting an account (OAuth exchange or API-key validation). */
|
||||
export interface ESignTokens {
|
||||
accessToken: string
|
||||
refreshToken: string | null
|
||||
/** ISO expiry of the access token, or null (API keys don't expire). */
|
||||
expiresAt: string | null
|
||||
accountId: string | null
|
||||
baseUri: string | null
|
||||
accountName: string | null
|
||||
}
|
||||
|
||||
/** Decrypted, ready-to-use credentials for a single connected account. */
|
||||
export interface ESignCredentials {
|
||||
provider: ESignProvider
|
||||
accessToken: string
|
||||
refreshToken?: string | null
|
||||
accountId?: string | null
|
||||
baseUri?: string | null
|
||||
}
|
||||
|
||||
export interface SendParams {
|
||||
document: Buffer
|
||||
documentName: string
|
||||
@@ -7,20 +29,54 @@ export interface SendParams {
|
||||
signerName: string
|
||||
subject: string
|
||||
message: string
|
||||
}
|
||||
|
||||
export interface WebhookResult {
|
||||
externalId: string
|
||||
status: "signed" | "declined" | "voided"
|
||||
/** Our callback the provider should ping on status changes (DocuSign envelope-level Connect). */
|
||||
webhookUrl: string
|
||||
}
|
||||
|
||||
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
|
||||
/** "oauth" → connect via redirect; "apikey" → connect by pasting a key. */
|
||||
kind: "oauth" | "apikey"
|
||||
/**
|
||||
* True when the platform can offer this provider. OAuth providers need the
|
||||
* operator's app credentials (client id/secret); API-key providers are always
|
||||
* available because the landlord brings their own key.
|
||||
*/
|
||||
available(): boolean
|
||||
|
||||
// ── OAuth providers (DocuSign) ────────────────────────────────────────────
|
||||
getAuthUrl(state: string): string
|
||||
exchangeCode(code: string): Promise<ESignTokens>
|
||||
refresh(refreshToken: string): Promise<ESignTokens>
|
||||
|
||||
// ── API-key providers (Dropbox Sign) ──────────────────────────────────────
|
||||
/** Validate a pasted API key and return a token set to store. */
|
||||
connectApiKey(apiKey: string): Promise<ESignTokens>
|
||||
|
||||
// ── Common ────────────────────────────────────────────────────────────────
|
||||
/** Send a document for signature; returns the provider's envelope/request id. */
|
||||
send(creds: ESignCredentials, params: SendParams): Promise<{ externalId: string }>
|
||||
/** Extract the external id from an inbound (still UNVERIFIED) webhook body, for owner lookup. */
|
||||
peekExternalId(body: string): string | null
|
||||
/**
|
||||
* Authenticate an inbound webhook and return the authoritative status.
|
||||
* DocuSign pull-verifies by fetching the envelope with the owner's token;
|
||||
* Dropbox Sign HMAC-verifies the body with the account API key. Returns null
|
||||
* if the event isn't authentic or isn't a terminal status we track.
|
||||
*/
|
||||
verifyAndGetStatus(
|
||||
creds: ESignCredentials,
|
||||
externalId: string,
|
||||
body: string,
|
||||
headers: Headers
|
||||
): Promise<ESignStatus | null>
|
||||
/** Download the completed/executed document, or null if unavailable. */
|
||||
getSignedDocument(creds: ESignCredentials, externalId: string): Promise<Buffer | null>
|
||||
}
|
||||
|
||||
/** The OAuth callback URL for a provider (must match what's registered in the provider app). */
|
||||
export function esignRedirectUri(provider: ESignProvider): string {
|
||||
const base = (process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000").replace(/\/+$/, "")
|
||||
return `${base}/api/esign/${provider}/callback`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user