← 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)$).*)"],
};