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
+182
View File
@@ -0,0 +1,182 @@
# Deploying Property Management Network on DigitalOcean App Platform
This app runs as a single Next.js 16 (standalone) container. On App Platform it needs:
- a **Managed PostgreSQL** database,
- **DigitalOcean Spaces** (S3-compatible) for uploads — App Platform containers are
**ephemeral**, so local-disk storage would be wiped on every deploy (the app already
uses Spaces; see `SPACES_*` env vars),
- **DigitalOcean Functions** for the two daily cron jobs (App Platform has no native cron),
- a few third-party keys/credentials (Stripe, OpenAI, SMTP/SMTP2GO, Cloudflare Turnstile).
Because the source repo lives on self-hosted **Gitea** (which App Platform can't pull),
the app is deployed as a **pre-built image from DigitalOcean Container Registry (DOCR)**.
---
## 0. Prerequisites
- `doctl` installed and authenticated (`doctl auth init`)
- A DOCR registry: `doctl registry create <your-registry>` (once)
- A Managed PostgreSQL cluster (see step 1)
- The Space `property-management-network` in `nyc3` with CDN enabled (already set up)
### Optional: manage this app with the DigitalOcean MCP server
For natural-language management of App Platform, the database cluster, and Spaces
from Claude Code (deploy status, logs, env vars), register the DigitalOcean MCP
server. It's a **management convenience only** — deploys still go through DOCR +
`doctl`, migrations through `scripts/migrate-prod.mjs`, and cron through DO Functions.
1. Create a **scoped** DO API token (console → **API → Tokens**) limited to the
resources used here: App Platform (read + write), Databases (read), Spaces
(read). Do **not** use a full-access token.
2. Register it locally — the token is stored in `~/.claude.json`, never in the repo:
```bash
claude mcp add digitalocean --scope local \
-e DIGITALOCEAN_API_TOKEN=<scoped-token> \
-- npx -y @digitalocean/mcp --services apps,databases,spaces
```
3. Reconnect the Claude Code session (`/mcp`); `claude mcp list` should then show
`digitalocean` connected. Remove anytime with `claude mcp remove digitalocean`.
---
## 1. Database
Create a **Managed PostgreSQL** cluster and a database (e.g. `propertymanagementnetwork`).
**Privileges:** the migrator and the app both run DDL (create the `drizzle` schema +
tables, and apply migrations on boot). A database owned by `doadmin` does **not** grant
DDL to a scoped user automatically. Either:
- use the **`doadmin`** user in `DATABASE_URL`, **or**
- grant your scoped user the needed rights (run once as `doadmin`):
```sql
GRANT ALL ON DATABASE propertymanagementnetwork TO propertymanagementnetworksuser;
\c propertymanagementnetwork
GRANT ALL ON SCHEMA public TO propertymanagementnetworksuser;
ALTER DEFAULT PRIVILEGES IN SCHEMA public
GRANT ALL ON TABLES TO propertymanagementnetworksuser;
```
**Host & port:** use the **private** host (`private-...db.ondigitalocean.com`) in the
app's `DATABASE_URL` — the app runs inside DO's network, so it's faster and not publicly
exposed. Use the **public** host only for one-off admin/migration from your laptop. Use
the **direct port `25060`** (not the `25061` connection pool) so `migrate-on-boot` and its
advisory locks work correctly.
**Trusted Sources:** on the DB cluster → **Settings → Trusted Sources**, add the App
Platform app (and, temporarily, your laptop's IP for the initial migration). Otherwise the
cluster's firewall refuses connections.
**SSL (verified TLS — recommended):** DO's DB cert isn't in the system trust store, so
verification against the system CAs fails. Use verify-full instead: **omit `?sslmode=...`
from `DATABASE_URL`**, keep `DATABASE_SSL=require`, and set `DATABASE_CA` to the cluster's
CA cert — DB cluster → **Connection Details → Download CA certificate**, then paste the PEM
contents as the `DATABASE_CA` secret. With `require` and no (or an invalid) CA the app
**refuses to connect** rather than run unverified — that's intended. `DATABASE_SSL=no-verify`
(encrypted but unverified) exists only as an emergency fallback; do not use it in production.
---
## 2. Build and push the image to DOCR
`NEXT_PUBLIC_*` values are inlined into the browser bundle at **build time**, so pass
them as `--build-arg`. Use the URL you'll actually serve on (your custom domain, or the
`*.ondigitalocean.app` URL once known):
```bash
doctl registry login # auth Docker to DOCR
REG=registry.digitalocean.com/<your-registry>
docker build \
--build-arg NEXT_PUBLIC_APP_URL=https://<your-domain> \
--build-arg NEXT_PUBLIC_APP_NAME="Property Management Network" \
--build-arg NEXT_PUBLIC_TURNSTILE_SITE_KEY=0x4AAAAAADuDQverznfv1a60 \
-t $REG/property-management-network:latest .
docker push $REG/property-management-network:latest
```
> First deploy chicken-and-egg: if you don't have a domain yet, deploy once to get the
> `*.ondigitalocean.app` URL, then rebuild/push with that URL as `NEXT_PUBLIC_APP_URL`.
---
## 3. Create the app
```bash
doctl apps create --spec .do/app.yaml
```
Then set every `type: SECRET` value (App → Settings → Environment Variables), or edit
`.do/app.yaml` before applying. Secrets to fill: `DATABASE_URL`, `DATABASE_CA`,
`BETTER_AUTH_SECRET`, `GOOGLE_CLIENT_ID/SECRET`, `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`,
`OPENAI_API_KEY`, `SMTP_USER`, `SMTP_PASS`, `TURNSTILE_SECRET_KEY`, `SPACES_KEY`,
`SPACES_SECRET`, `CRON_SECRET` (plus the Stripe price IDs). Email sends via **SMTP
(SMTP2GO)** — `SMTP_HOST`/`SMTP_PORT`/`EMAIL_FROM` ship as non-secret defaults; without
`SMTP_USER` + `SMTP_PASS` all outbound email is silently skipped. `${APP_URL}` auto-resolves
for `BETTER_AUTH_URL` / `NEXT_PUBLIC_APP_URL` at runtime.
Migrations do **not** run on boot (`RUN_MIGRATIONS_ON_START=false`) — the app user has no
DDL rights by design. Apply schema changes out-of-band as **doadmin** before/after
deploying, from a machine allowed by the DB's Trusted Sources:
```bash
DATABASE_URL="postgresql://doadmin:<pw>@<public-host>:25060/propertymanagementnetwork" \
DATABASE_SSL=no-verify node scripts/migrate.mjs
```
The migrator is idempotent (only pending migrations run). The current schema (00000002)
is already applied to production. Redeploys reuse the same image tag — App Platform pulls
the new `:latest` on push (`deploy_on_push`).
---
## 4. Cron — DigitalOcean Functions
The two jobs are triggered by DO Functions schedulers hitting the app's protected
endpoints. Create `functions/.env` (gitignored):
```
APP_BASE_URL=https://<your-domain>
CRON_SECRET=<same value as the app's CRON_SECRET>
```
Deploy:
```bash
doctl serverless install # once
doctl serverless connect # once, pick/create a namespace
doctl serverless deploy functions --env functions/.env
```
This registers `cron/run` with two scheduler triggers: `daily` at `0 9 * * *` and
`late-fees` at `0 8 * * *` (UTC). Verify in **Functions → Triggers**.
---
## 5. Stripe webhook
Point a Stripe webhook at `https://<your-domain>/api/stripe/webhook` and put its signing
secret in `STRIPE_WEBHOOK_SECRET`. Subscribe to: `checkout.session.completed`,
`customer.subscription.created/updated/deleted`, `invoice.payment_failed`,
`payment_intent.succeeded`.
---
## 6. Google OAuth (optional)
Set `GOOGLE_CLIENT_ID/SECRET` and add `https://<your-domain>/api/auth/callback/google`
as an authorized redirect URI.
---
## Post-deploy checklist
- [ ] `https://<domain>/api/health` returns `{"status":"ok",...}`
- [ ] Sign up / log in works (verifies `DATABASE_URL` + `BETTER_AUTH_*` + Turnstile)
- [ ] Upload a document; confirm the object appears in the Space and serves via the CDN
- [ ] Trigger the `daily` function manually (`doctl serverless functions invoke cron/run -p job:daily`) → 200
- [ ] Stripe webhook delivers successfully