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