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:
co-authored by
Claude Opus 4.8
parent
969d5d4c8a
commit
c9968531e4
@@ -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()
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
@@ -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"))
|
||||
@@ -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"))
|
||||
Reference in New Issue
Block a user