Deploy on DigitalOcean App Platform (GitHub-source build) + consolidate audit-fixes

Deploy config:
- .do/app.yaml: build the Dockerfile directly from GitHub (deploy_on_push) instead
  of a pre-built DOCR image; NEXT_PUBLIC_* set RUN_AND_BUILD_TIME with the
  propertymanagement.network domain so they bake into the client bundle; add
  custom domains block (apex + www); wire Sentry DSN (server + browser).

Included pending work from the audit-fixes branch:
- AI provider abstraction (OpenAI/Anthropic, admin-selectable; Anthropic default)
- Per-landlord e-signature (DocuSign OAuth + Dropbox Sign) + migration 0010
- Outbound webhooks / Zapier integration
- PayPal removal (Stripe-only billing)
- Storage hardening (fail-loud when Spaces unconfigured), security fixes

Verified: full production Docker build (same build-args as DO) passes clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Leon Serfaty
2026-07-03 04:45:24 -04:00
co-authored by Claude Opus 4.8
parent 917a06ee85
commit 5495b94924
86 changed files with 7647 additions and 1182 deletions
+74 -6
View File
@@ -28,6 +28,19 @@ const PROTECTED_PATHS = [
const AUTH_PATHS = ["/login", "/signup", "/forgot-password"]
// Origin of the Sentry ingest endpoint, derived from the public DSN so the
// CSP stays in sync with whatever project/region the DSN points at. Returns
// null when Sentry is not configured.
function sentryIngestOrigin(): string | null {
const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN
if (!dsn) return null
try {
return new URL(dsn).origin
} catch {
return null
}
}
// Build the per-request Content-Security-Policy. `script-src` carries a
// per-request nonce instead of 'unsafe-inline'. `style-src` keeps
// 'unsafe-inline' because Radix / Tailwind / framer-motion inject inline
@@ -36,6 +49,7 @@ const AUTH_PATHS = ["/login", "/signup", "/forgot-password"]
// scripts automatically.
function buildCsp(nonce: string): string {
const isDev = process.env.NODE_ENV !== "production"
const sentry = sentryIngestOrigin()
// In development, Next.js/React and Turbopack HMR require eval() for hot
// reloading and debugging features, and open a dev websocket. These are NOT
@@ -43,9 +57,14 @@ function buildCsp(nonce: string): string {
const scriptSrc = isDev
? `script-src 'self' 'nonce-${nonce}' 'unsafe-eval' https://challenges.cloudflare.com`
: `script-src 'self' 'nonce-${nonce}' https://challenges.cloudflare.com`
const connectSrc = isDev
? "connect-src 'self' ws: wss: https://api.stripe.com https://api.openai.com https://challenges.cloudflare.com"
: "connect-src 'self' https://api.stripe.com https://api.openai.com https://challenges.cloudflare.com"
const connectSrc = [
"connect-src 'self'",
isDev ? "ws: wss:" : "",
"https://api.stripe.com https://api.openai.com https://challenges.cloudflare.com",
sentry ?? "",
]
.filter(Boolean)
.join(" ")
return [
"default-src 'self'",
@@ -54,13 +73,39 @@ function buildCsp(nonce: string): string {
scriptSrc,
"font-src 'self' data:",
connectSrc,
// Sentry Session Replay spins up its compression worker from a blob: URL;
// without worker-src the browser falls back to script-src and blocks it.
"worker-src 'self' blob:",
"frame-src https://js.stripe.com https://hooks.stripe.com https://challenges.cloudflare.com",
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
"object-src 'none'",
].join("; ")
}
// Cookie presence is only a hint (cheap, no DB). Before bouncing a visitor off
// an auth page we confirm the session is actually alive — otherwise a stale
// cookie loops forever: /dashboard → /login (server sees no session) →
// /dashboard (proxy sees a cookie) → … until ERR_TOO_MANY_REDIRECTS.
// "unknown" (auth service unreachable / rate-limited) renders the auth page
// without touching cookies, which is safe in both directions.
async function sessionState(request: NextRequest): Promise<"valid" | "invalid" | "unknown"> {
try {
const base = process.env.BETTER_AUTH_URL ?? request.nextUrl.origin
const res = await fetch(new URL("/api/auth/get-session", base), {
headers: { cookie: request.headers.get("cookie") ?? "" },
cache: "no-store",
})
if (!res.ok) return "unknown"
// Better Auth returns JSON `null` when the session is missing or revoked.
const session = await res.json()
return session ? "valid" : "invalid"
} catch {
return "unknown"
}
}
export async function proxy(request: NextRequest) {
const pathname = request.nextUrl.pathname
@@ -76,10 +121,18 @@ export async function proxy(request: NextRequest) {
}
const isAuthPage = AUTH_PATHS.some((p) => pathname.startsWith(p))
let dropStaleSessionCookie = false
if (isAuthPage && sessionCookie) {
const url = request.nextUrl.clone()
url.pathname = "/dashboard"
return NextResponse.redirect(url)
const state = await sessionState(request)
if (state === "valid") {
const url = request.nextUrl.clone()
url.pathname = "/dashboard"
return NextResponse.redirect(url)
}
// Dead cookie (session revoked or expired): render the auth page and drop
// the cookie below so protected paths stop treating this visitor as
// signed in. On "unknown", render the page but keep the cookie.
dropStaleSessionCookie = state === "invalid"
}
// Per-request CSP nonce. UUID contains only hex + dashes, so it never
@@ -96,6 +149,21 @@ export async function proxy(request: NextRequest) {
const response = NextResponse.next({ request: { headers: requestHeaders } })
// Also set the CSP on the outgoing response so the browser enforces it.
response.headers.set("Content-Security-Policy", csp)
if (dropStaleSessionCookie) {
// Covers both the plain and __Secure-prefixed Better Auth cookie names.
for (const cookie of request.cookies.getAll()) {
if (!cookie.name.includes("better-auth.session_token")) continue
response.cookies.set(cookie.name, "", {
maxAge: 0,
path: "/",
httpOnly: true,
sameSite: "lax",
secure: cookie.name.startsWith("__Secure-"),
})
}
}
return response
}