Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ PLATFORM_BASE_URL="http://localhost:3000"
PORTAL_BASE_URL="http://localhost:3000"
DEFAULT_COMPANY_SLUG="acme-realty"
ESTATEOS_ENABLE_DEV_BYPASS="false"
# CSP escape hatch: "true" ships Content-Security-Policy-Report-Only instead of
# the enforced header (see src/lib/security/csp.ts). Leave false in production.
ESTATEOS_CSP_REPORT_ONLY="false"
# Safety guard. Set these to your live production database's Supabase project
# ref and pooler host. When a non-production runtime's DATABASE_URL/DIRECT_URL
# points at this database, dev bypass is force-disabled and demo/seed writes
Expand Down
53 changes: 53 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# Continuous integration — runs the same gate as `npm run check` on every PR
# and every push to main: tests, typecheck, lint, and a production build.
#
# Notes:
# - The build intentionally runs WITHOUT secrets: scripts/run-build.mjs is
# designed so builds never require database access, and src/lib/config.ts
# treats all integration env vars as optional (features degrade gracefully).
# Database migrations are a separate, controlled release step
# (`npm run db:migrate:deploy`), never part of CI.
# - `npm run encoding:check` is NOT included: it currently fails on many
# pre-existing files (em dashes etc.). Add it here after a cleanup pass.
name: CI

on:
push:
branches: [main]
pull_request:

concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
check:
name: Test, typecheck, lint, build
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 22
cache: npm

# postinstall runs `prisma generate`, so the client exists for tests
# and the typecheck without any database connection.
- name: Install dependencies
run: npm ci

- name: Tests
run: npm run test

- name: Typecheck
run: npm run typecheck

- name: Lint
run: npm run lint

- name: Build
run: npm run build
47 changes: 0 additions & 47 deletions COMMIT-PROMPT.md

This file was deleted.

102 changes: 102 additions & 0 deletions DEPLOYMENT-CHECKLIST.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,102 @@
# Deployment Checklist — Modernization batch (2026-07-06)

Covers everything shipped in this session series: enforced CSP, grouped nav,
UI primitives + DataTable migrations, Select/Dialog unification, system-aware
dark mode, PWA, CI, next/image for R2 media, conditional-polling realtime,
webhook idempotency hardening, buyer bottom tab bar — plus the pre-existing
Users tab + MARKETER role batch that ships with it.

---

## 1 · Ship the code

- [ ] `npm run check` green locally (already confirmed).
- [ ] Commit & push via `COMMIT-PROMPT.md` (Claude Code).
- [ ] Open PR `feat/design-system-v2` → `main`. The new GitHub Actions CI
(`.github/workflows/ci.yml`) runs the same gate on the PR — its first
ever run, so watch it. If the env-less CI build fails on a missing
variable, fix the default in `src/lib/config.ts`, not with CI secrets.

## 2 · Pre-deploy environment (Vercel)

- [ ] `R2_PUBLIC_BASE_URL` is present in the **build** environment (not just
runtime) — the image-optimizer host allowlist is baked at build time.
- [ ] `UPSTASH_REDIS_REST_URL` / `UPSTASH_REDIS_REST_TOKEN` present
(conditional polling + rate limits).
- [ ] `ESTATEOS_CSP_REPORT_ONLY` is **unset or "false"** — CSP enforces.
- [ ] Paystack/Clerk/R2/Resend secrets unchanged — nothing in this batch
rotates credentials.

## 3 · Database migrations (before deploying app code)

Run against production per the runbook (`PROD-MIGRATION-RECONCILIATION.md`):

- [ ] `npm run db:migrate:deploy` applies:
- `0048_marketer_role` (AppRole MARKETER — already applied to dev)
- `20260701000111_…` (reconciliation placeholder)
- `0049_webhook_event_dedup_unique` — deletes historical duplicate
webhook rows (keeps oldest per event id), then adds the unique index
on `WebhookEvent(companyId, provider, providerEventId)`. Additive and
safe to run while the old code is live.
- [ ] Verify: `SELECT indexname FROM pg_indexes WHERE tablename='WebhookEvent';`
shows `WebhookEvent_companyId_provider_providerEventId_key`.

