import { clerkMiddleware, createRouteMatcher } from "@clerk/nextjs/server"; import { NextResponse } from "next/server"; import { timingSafeStringEqual } from "@/lib/timing-safe-compare"; import { isMaintenanceModeEnabled } from "@/lib/maintenance"; const MAINTENANCE_EXEMPT_PATHS = new Set(["/impressum", "/datenschutz", "/nutzungsbedingungen", "/ueber-uns"]); const MAINTENANCE_HTML = ` Wartungsarbeiten · PawFeed

Wir sind gleich zurück

PawFeed führt gerade kurz Wartungsarbeiten durch. Bitte versuch es in wenigen Minuten erneut.

`; const isPublicRoute = createRouteMatcher([ "/", "/sign-up(.*)", "/log-in(.*)", "/forgot-password(.*)", "/reset-password(.*)", "/verify-email(.*)", "/health-card/(.*)", "/api/gdpr-export/(.*)", "/opengraph-image", "/impressum", "/datenschutz", "/nutzungsbedingungen", "/ueber-uns", "/join(.*)", "/api/webhooks/(.*)", "/api/auth/(.*)", "/api/cron/(.*)", "/api/csp-report", "/api/health", // 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 getClerkFrontendApiHostname(): string { // A Clerk publishable key is base64(`${frontendApiHost}$`) after its // pk_test_/pk_live_ prefix — this middleware runs on the Edge runtime, so // atob (not Buffer) for the decode. const key = process.env.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY; if (key) { try { const encoded = key.replace(/^pk_(test|live)_/, ""); return atob(encoded).replace(/\$$/, ""); } catch { /* fall through */ } } return "clerk.pawfeed.org"; } 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 }); } } // Maintenance mode: admin-toggled (admin.setMaintenanceMode), blocks all // page/app traffic except the admin panel itself (so it can be turned // back off), API routes (webhooks/cron/csp-report must keep working), // the fixed-language legal pages (Impressumspflicht), and sign-in/Clerk's // own routes — an admin whose session expired must still be able to log // back in and reach /p/* to disable maintenance mode. Fails open on a // Redis error — an outage there must never take the whole site down. if ( !pathname.startsWith("/p/") && !pathname.startsWith("/api/") && !pathname.startsWith("/log-in") && !pathname.startsWith("/sign-up") && !pathname.startsWith("/__clerk/") && !MAINTENANCE_EXEMPT_PATHS.has(pathname) ) { const maintenanceOn = await isMaintenanceModeEnabled().catch(() => false); if (maintenanceOn) { return new NextResponse(MAINTENANCE_HTML, { status: 503, headers: { "content-type": "text/html; charset=utf-8", "retry-after": "3600" }, }); } } if (!isPublicRoute(request)) { await auth.protect(); } }, { // Enforced as of 2026-08-22 (Mozilla Observatory docked 25 points for // Report-Only-only). The report-only period (since 2026-08-12/13) came // back clean: GlitchTip shows zero CSP violations since 2026-08-16 // (verified via issue_events_issueaggregate on the self-hosted instance) // except 6 script-src "eval" hits on 2026-08-15 from /pets/[petId] and // /onboarding/pet — both routes use react-easy-crop for avatar cropping, // the likely source, and it hasn't recurred since. Clerk's production // script-src default omits 'unsafe-eval' (see // node_modules/@clerk/nextjs/.../content-security-policy.js), so an // enforced policy would hard-block that call if it ever recurs. Adding // 'unsafe-eval' back here defensively avoids repeating the earlier // incident where an enforced CSP blanked the whole app (ClerkProvider // crashed uncaught on a blocked required resource) — it costs nothing // extra in Mozilla's scoring since 'unsafe-inline'/https:/http: are // already unconditionally in Clerk's default script-src. Revisit once // the eval source is confirmed fixed and remove this line. contentSecurityPolicy: { reportOnly: false, // strict: true drops http:/https: from script-src and adds // 'strict-dynamic' + a per-request nonce instead (CSP3 best practice — // Mozilla Observatory flags http:/https:/unsafe-inline as "overly // broad"). Clerk forwards the resulting Content-Security-Policy header // (with the nonce) on both the response AND the request it passes // downstream (see setRequestHeadersOnNextResponse in // @clerk/nextjs/.../clerkMiddleware.js), which is exactly what // Next.js's own automatic nonce application reads during SSR — so // Next's framework scripts and page bundles get noticed for free. // 'unsafe-inline' stays in the header text as a spec-mandated fallback // for pre-CSP3 browsers; browsers that understand 'strict-dynamic' // ignore it. Any hand-written inline