← back to Govarbitrage

src/middleware.ts

122 lines

import { NextRequest, NextResponse } from "next/server";
import { SESSION_COOKIE, SESSION_MAX_AGE, createSession, verifySession } from "@/lib/session";
import { FLEET_SSO_COOKIE, fleetSsoOk } from "@/lib/fleet-sso";

// Public paths that never require a session:
//  - /login and the auth API
//  - /b/<slug> public contingent buyer-interest pages
//  - the public buyer-lead submission endpoint (anonymous buyers)
const PUBLIC = [
  /^\/login(?:\/|$)/,
  /^\/api\/auth\//,
  /^\/b\//,
  /^\/api\/listings\/[^/]+\/buyer-lead$/,
  /^\/pricing(?:\/|$)/, // public marketing page (upgrade CTA)
  /^\/api\/billing\/webhook$/, // Stripe calls this server-to-server, no session
  /^\/newsletter(?:\/|$)/, // public digest subscribe landing
  /^\/api\/newsletter\/(?:subscribe|confirm|unsubscribe)$/, // anonymous subscribers + email links
  /^\/deals(?:\/|$)/, // public SEO digest archive (frozen snapshots, teaser numbers only)
  /^\/selling-avenues(?:\/|$)/, // public SEO reference (where/how to resell surplus) — crawlable, in sitemap
  /^\/sitemap\.xml$/, // crawlers (robots.txt is already excluded by the matcher)
  /^\/robots\.txt$/,
];

function isPublic(pathname: string): boolean {
  return PUBLIC.some((re) => re.test(pathname));
}

// Outer un/pw wall for the whole site (ideas/fleet-unified admin:DW2024!).
// Override with BASIC_AUTH="user:pass"; BASIC_AUTH="" disables. Runs in Edge → use atob, not Buffer.
const BASIC_AUTH = process.env.BASIC_AUTH ?? "admin:DW2024!";
function basicAuthOk(req: NextRequest): boolean {
  if (!BASIC_AUTH) return true;
  const m = (req.headers.get("authorization") || "").match(/^Basic\s+(.+)$/i);
  if (!m) return false;
  try { return atob(m[1]) === BASIC_AUTH; } catch { return false; }
}

export async function middleware(req: NextRequest) {
  const { pathname } = req.nextUrl;

  // Machine callers that cannot present a password bypass the wall:
  // Stripe's server-to-server webhook and the extension/CSV import-token clients.
  const importToken = process.env.IMPORT_TOKEN;
  const machineOk =
    pathname === "/api/billing/webhook" ||
    (!!importToken && req.headers.get("x-import-token") === importToken);
  if (!machineOk && !basicAuthOk(req)) {
    return new NextResponse("auth required", {
      status: 401,
      headers: { "WWW-Authenticate": 'Basic realm="GovArbitrage"' },
    });
  }

  if (isPublic(pathname)) return NextResponse.next();

  // Valid session cookie?
  const token = req.cookies.get(SESSION_COOKIE)?.value;
  const session = token ? await verifySession(token) : null;

  // Machine clients (extension / CSV import) may present the import token.
  const hasImportToken = !!importToken && req.headers.get("x-import-token") === importToken;

  if (session || hasImportToken) return NextResponse.next();

  // Basic-auth IS the login (Steve 2026-07-27: "my basic un and pw, not the email
  // login"). Any human who cleared the outer un/pw wall (the BASIC_AUTH creds) is
  // treated as ADMIN — we mint a real ADMIN session here so the app never bounces to the
  // email /login form. basicAuthOk is already true for every non-public/non-machine
  // request that reached this line (the wall 401s otherwise); the guard just makes
  // sure a machine-token-only caller doesn't get an admin cookie.
  if (basicAuthOk(req)) {
    const ga = await createSession({ sub: "basic-admin", email: "admin@agentabrams.com", role: "ADMIN" });
    const res = NextResponse.next();
    res.cookies.set(SESSION_COOKIE, ga, {
      httpOnly: true,
      secure: true,
      sameSite: "lax",
      path: "/",
      maxAge: SESSION_MAX_AGE,
    });
    return res;
  }

  // Fleet single-sign-on: a valid shared *.agentabrams.com "aafleet" cookie
  // establishes an ANALYST session here (operational — full data + imports +
  // buyer pages), so one fleet login carries into auctions with no second form.
  // The tier-0 credential surface still requires ADMIN, reached only via the
  // local username/password login. Rationale: the aafleet token carries no
  // per-user identity, audience, or role, so a leaked sibling-site cookie must
  // not unlock this app's encrypted provider credentials or Stripe/billing
  // state (DTD 2026-07-25, verdict B — scoped below ADMIN; bumped VIEWER→ANALYST
  // per Steve so the fleet view is operational, not read-only).
  // To fully sign out, log out at auth.agentabrams.com (clearing ga_session
  // alone would just be re-minted from aafleet).
  if (await fleetSsoOk(req.cookies.get(FLEET_SSO_COOKIE)?.value)) {
    const ga = await createSession({ sub: "fleet-sso", email: "fleet@agentabrams.com", role: "ANALYST" });
    const res = NextResponse.next();
    res.cookies.set(SESSION_COOKIE, ga, {
      httpOnly: true,
      secure: true,
      sameSite: "lax",
      path: "/",
      maxAge: SESSION_MAX_AGE,
    });
    return res;
  }

  // API → 401 JSON; page → redirect to login with a return path.
  if (pathname.startsWith("/api/")) {
    return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
  }
  const url = req.nextUrl.clone();
  url.pathname = "/login";
  url.searchParams.set("next", pathname);
  return NextResponse.redirect(url);
}

export const config = {
  // Run on everything except Next internals + static asset files.
  matcher: ["/((?!_next/static|_next/image|favicon.ico|.*\\.(?:png|jpg|jpeg|svg|gif|ico|css|js|map|txt)$).*)"],
};