Files
podcastdistributiona/middleware.ts
T
Leon SerfatyandClaude Opus 5 3e9ba07175 feat: Cloudflare Turnstile on auth, CSP fixes, admin/SEO/analytics additions
Turnstile bot protection (sign-in, sign-up, password-reset):
- Register Better Auth's captcha plugin with the cloudflare-turnstile
  provider; endpoints listed explicitly rather than relying on defaults.
  /reset-password is intentionally excluded — it is reached only via a
  single-use emailed token.
- Add an explicit-render Turnstile widget component. Tokens are single-use,
  so each form resets the challenge after a failed submit; submit stays
  disabled until a token is held.
- Read the site key server-side and pass it down as a prop, so rotating it
  does not require a rebuild.
- Fail fast in production when TURNSTILE_SECRET_KEY is missing, and when a
  secret is set without a site key (that combination would demand a token
  no form can produce, locking every user out).
- Pass a throwaway secret during `next build` in the Dockerfile, mirroring
  the existing BETTER_AUTH_SECRET treatment, so image builds don't need it.

CSP fixes in middleware (these blocked Turnstile entirely):
- Add frame-src for challenges.cloudflare.com. Without it the widget's
  iframe fell back to default-src 'self' and was blocked outright.
- Allow 'unsafe-eval' and websockets in DEVELOPMENT only. `next dev`
  compiles with eval(), so the strict policy threw EvalError and killed
  hydration — no client JS ran at all, which also meant form submit
  handlers never fired. Production policy is unchanged and still strict.

Also included (concurrent work in the tree):
- Admin organizations pages and lib/admin/orgs.
- Episode moderation migration, SEO metadata (sitemap, robots, JSON-LD,
  OG/Twitter images, manifest), Umami analytics, not-found page.

Local dev database: docker-compose.dev.yml provisions Postgres 18 on port
5443 (5432-5442 are in use by other local projects).

Note: `npx tsc --noEmit` currently fails in app/(app)/team/page.tsx — an
`invitations` prop the component does not accept. This predates the commit
and will fail `next build` until fixed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-07 11:10:55 -04:00

117 lines
5.3 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
// Better Auth's session cookie name (default prefix "better-auth"); the
// "__Secure-" variant is used when cookies are served over HTTPS in production.
const SESSION_COOKIES = ["better-auth.session_token", "__Secure-better-auth.session_token"];
// Authed surfaces that require an optimistic session-cookie check. Anonymous users
// hitting these are redirected to /sign-in. Public/marketing/auth routes are NOT
// listed here, so they are never redirected (CSP still applies to them, below).
const AUTHED_PREFIXES = [
"/dashboard",
"/episodes",
"/series",
"/usage",
"/billing",
"/team",
"/api-keys",
"/settings",
"/admin",
];
/**
* Runs on every request (see matcher). Two responsibilities:
*
* 1. CSP/nonce (all routes): generate a per-request base64 nonce with the Web Crypto
* API (Edge-safe — no node:crypto), expose it on the inbound `x-nonce` request
* header, and set a nonce-based Content-Security-Policy response header. Next.js
* auto-applies this nonce to its own framework <script> tags when the `x-nonce`
* request header is present; the root layout may also read it via `headers()` to
* nonce any manual inline scripts.
*
* 2. Optimistic edge gate (authed prefixes only): redirect anonymous users away from
* authed surfaces. Only checks for the *presence* of a session cookie — real
* session validation (and admin/role checks) happen in the route-group layouts.
*/
export function middleware(req: NextRequest) {
const { pathname, search } = req.nextUrl;
// Per-request nonce (base64). randomUUID is Edge-runtime safe and unguessable.
const nonce = Buffer.from(crypto.randomUUID()).toString("base64");
// Cloudflare Turnstile needs three allowances: its script, the IFRAME the widget
// actually renders into, and the XHRs that script makes. Without frame-src the
// iframe falls back to default-src 'self' and the challenge silently never
// appears — leaving the submit button permanently disabled.
const TURNSTILE_ORIGIN = "https://challenges.cloudflare.com";
// `next dev` compiles client chunks with eval() (HMR + cheap source maps) and
// talks to the dev server over a websocket. Without these two dev-only
// relaxations the CSP throws EvalError, hydration dies, and NOTHING on the page
// is interactive — no Turnstile widget, and form submit handlers never fire.
// Production builds need neither, so the shipped policy stays strict.
const isDev = process.env.NODE_ENV !== "production";
const devScriptSrc = isDev ? " 'unsafe-eval'" : "";
const devConnectSrc = isDev ? " ws: http://localhost:* http://127.0.0.1:*" : "";
const csp = [
"default-src 'self'",
// NOTE: deliberately NOT 'strict-dynamic'.
//
// 'strict-dynamic' makes browsers ignore 'self' and every host-source in this
// directive, leaving the nonce as the only way in. That works on dynamically
// rendered routes, where Next stamps the per-request nonce onto its <script>
// tags — but a statically prerendered page has no request to take a nonce
// from, so its HTML ships without one. With 'strict-dynamic' the browser then
// blocked every framework chunk on /, /pricing, /features, /faq, /about and
// the legal pages: React never hydrated and the marketing surface was inert.
//
// Without it, 'self' covers the same-origin /_next/static chunks (and the
// proxied analytics tracker at /_a), and the Turnstile host-source actually
// takes effect instead of being silently ignored. The nonce is kept, so
// dynamic routes and any inline script still get the stronger guarantee.
`script-src 'self' 'nonce-${nonce}' ${TURNSTILE_ORIGIN}${devScriptSrc}`,
"style-src 'self' 'unsafe-inline'",
"img-src 'self' data: https://oaidalleapiprodscus.blob.core.windows.net https://images.unsplash.com",
"media-src 'self'",
`connect-src 'self' ${TURNSTILE_ORIGIN}${devConnectSrc}`,
`frame-src 'self' ${TURNSTILE_ORIGIN}`,
"frame-ancestors 'none'",
"base-uri 'self'",
"form-action 'self'",
].join("; ");
// Optimistic auth gate for the previously-matched authed prefixes only.
const isAuthedPath = AUTHED_PREFIXES.some(
(p) => pathname === p || pathname.startsWith(p + "/")
);
if (isAuthedPath) {
const hasSession = SESSION_COOKIES.some((name) => req.cookies.has(name));
if (!hasSession) {
const signIn = new URL("/sign-in", req.url);
signIn.searchParams.set("redirect", pathname + search);
return NextResponse.redirect(signIn);
}
}
// Forward the nonce to the app via a request header, and set the CSP on the response.
const requestHeaders = new Headers(req.headers);
requestHeaders.set("x-nonce", nonce);
requestHeaders.set("Content-Security-Policy", csp);
const res = NextResponse.next({ request: { headers: requestHeaders } });
res.headers.set("Content-Security-Policy", csp);
return res;
}
export const config = {
matcher: [
// Run on every request EXCEPT static assets so CSP applies app-wide while
// avoiding unnecessary work on prefetched/static files.
{
source: "/((?!_next/static|_next/image|favicon.ico).*)",
missing: [
{ type: "header", key: "next-router-prefetch" },
{ type: "header", key: "purpose", value: "prefetch" },
],
},
],
};