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:
Leon Serfaty
2026-07-02 13:42:34 -04:00
co-authored by Claude Opus 4.8
parent 969d5d4c8a
commit c9968531e4
282 changed files with 41530 additions and 4013 deletions
+142
View File
@@ -0,0 +1,142 @@
import { and, eq, gt } from "drizzle-orm"
import { db } from "@/lib/db"
import { accounting_connections, rent_payments, expenses } from "@/lib/db/schema"
import { encrypt, decrypt } from "@/lib/crypto"
import { quickbooks } from "./quickbooks"
import { xero } from "./xero"
import type { AccountingProvider, OAuthTokens, Provider } from "./types"
export type { Provider } from "./types"
const PROVIDERS: Record<Provider, AccountingProvider> = { quickbooks, xero }
export function getProvider(id: string): AccountingProvider | null {
return id === "quickbooks" || id === "xero" ? PROVIDERS[id] : null
}
export function listProviders() {
return (Object.keys(PROVIDERS) as Provider[]).map((id) => ({
id,
label: PROVIDERS[id].label,
configured: PROVIDERS[id].configured(),
}))
}
/** Upsert an encrypted connection for (owner, provider). */
export async function saveConnection(ownerId: string, provider: Provider, tokens: OAuthTokens) {
const values = {
user_id: ownerId,
provider,
access_token: encrypt(tokens.accessToken),
refresh_token: encrypt(tokens.refreshToken),
expires_at: tokens.expiresAt,
realm_id: tokens.realmId,
org_name: tokens.orgName,
status: "active" as const,
last_error: null,
}
const existing = await db.query.accounting_connections.findFirst({
where: and(eq(accounting_connections.user_id, ownerId), eq(accounting_connections.provider, provider)),
columns: { id: true },
})
if (existing) {
await db.update(accounting_connections).set({ ...values, updated_at: new Date().toISOString() }).where(eq(accounting_connections.id, existing.id))
} else {
await db.insert(accounting_connections).values(values)
}
}
export async function getConnection(ownerId: string, provider: Provider) {
return db.query.accounting_connections.findFirst({
where: and(eq(accounting_connections.user_id, ownerId), eq(accounting_connections.provider, provider)),
})
}
export async function listConnections(ownerId: string) {
const rows = await db.query.accounting_connections.findMany({ where: eq(accounting_connections.user_id, ownerId) })
// Never leak tokens to callers.
return rows.map((r) => ({ provider: r.provider, orgName: r.org_name, status: r.status, lastSyncAt: r.last_sync_at, lastError: r.last_error }))
}
export async function disconnect(ownerId: string, provider: Provider) {
await db.delete(accounting_connections).where(and(eq(accounting_connections.user_id, ownerId), eq(accounting_connections.provider, provider)))
}
/** Load tokens, refreshing (and persisting) if the access token is near expiry. */
async function getValidTokens(ownerId: string, provider: Provider): Promise<OAuthTokens | null> {
const conn = await getConnection(ownerId, provider)
if (!conn) return null
let tokens: OAuthTokens = {
accessToken: decrypt(conn.access_token),
refreshToken: decrypt(conn.refresh_token),
expiresAt: conn.expires_at,
realmId: conn.realm_id,
orgName: conn.org_name,
}
const nearExpiry = conn.expires_at && new Date(conn.expires_at).getTime() - Date.now() < 60_000
if (nearExpiry) {
tokens = await getProvider(provider)!.refresh(tokens)
await saveConnection(ownerId, provider, tokens)
}
return tokens
}
/**
* One-way sync: push rent income + expenses created since the last sync into the
* connected provider. Uses a created_at watermark for idempotency (records synced
* once are not re-pushed).
*/
export async function syncNow(ownerId: string, provider: Provider): Promise<{ income: number; expense: number }> {
const prov = getProvider(provider)
if (!prov) throw new Error("Unknown provider")
const conn = await getConnection(ownerId, provider)
if (!conn) throw new Error("Not connected")
try {
const tokens = (await getValidTokens(ownerId, provider))!
const since = conn.last_sync_at ?? "1970-01-01T00:00:00.000Z"
const paid = await db.query.rent_payments.findMany({
where: and(eq(rent_payments.user_id, ownerId), eq(rent_payments.status, "paid"), gt(rent_payments.created_at, since)),
columns: { id: true, amount: true, paid_date: true, due_date: true, tenant_id: true },
with: { tenant: { columns: { first_name: true, last_name: true } } },
})
const exp = await db.query.expenses.findMany({
where: and(eq(expenses.user_id, ownerId), gt(expenses.created_at, since)),
columns: { id: true, amount: true, expense_date: true, category: true, vendor: true, description: true },
})
const income = await prov.pushIncome(
tokens,
paid.map((p) => ({
externalId: p.id,
date: (p.paid_date ?? p.due_date ?? "").slice(0, 10),
amount: Number(p.amount),
customerName: `${p.tenant?.first_name ?? ""} ${p.tenant?.last_name ?? ""}`.trim() || "Tenant",
description: `Rent payment`,
}))
)
const expense = await prov.pushExpense(
tokens,
exp.map((e) => ({
externalId: e.id,
date: (e.expense_date ?? "").slice(0, 10),
amount: Number(e.amount),
category: e.category ?? "General",
vendor: e.vendor ?? null,
description: e.description ?? e.category ?? "Expense",
}))
)
await db
.update(accounting_connections)
.set({ last_sync_at: new Date().toISOString(), status: "active", last_error: null, updated_at: new Date().toISOString() })
.where(eq(accounting_connections.id, conn.id))
return { income: income.synced, expense: expense.synced }
} catch (e) {
const msg = (e as Error).message.slice(0, 500)
await db.update(accounting_connections).set({ status: "error", last_error: msg, updated_at: new Date().toISOString() }).where(eq(accounting_connections.id, conn.id))
throw new Error(msg)
}
}
+127
View File
@@ -0,0 +1,127 @@
import type { AccountingProvider, OAuthTokens, IncomeEntry, ExpenseEntry } from "./types"
import { redirectUri } from "./types"
// QuickBooks Online. Docs: https://developer.intuit.com/app/developer/qbo/docs/develop
const CLIENT_ID = process.env.QBO_CLIENT_ID ?? ""
const CLIENT_SECRET = process.env.QBO_CLIENT_SECRET ?? ""
const ENV = (process.env.QBO_ENVIRONMENT ?? "sandbox").toLowerCase()
const API_BASE = ENV === "production" ? "https://quickbooks.api.intuit.com" : "https://sandbox-quickbooks.api.intuit.com"
const TOKEN_URL = "https://oauth.platform.intuit.com/oauth2/v1/tokens/bearer"
const MINOR = "73"
function basicAuth() {
return "Basic " + Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64")
}
async function tokenRequest(form: Record<string, string>): Promise<OAuthTokens> {
const res = await fetch(TOKEN_URL, {
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(`QuickBooks token error ${res.status}: ${await res.text()}`)
const j = (await res.json()) as { access_token: string; refresh_token: string; expires_in: number }
return {
accessToken: j.access_token,
refreshToken: j.refresh_token,
expiresAt: new Date(Date.now() + j.expires_in * 1000).toISOString(),
realmId: null,
orgName: null,
}
}
async function api(tokens: OAuthTokens, path: string, init?: RequestInit) {
const res = await fetch(`${API_BASE}/v3/company/${tokens.realmId}/${path}${path.includes("?") ? "&" : "?"}minorversion=${MINOR}`, {
...init,
headers: { Authorization: `Bearer ${tokens.accessToken}`, Accept: "application/json", "Content-Type": "application/json", ...(init?.headers ?? {}) },
})
if (!res.ok) throw new Error(`QuickBooks API ${res.status}: ${(await res.text()).slice(0, 300)}`)
return res.json()
}
async function query<T = Record<string, unknown>>(tokens: OAuthTokens, q: string): Promise<T[]> {
const j = await api(tokens, `query?query=${encodeURIComponent(q)}`)
const key = Object.keys(j.QueryResponse ?? {}).find((k) => Array.isArray(j.QueryResponse[k]))
return key ? j.QueryResponse[key] : []
}
export const quickbooks: AccountingProvider = {
id: "quickbooks",
label: "QuickBooks Online",
configured: () => Boolean(CLIENT_ID && CLIENT_SECRET),
getAuthUrl(state) {
const p = new URLSearchParams({
client_id: CLIENT_ID,
response_type: "code",
scope: "com.intuit.quickbooks.accounting",
redirect_uri: redirectUri("quickbooks"),
state,
})
return `https://appcenter.intuit.com/connect/oauth2?${p.toString()}`
},
async exchangeCode(code, realmId) {
const tokens = await tokenRequest({ grant_type: "authorization_code", code, redirect_uri: redirectUri("quickbooks") })
tokens.realmId = realmId ?? null
try {
const info = await api(tokens, "companyinfo/" + tokens.realmId)
tokens.orgName = info?.CompanyInfo?.CompanyName ?? null
} catch {
/* name is best-effort */
}
return tokens
},
async refresh(tokens) {
const next = await tokenRequest({ grant_type: "refresh_token", refresh_token: tokens.refreshToken })
next.realmId = tokens.realmId
next.orgName = tokens.orgName
return next
},
async pushIncome(tokens, entries) {
if (!entries.length) return { synced: 0 }
// Post each rent payment as a SalesReceipt against the first Service item.
const items = await query<{ Id: string }>(tokens, "SELECT Id FROM Item WHERE Type='Service' MAXRESULTS 1")
const itemRef = items[0]?.Id
if (!itemRef) throw new Error("No QuickBooks Service item found to record income against")
let synced = 0
for (const e of entries) {
await api(tokens, "salesreceipt", {
method: "POST",
body: JSON.stringify({
TxnDate: e.date,
PrivateNote: `${e.description} [pmn:${e.externalId}]`,
Line: [{ Amount: e.amount, DetailType: "SalesItemLineDetail", Description: e.description, SalesItemLineDetail: { ItemRef: { value: itemRef }, Qty: 1, UnitPrice: e.amount } }],
}),
})
synced++
}
return { synced }
},
async pushExpense(tokens, entries) {
if (!entries.length) return { synced: 0 }
const banks = await query<{ Id: string }>(tokens, "SELECT Id FROM Account WHERE AccountType='Bank' MAXRESULTS 1")
const expenses = await query<{ Id: string }>(tokens, "SELECT Id FROM Account WHERE AccountType='Expense' MAXRESULTS 1")
const bankRef = banks[0]?.Id
const expRef = expenses[0]?.Id
if (!bankRef || !expRef) throw new Error("No QuickBooks Bank/Expense account found to record expenses against")
let synced = 0
for (const e of entries) {
await api(tokens, "purchase", {
method: "POST",
body: JSON.stringify({
PaymentType: "Cash",
AccountRef: { value: bankRef },
TxnDate: e.date,
PrivateNote: `${e.description} [pmn:${e.externalId}]`,
Line: [{ Amount: e.amount, DetailType: "AccountBasedExpenseLineDetail", Description: e.description, AccountBasedExpenseLineDetail: { AccountRef: { value: expRef } } }],
}),
})
synced++
}
return { synced }
},
}
+23
View File
@@ -0,0 +1,23 @@
import crypto from "crypto"
// Signed OAuth `state` (HMAC-SHA256) — carries the initiating owner + provider
// and is tamper-proof, so the callback can't be forged/CSRF'd.
const SECRET = process.env.BETTER_AUTH_SECRET ?? "dev-secret"
export function signState(data: { ownerId: string; provider: string }): string {
const payload = Buffer.from(JSON.stringify(data)).toString("base64url")
const sig = crypto.createHmac("sha256", SECRET).update(payload).digest("base64url")
return `${payload}.${sig}`
}
export function verifyState(state: string): { ownerId: string; provider: string } | 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 {
return JSON.parse(Buffer.from(payload, "base64url").toString("utf8"))
} catch {
return null
}
}
+50
View File
@@ -0,0 +1,50 @@
export type Provider = "quickbooks" | "xero"
export interface OAuthTokens {
accessToken: string
refreshToken: string
/** ISO timestamp of access-token expiry, or null. */
expiresAt: string | null
/** QuickBooks realmId / Xero tenantId. */
realmId: string | null
orgName: string | null
}
export interface IncomeEntry {
externalId: string
date: string // YYYY-MM-DD
amount: number
customerName: string
description: string
}
export interface ExpenseEntry {
externalId: string
date: string // YYYY-MM-DD
amount: number
category: string
vendor: string | null
description: string
}
export interface AccountingProvider {
id: Provider
label: string
/** True when the provider's OAuth app credentials are configured in env. */
configured(): boolean
/** Build the provider's OAuth authorize URL. */
getAuthUrl(state: string): string
/** Exchange an authorization code for tokens. `realmId` is QBO's callback param. */
exchangeCode(code: string, realmId?: string | null): Promise<OAuthTokens>
/** Refresh an expired access token; returns the new token set (refresh token may rotate). */
refresh(tokens: OAuthTokens): Promise<OAuthTokens>
/** Push income (rent) into the books. Returns how many were written. */
pushIncome(tokens: OAuthTokens, entries: IncomeEntry[]): Promise<{ synced: number }>
/** Push expenses into the books. */
pushExpense(tokens: OAuthTokens, entries: ExpenseEntry[]): Promise<{ synced: number }>
}
export function redirectUri(provider: Provider): string {
const base = (process.env.NEXT_PUBLIC_APP_URL ?? "http://localhost:3000").replace(/\/+$/, "")
return `${base}/api/integrations/${provider}/callback`
}
+114
View File
@@ -0,0 +1,114 @@
import type { AccountingProvider, OAuthTokens, IncomeEntry, ExpenseEntry } from "./types"
import { redirectUri } from "./types"
// Xero. Docs: https://developer.xero.com/documentation/guides/oauth2/
const CLIENT_ID = process.env.XERO_CLIENT_ID ?? ""
const CLIENT_SECRET = process.env.XERO_CLIENT_SECRET ?? ""
const TOKEN_URL = "https://identity.xero.com/connect/token"
const API_BASE = "https://api.xero.com/api.xro/2.0"
const SCOPES = "openid profile email accounting.transactions accounting.contacts offline_access"
// Standard default account codes (present in most Xero orgs).
const SALES_CODE = process.env.XERO_SALES_ACCOUNT_CODE ?? "200"
const EXPENSE_CODE = process.env.XERO_EXPENSE_ACCOUNT_CODE ?? "400"
function basicAuth() {
return "Basic " + Buffer.from(`${CLIENT_ID}:${CLIENT_SECRET}`).toString("base64")
}
async function tokenRequest(form: Record<string, string>): Promise<OAuthTokens> {
const res = await fetch(TOKEN_URL, {
method: "POST",
headers: { Authorization: basicAuth(), "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams(form),
})
if (!res.ok) throw new Error(`Xero token error ${res.status}: ${await res.text()}`)
const j = (await res.json()) as { access_token: string; refresh_token: string; expires_in: number }
return {
accessToken: j.access_token,
refreshToken: j.refresh_token,
expiresAt: new Date(Date.now() + j.expires_in * 1000).toISOString(),
realmId: null,
orgName: null,
}
}
async function api(tokens: OAuthTokens, path: string, init?: RequestInit) {
const res = await fetch(`${API_BASE}/${path}`, {
...init,
headers: {
Authorization: `Bearer ${tokens.accessToken}`,
"Xero-tenant-id": tokens.realmId ?? "",
Accept: "application/json",
"Content-Type": "application/json",
...(init?.headers ?? {}),
},
})
if (!res.ok) throw new Error(`Xero API ${res.status}: ${(await res.text()).slice(0, 300)}`)
return res.json()
}
export const xero: AccountingProvider = {
id: "xero",
label: "Xero",
configured: () => Boolean(CLIENT_ID && CLIENT_SECRET),
getAuthUrl(state) {
const p = new URLSearchParams({
response_type: "code",
client_id: CLIENT_ID,
redirect_uri: redirectUri("xero"),
scope: SCOPES,
state,
})
return `https://login.xero.com/identity/connect/authorize?${p.toString()}`
},
async exchangeCode(code) {
const tokens = await tokenRequest({ grant_type: "authorization_code", code, redirect_uri: redirectUri("xero") })
// Resolve the connected organisation (tenant).
const res = await fetch("https://api.xero.com/connections", { headers: { Authorization: `Bearer ${tokens.accessToken}`, Accept: "application/json" } })
const conns = (await res.json()) as Array<{ tenantId: string; tenantName: string }>
tokens.realmId = conns[0]?.tenantId ?? null
tokens.orgName = conns[0]?.tenantName ?? null
return tokens
},
async refresh(tokens) {
const next = await tokenRequest({ grant_type: "refresh_token", refresh_token: tokens.refreshToken })
next.realmId = tokens.realmId
next.orgName = tokens.orgName
return next
},
async pushIncome(tokens, entries) {
if (!entries.length) return { synced: 0 }
// ACCREC invoices (sales) against the standard sales account.
const invoices = entries.map((e) => ({
Type: "ACCREC",
Contact: { Name: e.customerName },
Date: e.date,
DueDate: e.date,
Status: "AUTHORISED",
Reference: `pmn:${e.externalId}`,
LineItems: [{ Description: e.description, Quantity: 1, UnitAmount: e.amount, AccountCode: SALES_CODE }],
}))
await api(tokens, "Invoices", { method: "POST", body: JSON.stringify({ Invoices: invoices }) })
return { synced: entries.length }
},
async pushExpense(tokens, entries) {
if (!entries.length) return { synced: 0 }
// ACCPAY invoices (bills) against the standard expense account.
const invoices = entries.map((e) => ({
Type: "ACCPAY",
Contact: { Name: e.vendor || "Expense" },
Date: e.date,
DueDate: e.date,
Status: "AUTHORISED",
Reference: `pmn:${e.externalId}`,
LineItems: [{ Description: e.description, Quantity: 1, UnitAmount: e.amount, AccountCode: EXPENSE_CODE }],
}))
await api(tokens, "Invoices", { method: "POST", body: JSON.stringify({ Invoices: invoices }) })
return { synced: entries.length }
},
}