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
+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.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user