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
+89
View File
@@ -0,0 +1,89 @@
// Backfill latitude/longitude for existing properties that predate geocoding.
//
// node scripts/backfill-geocode.mjs # dev (uses DATABASE_URL)
// node scripts/backfill-geocode.mjs --prod # prod (uses PROD_DATABASE_URL)
//
// Geocodes every property missing coordinates via OpenStreetMap Nominatim,
// throttled to 1 request/second per Nominatim's usage policy. Safe to re-run —
// it only touches rows where latitude or longitude is null.
import { readFileSync } from "node:fs"
import { config } from "dotenv"
import pg from "pg"
config({ path: ".env.local", quiet: true })
const useProd = process.argv.includes("--prod")
const url = useProd ? process.env.PROD_DATABASE_URL : process.env.DATABASE_URL
if (!url) {
console.error(`[backfill-geocode] ${useProd ? "PROD_DATABASE_URL" : "DATABASE_URL"} is not set.`)
process.exit(1)
}
function resolveSsl() {
if (useProd) {
try {
return { rejectUnauthorized: true, ca: readFileSync("ca-certificate.crt", "utf8") }
} catch {
return { rejectUnauthorized: false }
}
}
return process.env.DATABASE_SSL === "disable" ? false : { rejectUnauthorized: false }
}
const USER_AGENT =
process.env.GEOCODER_USER_AGENT ||
`PropertyManagementNetwork/1.0 (${process.env.NEXT_PUBLIC_APP_URL || "https://propertymanagement.network"})`
const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
async function geocode(a) {
if (!a.address_line1 && !a.city && !a.postal_code) return null
const street = [a.address_line1, a.address_line2].filter(Boolean).join(" ").trim()
const params = new URLSearchParams({ format: "jsonv2", limit: "1" })
if (street) params.set("street", street)
if (a.city) params.set("city", a.city)
if (a.state) params.set("state", a.state)
if (a.postal_code) params.set("postalcode", a.postal_code)
params.set("country", a.country || "US")
try {
const res = await fetch(`https://nominatim.openstreetmap.org/search?${params.toString()}`, {
headers: { "User-Agent": USER_AGENT, Accept: "application/json" },
})
if (!res.ok) return null
const json = await res.json()
const first = Array.isArray(json) ? json[0] : null
if (!first) return null
const lat = Number(first.lat)
const lon = Number(first.lon)
return Number.isFinite(lat) && Number.isFinite(lon) ? { lat, lon } : null
} catch {
return null
}
}
const pool = new pg.Pool({ connectionString: url, ssl: resolveSsl() })
const { rows } = await pool.query(
`select id, name, address_line1, address_line2, city, state, postal_code, country
from properties
where latitude is null or longitude is null`
)
console.log(`[backfill-geocode] ${rows.length} propert${rows.length === 1 ? "y" : "ies"} to geocode (${useProd ? "PROD" : "dev"})`)
let ok = 0
for (const r of rows) {
const g = await geocode(r)
const label = r.name || r.address_line1 || r.city || r.id
if (g) {
await pool.query(`update properties set latitude = $1, longitude = $2 where id = $3`, [g.lat, g.lon, r.id])
ok++
console.log(`${label}${g.lat.toFixed(4)}, ${g.lon.toFixed(4)}`)
} else {
console.log(` · ${label}: no match`)
}
await sleep(1100) // Nominatim: max 1 request/second
}
console.log(`[backfill-geocode] done — ${ok}/${rows.length} geocoded`)
await pool.end()
+68
View File
@@ -0,0 +1,68 @@
// One-off production migration runner (DigitalOcean Managed Postgres).
//
// Applies any pending Drizzle migrations in lib/db/migrations to the production
// database, using the doadmin connection. Drizzle tracks what's already applied
// in the __drizzle_migrations table, so this only runs the missing migrations
// and is safe to re-run.
//
// The connection string is read from PROD_DATABASE_URL so the credential never
// has to be typed on the command line. Set it in .env.local (git-ignored):
//
// PROD_DATABASE_URL=postgresql://doadmin:<pw>@<cluster>...:25060/<app-db>
//
// Then run: node scripts/migrate-prod.mjs
//
// TLS: verified against ca-certificate.crt when present, otherwise encrypted
// but unverified. Uses the direct port (25060), NOT the 25061 pooler — the
// migrator needs a real session (transactions + advisory locks).
import { readFileSync } from "node:fs"
import { config } from "dotenv"
import { drizzle } from "drizzle-orm/node-postgres"
import { migrate } from "drizzle-orm/node-postgres/migrator"
import pg from "pg"
config({ path: ".env.local", quiet: true })
const url = process.env.PROD_DATABASE_URL
if (!url) {
console.error(
"[migrate-prod] PROD_DATABASE_URL is not set. Add the doadmin connection " +
"string (port 25060, app database) to .env.local and re-run."
)
process.exit(1)
}
if (url.includes(":25061")) {
console.error(
"[migrate-prod] Refusing to run migrations through the 25061 connection " +
"pooler. Use the direct port 25060."
)
process.exit(1)
}
let ssl
try {
const ca = readFileSync("ca-certificate.crt", "utf8")
ssl = { rejectUnauthorized: true, ca }
console.log("[migrate-prod] TLS: verifying against ca-certificate.crt")
} catch {
ssl = { rejectUnauthorized: false }
console.log("[migrate-prod] TLS: encrypted (certificate not verified)")
}
const { Pool } = pg
const pool = new Pool({ connectionString: url, ssl })
const db = drizzle(pool)
try {
const host = new URL(url).host
console.log(`[migrate-prod] Connecting to ${host} ...`)
await migrate(db, { migrationsFolder: "./lib/db/migrations" })
console.log("[migrate-prod] Migrations applied successfully.")
await pool.end()
process.exit(0)
} catch (err) {
console.error(`[migrate-prod] Failed: ${err?.message ?? err}`)
await pool.end().catch(() => {})
process.exit(1)
}
+95
View File
@@ -0,0 +1,95 @@
// One-off migration: copy every file under STORAGE_DIR (local disk) into the
// DigitalOcean Spaces bucket, preserving the exact key path so existing
// /api/files/<key> URLs in the database keep resolving after the switch.
//
// Usage (from the project root):
// node scripts/migrate-storage-to-spaces.mjs # copy missing files
// FORCE=1 node scripts/migrate-storage-to-spaces.mjs # overwrite existing
//
// Run it wherever the source files live (a machine/container with STORAGE_DIR
// populated and the SPACES_* env vars configured).
import { config } from "dotenv"
config({ path: ".env.local" })
import { promises as fs } from "fs"
import path from "path"
import {
S3Client,
PutObjectCommand,
HeadObjectCommand,
} from "@aws-sdk/client-s3"
const STORAGE_DIR = path.resolve(process.cwd(), process.env.STORAGE_DIR ?? "./storage")
const bucket = process.env.SPACES_BUCKET
const force = process.env.FORCE === "1"
if (!process.env.SPACES_KEY || !process.env.SPACES_SECRET || !bucket) {
console.error("Missing SPACES_KEY / SPACES_SECRET / SPACES_BUCKET — aborting.")
process.exit(1)
}
const MIME = {
pdf: "application/pdf", png: "image/png", jpg: "image/jpeg", jpeg: "image/jpeg",
gif: "image/gif", webp: "image/webp", doc: "application/msword",
docx: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
xls: "application/vnd.ms-excel",
xlsx: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
csv: "text/csv", txt: "text/plain",
}
const ctype = (k) => MIME[k.split(".").pop()?.toLowerCase() ?? ""] ?? "application/octet-stream"
const s3 = new S3Client({
region: process.env.SPACES_REGION || "us-east-1",
endpoint: process.env.SPACES_ENDPOINT,
forcePathStyle: false,
credentials: { accessKeyId: process.env.SPACES_KEY, secretAccessKey: process.env.SPACES_SECRET },
})
async function* walk(dir) {
let entries
try {
entries = await fs.readdir(dir, { withFileTypes: true })
} catch {
return
}
for (const e of entries) {
const full = path.join(dir, e.name)
if (e.isDirectory()) yield* walk(full)
else if (e.isFile()) yield full
}
}
async function existsInBucket(key) {
try {
await s3.send(new HeadObjectCommand({ Bucket: bucket, Key: key }))
return true
} catch {
return false
}
}
let migrated = 0, skipped = 0, failed = 0
console.log(`Source: ${STORAGE_DIR}`)
console.log(`Target: ${process.env.SPACES_ENDPOINT}/${bucket}${force ? " (FORCE overwrite)" : ""}\n`)
for await (const file of walk(STORAGE_DIR)) {
const key = path.relative(STORAGE_DIR, file).split(path.sep).join("/")
try {
if (!force && (await existsInBucket(key))) {
console.log(`skip ${key}`)
skipped++
continue
}
const body = await fs.readFile(file)
await s3.send(new PutObjectCommand({
Bucket: bucket, Key: key, Body: body, ContentType: ctype(key), ACL: "private",
}))
console.log(`upload ${key} (${body.length} bytes)`)
migrated++
} catch (e) {
console.error(`FAIL ${key} -> ${e.name}: ${e.message}`)
failed++
}
}
console.log(`\nDone. migrated=${migrated} skipped=${skipped} failed=${failed}`)
process.exit(failed > 0 ? 1 : 0)
+102
View File
@@ -0,0 +1,102 @@
// One-time PayPal setup: creates the product + the recurring billing plans and
// prints the plan IDs to paste into your environment.
//
// 1. Set PAYPAL_CLIENT_ID / PAYPAL_SECRET (and PAYPAL_ENVIRONMENT) in .env.local
// 2. node scripts/paypal-setup-plans.mjs
// 3. Copy the printed PAYPAL_*_PLAN_ID lines into .env.local / production env
//
// Amounts mirror the app's pricing (Pro $29/mo, Landlord $59/mo); yearly is
// billed at 10× monthly (~2 months free). Adjust in the PayPal dashboard if you
// want different annual pricing. Safe to re-run (it creates fresh plans).
import { config } from "dotenv"
config({ path: ".env.local", quiet: true })
const ENV = process.env.PAYPAL_ENVIRONMENT === "live" ? "live" : "sandbox"
const BASE = ENV === "live" ? "https://api-m.paypal.com" : "https://api-m.sandbox.paypal.com"
const id = process.env.PAYPAL_CLIENT_ID
const secret = process.env.PAYPAL_SECRET
if (!id || !secret) {
console.error("[paypal-setup] Set PAYPAL_CLIENT_ID and PAYPAL_SECRET in .env.local first.")
process.exit(1)
}
async function getToken() {
const r = await fetch(`${BASE}/v1/oauth2/token`, {
method: "POST",
headers: {
Authorization: `Basic ${Buffer.from(`${id}:${secret}`).toString("base64")}`,
"Content-Type": "application/x-www-form-urlencoded",
},
body: "grant_type=client_credentials",
})
if (!r.ok) throw new Error(`auth ${r.status}: ${await r.text()}`)
return (await r.json()).access_token
}
const token = await getToken()
const post = (path, body) =>
fetch(`${BASE}${path}`, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
"Content-Type": "application/json",
Prefer: "return=representation",
},
body: JSON.stringify(body),
})
console.log(`[paypal-setup] Environment: ${ENV}`)
const prodRes = await post("/v1/catalogs/products", {
name: "Property Management Network",
description: "Property Management Network subscription",
type: "SERVICE",
category: "SOFTWARE",
})
if (!prodRes.ok) {
console.error("[paypal-setup] product creation failed:", await prodRes.text())
process.exit(1)
}
const product = await prodRes.json()
console.log(`[paypal-setup] Product: ${product.id}`)
const AMOUNTS = { pro: 29, landlord: 59 }
const envLines = []
for (const plan of ["pro", "landlord"]) {
for (const interval of ["month", "year"]) {
const amount = interval === "month" ? AMOUNTS[plan] : AMOUNTS[plan] * 10
const res = await post("/v1/billing/plans", {
product_id: product.id,
name: `${plan[0].toUpperCase()}${plan.slice(1)} ${interval === "month" ? "Monthly" : "Yearly"}`,
status: "ACTIVE",
billing_cycles: [
{
frequency: { interval_unit: interval === "month" ? "MONTH" : "YEAR", interval_count: 1 },
tenure_type: "REGULAR",
sequence: 1,
total_cycles: 0,
pricing_scheme: { fixed_price: { value: amount.toFixed(2), currency_code: "USD" } },
},
],
payment_preferences: {
auto_bill_outstanding: true,
setup_fee_failure_action: "CONTINUE",
payment_failure_threshold: 2,
},
})
if (!res.ok) {
console.error(`[paypal-setup] plan ${plan}/${interval} failed:`, await res.text())
continue
}
const p = await res.json()
const key = `PAYPAL_${plan.toUpperCase()}_${interval === "month" ? "MONTHLY" : "YEARLY"}_PLAN_ID`
console.log(` ${plan}/${interval} $${amount}${p.id}`)
envLines.push(`${key}=${p.id}`)
}
}
console.log("\n[paypal-setup] Add these to your environment:\n")
console.log(envLines.join("\n"))
+66
View File
@@ -0,0 +1,66 @@
// OPTIONAL pre-provisioning: creates the products + prices with stable lookup
// keys. You don't strictly need to run this — the app auto-creates any missing
// price on first checkout (see lib/stripe/prices.ts) — but running it once in
// live mode pre-creates the catalog so the first real checkout is instant.
//
// 1. Set STRIPE_SECRET_KEY in .env.local (sk_test_... to start, sk_live_... for prod)
// 2. node scripts/stripe-setup.mjs
//
// There are NO price-ID env vars to copy — prices are resolved by lookup key,
// so going live is just an API-key swap. Amounts mirror lib/stripe/plans.ts
// (Pro $29/mo, Landlord $59/mo, Lifetime $199 one-time; yearly = 10× monthly).
import { config } from "dotenv"
import Stripe from "stripe"
config({ path: ".env.local", quiet: true })
const key = process.env.STRIPE_SECRET_KEY
if (!key) {
console.error("[stripe-setup] Set STRIPE_SECRET_KEY in .env.local first.")
process.exit(1)
}
const stripe = new Stripe(key)
const live = key.startsWith("sk_live")
console.log(`[stripe-setup] mode: ${live ? "LIVE" : "test"}`)
const AMOUNTS = { pro: 29, landlord: 59, lifetime: 199 }
const envLines = []
async function makeProduct(name) {
const p = await stripe.products.create({ name: `Property Management Network — ${name}` })
return p.id
}
async function makePrice(product, dollars, interval /* "month" | "year" | null */, lookupKey) {
const p = await stripe.prices.create({
product,
currency: "usd",
unit_amount: Math.round(dollars * 100),
lookup_key: lookupKey,
transfer_lookup_key: true,
...(interval ? { recurring: { interval } } : {}),
})
return p.id
}
// Pro (monthly + yearly)
const proProduct = await makeProduct("Pro")
envLines.push(`pmn_pro_monthly → ${await makePrice(proProduct, AMOUNTS.pro, "month", "pmn_pro_monthly")}`)
envLines.push(`pmn_pro_yearly → ${await makePrice(proProduct, AMOUNTS.pro * 10, "year", "pmn_pro_yearly")}`)
console.log(` Pro product: ${proProduct}`)
// Landlord (monthly + yearly)
const landlordProduct = await makeProduct("Landlord")
envLines.push(`pmn_landlord_monthly → ${await makePrice(landlordProduct, AMOUNTS.landlord, "month", "pmn_landlord_monthly")}`)
envLines.push(`pmn_landlord_yearly → ${await makePrice(landlordProduct, AMOUNTS.landlord * 10, "year", "pmn_landlord_yearly")}`)
console.log(` Landlord product: ${landlordProduct}`)
// Lifetime (one-time)
const lifetimeProduct = await makeProduct("Lifetime")
envLines.push(`pmn_lifetime → ${await makePrice(lifetimeProduct, AMOUNTS.lifetime, null, "pmn_lifetime")}`)
console.log(` Lifetime product: ${lifetimeProduct}`)
console.log("\n[stripe-setup] Created prices with lookup keys (below). NO env vars")
console.log("needed — the app resolves prices by lookup key, so going live is just")
console.log("a key swap. Running this in live mode pre-creates the same keys there.\n")
console.log(envLines.join("\n"))