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
+182
@@ -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 (0000–0002)
|
||||
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
|
||||
Reference in New Issue
Block a user