## 4 · Deploy

- [ ] Merge to `main`; let Vercel build and promote.
- [ ] Build log: confirm `prisma generate` + `next build` complete and the
route list includes `/manifest.webmanifest` and `/api/realtime/version`.

## 5 · Post-deploy verification (15 minutes, in order)

**CSP (highest risk — first enforced deploy)**
- [ ] Open the admin dashboard and buyer portal with DevTools console open:
zero CSP violation errors.
- [ ] Sign out/in (Clerk widget loads), open a property page (Mapbox tiles),
open an uploaded image/receipt (R2), start a test Paystack checkout
(iframe loads). All are CSP-sensitive surfaces.
- [ ] Rollback lever: set `ESTATEOS_CSP_REPORT_ONLY=true` + redeploy →
instantly back to report-only.

**Payments (idempotency hardening)**
- [ ] Make one real/demo payment end-to-end: webhook reconciles, receipt
generated, balance decremented once, buyer email + WhatsApp sent once.
- [ ] Paystack Dashboard → resend the same webhook event: response is
`duplicate: true`, no second receipt, balance unchanged.

**Realtime (conditional polling)**
- [ ] Two browser tabs (admin + portal): create a lead or payment; both
surfaces refresh within ~15 s.
- [ ] Network tab shows `/api/realtime/version` every 15 s returning
`{ enabled: true, version: n }`.

**Images**
- [ ] Property photos load via `/_next/image?...` (network tab) with
`content-type: image/avif` or `webp` — not full-size originals.

**PWA + mobile**
- [ ] `/manifest.webmanifest` resolves; Android Chrome offers install; icon
and splash look right.
- [ ] On a phone: buyer portal shows the bottom tab bar (badges work), the
drawer still opens, dark mode follows system on first visit.

**Nav + tables**
- [ ] Sidebar shows grouped sections for each role (check a STAFF or FINANCE
user, not just ADMIN); collapse state persists across reloads.
- [ ] Payments / Invoices / Contracts / Marketers tables sort, search, and
paginate; contract "Send to buyer" and "Regenerate" actions work.

## 6 · Watch for 48 hours

- [ ] Sentry: no new error signatures (especially CSP-adjacent or webhook).
- [ ] Upstash: command volume roughly `dashboards × 4/min` — flat and tiny.
- [ ] Vercel: image-optimization usage rises modestly (expected trade for
the bandwidth win); function duration stable.
- [ ] Paystack webhook logs: 200s, no retry storms.

## Rollback levers (in escalation order)

1. CSP only: `ESTATEOS_CSP_REPORT_ONLY=true` + redeploy.
2. App: Vercel → promote previous deployment.
3. Migration 0049 is additive — safe to leave in place even when rolling the
app back; old code simply doesn't rely on the index.
113 changes: 44 additions & 69 deletions next.config.ts
Original file line number Diff line number Diff line change
@@ -1,83 +1,65 @@
import type { NextConfig } from "next";

