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