All files / src proxy.ts

100% Statements 9/9
100% Branches 6/6
100% Functions 1/1
100% Lines 9/9

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52        5x                                                               6x 1x     5x 5x 2x   3x       3x 3x    
import { NextResponse, type NextRequest } from "next/server";
import { getSessionCookie } from "better-auth/cookies";
import { isRequestAuthorized } from "@/lib/middleware-auth";
 
export const config = {
  matcher: [
    // Protect all routes except these. `tutorials/*` holds public walkthroughs, and
    // `onboarding/*` holds the public sample images it renders. `api/auth` is
    // excluded because better-auth serves its public Google sign-in/session +
    // OAuth-callback endpoints there and must NOT be 307'd to /login, or the
    // login/callback requests never reach the handler. `signup` remains
    // public only so the legacy URL can redirect to /login. `shared-results/*`
    // is the tokenized read-only share page — SharePanel promises "no account,
    // no signing in", and the token itself is the capability. `invitations/*`
    // (the accept page) and `api/invitations/accept` (its GET preview) are
    // public for the same reason: the 256-bit invitation token IS the read
    // capability. POST accept keeps its own route-level session 401 (DAL
    // enforcement) — this matcher is only the UX redirect, never the security
    // boundary. `api/shared-results/*` (purses-21x5.1) is the same share-token
    // capability applied to its photo byte route: the page's <img> requests
    // carry no session cookie, and the route re-derives its own authz from the
    // token — this exclusion is only the UX bypass, the real gate lives in the
    // route.
    "/((?!_next(?:/|$)|api/auth(?:/|$)|login(?:/|$)|signup(?:/|$)|api/shared-results(?:/|$)|shared-results(?:/|$)|tutorial(?:/|$)|tutorials(?:/|$)|onboarding(?:/|$)|invitations(?:/|$)|api/invitations/accept(?:/|$)).*)",
  ],
};
 
// Optimistic auth (Cutover Pass 1, Phase 3). better-auth's getSessionCookie is a
// PRESENCE check on the session cookie — no DB read at the edge, so it can't
// distinguish a valid session from a forged cookie. That's fine: real
// enforcement lives in the DAL (every route/page independently calls
// requireAuth/requireApiCapability). Middleware is only the UX redirect. The
// SESSIONLESS_POST_ROUTES allowlist (Vercel Blob's upload-completion callback,
// which self-verifies its Blob-signed payload) is preserved via
// isRequestAuthorized, exactly as under the old withAuth.
export default function proxy(request: NextRequest) {
  if (process.env.NODE_ENV === "development" && process.env.AUTH_BYPASS === "1") {
    return NextResponse.next();
  }
 
  const hasCookie = getSessionCookie(request) !== null;
  if (isRequestAuthorized(request.nextUrl.pathname, hasCookie)) {
    return NextResponse.next();
  }
  const loginUrl = new URL("/login", request.url);
  // Carry the original path so sign-in lands back on it — an emailed
  // /invitations/[token] link must survive the login round-trip. Path only
  // (always "/"-rooted), never a full URL, so this can't open-redirect.
  loginUrl.searchParams.set("callbackUrl", request.nextUrl.pathname);
  return NextResponse.redirect(loginUrl);
}