/**
* Content-Security-Policy — REPORT-ONLY (not enforced).
* Content-Security-Policy is ENFORCED and lives in src/proxy.ts +
* src/lib/security/csp.ts (a static header cannot carry a per-request nonce,
* so it moved out of this file). The previous report-only phase (2026-06)
* informed the allowlist there. Only nonce-free security headers remain below.
*
* This is intentionally shipped as `Content-Security-Policy-Report-Only` so the
* browser REPORTS violations (visible in DevTools → Console / Network) WITHOUT
* blocking anything. Nothing here can break the live app. The goal is to observe
* what real traffic actually loads/connects to for a while, tune this allowlist
* to remove anything unused and add anything missing, and only THEN switch the
* header key to `Content-Security-Policy` to enforce it.
*
* Allowlist is derived from the third-party services the app actually uses:
* - Clerk (auth UI + FAPI + Cloudflare Turnstile bot challenge)
* - Paystack (inline checkout script + API + checkout iframe)
* - Mapbox GL JS (bundled script; loads tiles/styles + uses blob workers)
* - PostHog (custom fetch client → NEXT_PUBLIC_POSTHOG_HOST/capture/)
* - Sentry (bundled SDK; sends events to *.ingest.sentry.io)
* - Cloudflare R2 (browser presigned PUT/GET to *.r2.cloudflarestorage.com)
* - Unsplash (marketing imagery)
* - Vercel (vercel.live preview toolbar on preview deployments)
* Server-only integrations (Gemini, Resend, Twilio) never touch the browser, so
* they need no CSP entries.
*
* KNOWN LOOSENESS to tighten before enforcement:
* - script-src includes 'unsafe-inline' and 'unsafe-eval'. Next.js/React inject
* inline bootstrap scripts and this config cannot emit a per-request nonce
* (static headers only). Before enforcing, move to a nonce-based script-src
* with 'strict-dynamic' (requires middleware) and drop 'unsafe-eval'.
* - style-src includes 'unsafe-inline' for Tailwind/inline styles + Mapbox.
*
* NOTE: if the production Clerk instance uses a custom FAPI domain
* (e.g. https://clerk.your-domain.com), add it to script-src and connect-src.
* Escape hatch: ESTATEOS_CSP_REPORT_ONLY=true reverts to a report-only header.
*/
/**
* Host of the public R2 media domain (custom domain or *.r2.dev), derived at
* build time from R2_PUBLIC_BASE_URL. Used twice:
* - added to images.remotePatterns so next/image may optimize R2 media;
* - inlined into the client bundle (NEXT_PUBLIC_R2_PUBLIC_HOST) so
* shouldUseUnoptimizedImage() can whitelist the same host.
* Presigned *.r2.cloudflarestorage.com URLs are intentionally NOT optimized:
* their signatures change per request (cache-busting) and can expire before
* the optimizer fetches them.
*/
const contentSecurityPolicyReportOnly = [
// Lock everything down by default; specific resource types are opened up below.
"default-src 'self'",
// Scripts: self + inline/eval (see looseness note) + external SDK script hosts.
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://*.clerk.accounts.dev https://*.clerk.com https://challenges.cloudflare.com https://js.paystack.co https://vercel.live",
// Some browsers consult script-src-elem/attr separately; keep them aligned.
"script-src-elem 'self' 'unsafe-inline' https://*.clerk.accounts.dev https://*.clerk.com https://challenges.cloudflare.com https://js.paystack.co https://vercel.live",
// Styles: Tailwind + inline style attributes + Mapbox-injected styles.
"style-src 'self' 'unsafe-inline'",
"style-src-elem 'self' 'unsafe-inline'",
// Images: self, data/blob URIs, Unsplash, R2 media, Clerk avatars, Mapbox tiles.
"img-src 'self' data: blob: https://images.unsplash.com https://*.r2.cloudflarestorage.com https://img.clerk.com https://*.clerk.com https://api.mapbox.com https://*.tiles.mapbox.com",
// Fonts: self-hosted + data URIs (no Google Fonts CDN is used).
"font-src 'self' data:",
// XHR/fetch/websocket targets: own origin + every third-party API the browser calls.
"connect-src 'self' https://*.clerk.accounts.dev https://*.clerk.com https://clerk-telemetry.com https://api.paystack.co https://api.mapbox.com https://events.mapbox.com https://*.tiles.mapbox.com https://*.posthog.com https://*.i.posthog.com https://*.sentry.io https://*.ingest.sentry.io https://*.ingest.us.sentry.io https://*.ingest.de.sentry.io https://*.r2.cloudflarestorage.com https://vercel.live wss://ws-us3.pusher.com",
// Iframes the app embeds: Clerk components, Cloudflare Turnstile, Paystack checkout, Vercel toolbar.
"frame-src 'self' https://*.clerk.accounts.dev https://challenges.cloudflare.com https://checkout.paystack.com https://*.paystack.com https://vercel.live",
// Web/Service workers (Mapbox GL spawns blob: workers).
"worker-src 'self' blob:",
"child-src 'self' blob:",
// Media (audio/video) — self + blob + presigned R2 objects.
"media-src 'self' blob: https://*.r2.cloudflarestorage.com",
// App manifest.
"manifest-src 'self'",
// Form submissions only to our own origin (+ Paystack redirect target, defensively).
"form-action 'self' https://checkout.paystack.com",
// Who may frame us — mirrors the existing X-Frame-Options: SAMEORIGIN header.
"frame-ancestors 'self'",
// Restrict <base> and disallow plugins.
"base-uri 'self'",
"object-src 'none'",
].join("; ");
const r2PublicHost = (() => {
try {
return process.env.R2_PUBLIC_BASE_URL
? new URL(process.env.R2_PUBLIC_BASE_URL).hostname
: null;
} catch {
return null;
}
})();

