import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server"; import { NextResponse } from "next/server"; import { timingSafeStringEqual } from "@/lib/timing-safe-compare"; const isPublicRoute = createRouteMatcher([ "/", "/sign-up(.*)", "/log-in(.*)", "/forgot-password(.*)", "/reset-password(.*)", "/verify-email(.*)", "/health-card/(.*)", "/api/gdpr-export/(.*)", "/opengraph-image", "/impressum", "/datenschutz", "/nutzungsbedingungen", "/join(.*)", "/api/webhooks/(.*)", "/api/auth/(.*)", "/api/cron/(.*)", "/api/csp-report", // tRPC handles its own auth per-procedure (protectedProcedure vs. // publicProcedure) and returns a proper JSON UNAUTHORIZED error — letting // Clerk's page-level auth.protect() intercept it first produced a 307 HTML // redirect to the sign-in page instead, which a JSON API client can't // parse, and incorrectly blocked publicProcedure calls (e.g. // explore.listPets) for logged-out visitors entirely. "/api/trpc/(.*)", ]); function getSupabaseHostname(): string { const url = process.env.NEXT_PUBLIC_SUPABASE_URL; if (url) { try { return new URL(url).hostname; } catch { /* fall through */ } } return "*.supabase.co"; } function getGlitchtipHostname(): string { const dsn = process.env.NEXT_PUBLIC_SENTRY_DSN; if (dsn) { try { return new URL(dsn).hostname; } catch { /* fall through */ } } return "glitchtip.pawfeed.org"; } export default clerkMiddleware( async (auth, request) => { const { pathname } = request.nextUrl; // Invite gate: block /sign-up unless the visitor has a valid invite cookie. // Set INVITE_REQUIRED=false in env to disable for the live/post-beta launch. if (pathname.startsWith("/sign-up")) { const inviteRequired = process.env.INVITE_REQUIRED !== "false"; if (inviteRequired && !request.cookies.has("pf_invite")) { return NextResponse.redirect(new URL("/join", request.url)); } } // Admin panel: validate secret path segment before anything else. // Returns 404 (not 403) — the panel's existence must not be revealed. if (pathname.startsWith("/p/")) { const segments = pathname.split("/"); const providedSecret = segments[2] ?? ""; const adminSecret = process.env.ADMIN_SECRET ?? ""; if (!adminSecret || !timingSafeStringEqual(providedSecret, adminSecret)) { return new NextResponse(null, { status: 404 }); } } if (!isPublicRoute(request)) { await auth.protect(); } }, { // Report-Only for now — an earlier enforced attempt blanked the whole // app because ClerkProvider crashes uncaught when a required resource // gets blocked mid-init. Clerk's own contentSecurityPolicy option (not // the hand-rolled next.config.ts draft this replaces) already ships the // 'unsafe-inline'/https: script-src Next.js needs to hydrate, which is // what the hand-rolled version was missing. Move to enforced only after // a clean report-only period — see /api/csp-report for the violations. contentSecurityPolicy: { reportOnly: true, reportTo: "/api/csp-report", directives: { "img-src": [`https://${getSupabaseHostname()}`, "https://image.mux.com", "blob:"], "connect-src": [ `https://${getSupabaseHostname()}`, `https://${getGlitchtipHostname()}`, "https://stream.mux.com", "https://image.mux.com", "https://litix.io", "https://storage.googleapis.com", ], "media-src": ["self", "blob:", "https://stream.mux.com", "https://*.edgemv.mux.com"], "font-src": ["self", "data:"], "frame-ancestors": ["none"], "object-src": ["none"], "base-uri": ["self"], "report-uri": ["/api/csp-report"], }, }, } ); export const config = { matcher: [ "/((?!_next|[^?]*\\.(?:html?|css|js(?!on)|jpe?g|webp|png|gif|svg|ttf|woff2?|ico|csv|docx?|xlsx?|zip|webmanifest)).*)", "/(api|trpc)(.*)", "/__clerk/(.*)", ], };