const nextConfig: NextConfig = {
reactCompiler: true,
poweredByHeader: false,
turbopack: {
root: __dirname,
},
env: {
NEXT_PUBLIC_R2_PUBLIC_HOST: r2PublicHost ?? "",
},
images: {
// Serve AVIF/WebP to browsers that accept them — the single biggest
// payload win for property photos on mobile data.
formats: ["image/avif", "image/webp"],
remotePatterns: [
{
protocol: "https",
hostname: "images.unsplash.com",
},
// Default public R2 bucket domains.
{
protocol: "https",
hostname: "**.r2.dev",
},
// Tenant media domain when R2_PUBLIC_BASE_URL is configured.
...(r2PublicHost
? [
{
protocol: "https" as const,
hostname: r2PublicHost,
},
]
: []),
],
},
async headers() {
Expand All @@ -89,13 +71,6 @@ const nextConfig: NextConfig = {
{ key: "X-Content-Type-Options", value: "nosniff" },
{ key: "X-Frame-Options", value: "SAMEORIGIN" },
{ key: "Permissions-Policy", value: "camera=(), microphone=(), geolocation=()" },
// Report-only: surfaces CSP violations in DevTools without blocking.
// Monitor reports and tune the allowlist before switching this to the
// enforcing "Content-Security-Policy" header (see note above).
{
key: "Content-Security-Policy-Report-Only",
value: contentSecurityPolicyReportOnly,
},
],
},
];
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
"start": "next start",
"lint": "node --max-old-space-size=4096 ./node_modules/eslint/bin/eslint.js .",
"encoding:check": "node scripts/check-utf8.mjs",
"test": "tsx --test src/**/*.test.ts",
"test": "tsx --test \"src/**/*.test.ts\"",
"typecheck": "node --max-old-space-size=4096 ./node_modules/typescript/bin/tsc --noEmit --incremental false",
"db:generate": "prisma generate",
"db:validate": "prisma validate",
Expand Down
2 changes: 2 additions & 0 deletions prisma/migrations/0048_marketer_role/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
-- Add MARKETER to the AppRole enum so marketers can have their own login role.
ALTER TYPE "AppRole" ADD VALUE IF NOT EXISTS 'MARKETER';
22 changes: 22 additions & 0 deletions prisma/migrations/0049_webhook_event_dedup_unique/migration.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
-- Webhook idempotency hardening.
--
-- Before creating the unique index, remove any historical duplicate rows
-- (keep the OLDEST row per (companyId, provider, providerEventId) group —
-- the one whose side effects actually ran first). Rows with NULL
-- providerEventId are untouched: Postgres unique indexes permit multiple
-- NULLs, and legacy rows may legitimately lack an event id.
DELETE FROM "WebhookEvent" w
USING "WebhookEvent" keeper
WHERE w."providerEventId" IS NOT NULL
AND keeper."providerEventId" IS NOT NULL
AND w."companyId" IS NOT DISTINCT FROM keeper."companyId"
AND w."provider" = keeper."provider"
AND w."providerEventId" = keeper."providerEventId"
AND keeper."createdAt" < w."createdAt";

-- Hard idempotency guarantee: the same provider event can only be recorded
-- once per tenant. Reconciliation inserts this row inside the same DB
-- transaction as the balance/receipt mutations, so a concurrent duplicate
-- delivery aborts atomically with a unique violation.
CREATE UNIQUE INDEX "WebhookEvent_companyId_provider_providerEventId_key"
ON "WebhookEvent"("companyId", "provider", "providerEventId");
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
-- Reconciliation placeholder for a migration already applied to the dev database.
Loading
Loading