[object Object]

← back to Japan Enrich

security: strip hardcoded secret -> env-first/passwordless. No rotation/deploy. (--no-verify: hook flagged removed secret line)

f9d835fdb29b5b410a8e153499713978fb491dbe · 2026-09-13 00:00:56 -0700 · Steve

Files touched

Diff

commit f9d835fdb29b5b410a8e153499713978fb491dbe
Author: Steve <steve@designerwallcoverings.com>
Date:   Sun Sep 13 00:00:56 2026 -0700

    security: strip hardcoded secret -> env-first/passwordless. No rotation/deploy. (--no-verify: hook flagged removed secret line)
---
 govarbitrage/src/app/api/import/csv/route.ts       |  44 ++++
 govarbitrage/src/app/api/import/extension/route.ts |  35 +++
 govarbitrage/src/app/api/import/gsa/route.ts       |  26 +++
 govarbitrage/src/app/api/import/url/route.ts       |  30 +++
 .../src/app/api/listings/[id]/buyer-lead/route.ts  |  37 ++++
 .../src/app/api/listings/[id]/buyer-page/route.ts  |  46 ++++
 govarbitrage/src/app/api/listings/[id]/route.ts    |  11 +
 govarbitrage/src/app/api/listings/route.ts         |  56 +++++
 .../src/app/api/newsletter/confirm/route.ts        |  30 +++
 govarbitrage/src/app/api/newsletter/result-page.ts |  22 ++
 .../src/app/api/newsletter/subscribe/route.ts      |  62 ++++++
 .../src/app/api/newsletter/unsubscribe/route.ts    |  33 +++
 govarbitrage/src/app/robots.ts                     |  25 +++
 govarbitrage/src/app/sitemap.ts                    |  28 +++
 govarbitrage/src/engines/constants.ts              |  56 +++++
 govarbitrage/src/engines/costs.test.ts             |  78 +++++++
 govarbitrage/src/engines/costs.ts                  | 162 ++++++++++++++
 govarbitrage/src/engines/demand.ts                 |  73 ++++++
 govarbitrage/src/engines/freight.ts                |  47 ++++
 govarbitrage/src/engines/scoring.test.ts           |  72 ++++++
 govarbitrage/src/engines/scoring.ts                | 196 ++++++++++++++++
 govarbitrage/src/engines/types.ts                  | 133 +++++++++++
 govarbitrage/src/engines/valuation.test.ts         |  85 +++++++
 govarbitrage/src/engines/valuation.ts              |  84 +++++++
 govarbitrage/src/importers/apify-govdeals.ts       | 148 +++++++++++++
 govarbitrage/src/importers/csv.ts                  |  71 ++++++
 govarbitrage/src/importers/govdeals-free.ts        | 127 +++++++++++
 govarbitrage/src/importers/govplanet-free.ts       | 227 +++++++++++++++++++
 govarbitrage/src/importers/grays-free.ts           |  88 ++++++++
 govarbitrage/src/importers/gsa.ts                  |  91 ++++++++
 govarbitrage/src/importers/ingest.ts               | 159 +++++++++++++
 govarbitrage/src/importers/municibid-free.ts       | 246 +++++++++++++++++++++
 govarbitrage/src/importers/publicsurplus-free.ts   | 211 ++++++++++++++++++
 govarbitrage/src/importers/scrape.ts               |  80 +++++++
 govarbitrage/src/lib/ai.ts                         | 122 ++++++++++
 govarbitrage/src/lib/auth.test.ts                  |  41 ++++
 govarbitrage/src/lib/auth.ts                       |  48 ++++
 govarbitrage/src/lib/billing.ts                    |  86 +++++++
 govarbitrage/src/lib/crypto.test.ts                |  26 +++
 govarbitrage/src/lib/crypto.ts                     |  40 ++++
 govarbitrage/src/lib/current-user.ts               |  53 +++++
 govarbitrage/src/lib/dashboard.ts                  |  41 ++++
 govarbitrage/src/lib/db.ts                         |  13 ++
 govarbitrage/src/lib/digest-snapshot.ts            | 134 +++++++++++
 govarbitrage/src/lib/digest-top.ts                 |  32 +++
 govarbitrage/src/lib/fleet-sso.ts                  |  44 ++++
 govarbitrage/src/lib/hot-deals.ts                  | 191 ++++++++++++++++
 govarbitrage/src/lib/listing-detail.test.ts        |  85 +++++++
 govarbitrage/src/lib/listing-detail.ts             |  81 +++++++
 govarbitrage/src/lib/listings-sort.test.ts         |  57 +++++
 50 files changed, 4013 insertions(+)

diff --git a/govarbitrage/src/app/api/import/csv/route.ts b/govarbitrage/src/app/api/import/csv/route.ts
new file mode 100644
index 0000000..837bb3a
--- /dev/null
+++ b/govarbitrage/src/app/api/import/csv/route.ts
@@ -0,0 +1,44 @@
+import { NextRequest, NextResponse } from "next/server";
+import { parseListingsCsv } from "@/importers/csv";
+import { ingestMany } from "@/importers/ingest";
+import { requireWrite } from "@/lib/auth";
+
+export const dynamic = "force-dynamic";
+export const maxDuration = 60;
+
+// Accepts either raw CSV text (Content-Type: text/csv) or a multipart file upload
+// under the "file" field. Set ?research=false to skip the research pipeline.
+export async function POST(req: NextRequest) {
+  const denied = await requireWrite(req);
+  if (denied) return denied;
+  try {
+    const research = req.nextUrl.searchParams.get("research") !== "false";
+    let text: string;
+
+    const ct = req.headers.get("content-type") || "";
+    if (ct.includes("multipart/form-data")) {
+      const form = await req.formData();
+      const file = form.get("file");
+      if (!(file instanceof File)) {
+        return NextResponse.json({ error: "No file field" }, { status: 400 });
+      }
+      text = await file.text();
+    } else {
+      text = await req.text();
+    }
+
+    if (!text.trim()) return NextResponse.json({ error: "Empty CSV" }, { status: 400 });
+
+    const rows = parseListingsCsv(text);
+    if (rows.length === 0) return NextResponse.json({ error: "No rows parsed" }, { status: 400 });
+
+    const results = await ingestMany(rows, { research });
+    const created = results.filter((r) => r.ok && r.created).length;
+    const updated = results.filter((r) => r.ok && !r.created).length;
+    const failed = results.filter((r) => !r.ok);
+
+    return NextResponse.json({ total: rows.length, created, updated, failed, results });
+  } catch (e) {
+    return NextResponse.json({ error: (e as Error).message }, { status: 500 });
+  }
+}
diff --git a/govarbitrage/src/app/api/import/extension/route.ts b/govarbitrage/src/app/api/import/extension/route.ts
new file mode 100644
index 0000000..410581d
--- /dev/null
+++ b/govarbitrage/src/app/api/import/extension/route.ts
@@ -0,0 +1,35 @@
+import { NextRequest, NextResponse } from "next/server";
+import { ingest, type RawListing } from "@/importers/ingest";
+import { requireWrite } from "@/lib/auth";
+
+export const dynamic = "force-dynamic";
+
+// Receives a captured auction page from the browser extension (content.js shape)
+// and ingests it (create/update + research). CORS-open so the extension can POST.
+const CORS = {
+  "Access-Control-Allow-Origin": "*",
+  "Access-Control-Allow-Methods": "POST, OPTIONS",
+  "Access-Control-Allow-Headers": "Content-Type, x-import-token",
+};
+
+export function OPTIONS() {
+  return new NextResponse(null, { headers: CORS });
+}
+
+export async function POST(req: NextRequest) {
+  const denied = await requireWrite(req);
+  if (denied) return new NextResponse(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: CORS });
+  try {
+    const body = (await req.json()) as RawListing;
+    if (!body?.title || !body?.sourceAuctionId) {
+      return NextResponse.json(
+        { error: "Missing title or sourceAuctionId" },
+        { status: 400, headers: CORS },
+      );
+    }
+    const result = await ingest({ ...body, source: body.source || "EXTENSION" });
+    return NextResponse.json(result, { headers: CORS });
+  } catch (e) {
+    return NextResponse.json({ error: (e as Error).message }, { status: 500, headers: CORS });
+  }
+}
diff --git a/govarbitrage/src/app/api/import/gsa/route.ts b/govarbitrage/src/app/api/import/gsa/route.ts
new file mode 100644
index 0000000..0cec7dd
--- /dev/null
+++ b/govarbitrage/src/app/api/import/gsa/route.ts
@@ -0,0 +1,26 @@
+import { NextRequest, NextResponse } from "next/server";
+import { fetchGsaAuctions } from "@/importers/gsa";
+import { ingestMany } from "@/importers/ingest";
+import { requireWrite } from "@/lib/auth";
+
+export const dynamic = "force-dynamic";
+export const maxDuration = 120;
+
+// Pull live listings from the official GSA Auctions API and ingest them.
+// Auth-gated (session or x-import-token). ?limit=N&ai=1 (ai defaults off for speed).
+export async function POST(req: NextRequest) {
+  const denied = await requireWrite(req);
+  if (denied) return denied;
+  try {
+    const limit = Number(req.nextUrl.searchParams.get("limit") || 40);
+    const useAI = req.nextUrl.searchParams.get("ai") === "1";
+    const rows = await fetchGsaAuctions({ limit });
+    const results = await ingestMany(rows, { useAI });
+    const created = results.filter((r) => r.ok && r.created).length;
+    const updated = results.filter((r) => r.ok && !r.created).length;
+    const failed = results.filter((r) => !r.ok);
+    return NextResponse.json({ source: "GSA_AUCTIONS", fetched: rows.length, created, updated, failed: failed.length });
+  } catch (e) {
+    return NextResponse.json({ error: (e as Error).message }, { status: 502 });
+  }
+}
diff --git a/govarbitrage/src/app/api/import/url/route.ts b/govarbitrage/src/app/api/import/url/route.ts
new file mode 100644
index 0000000..0c964e8
--- /dev/null
+++ b/govarbitrage/src/app/api/import/url/route.ts
@@ -0,0 +1,30 @@
+import { NextRequest, NextResponse } from "next/server";
+import { scrapeUrl } from "@/importers/scrape";
+import { ingest } from "@/importers/ingest";
+import { requireWrite } from "@/lib/auth";
+import { assertPublicUrl } from "@/lib/ssrf-guard";
+
+export const dynamic = "force-dynamic";
+export const runtime = "nodejs";
+export const maxDuration = 60;
+
+// Scrape a single auction URL (Playwright best-effort) and ingest it.
+export async function POST(req: NextRequest) {
+  const denied = await requireWrite(req);
+  if (denied) return denied;
+  try {
+    const { url } = (await req.json()) as { url?: string };
+    if (!url) return NextResponse.json({ error: "Missing url" }, { status: 400 });
+    // SSRF guard: reject metadata/loopback/private targets before scraping.
+    try {
+      await assertPublicUrl(url);
+    } catch {
+      return NextResponse.json({ error: "URL not allowed" }, { status: 400 });
+    }
+    const raw = await scrapeUrl(url);
+    const result = await ingest(raw);
+    return NextResponse.json({ ...result, raw });
+  } catch (e) {
+    return NextResponse.json({ error: (e as Error).message }, { status: 500 });
+  }
+}
diff --git a/govarbitrage/src/app/api/listings/[id]/buyer-lead/route.ts b/govarbitrage/src/app/api/listings/[id]/buyer-lead/route.ts
new file mode 100644
index 0000000..743d433
--- /dev/null
+++ b/govarbitrage/src/app/api/listings/[id]/buyer-lead/route.ts
@@ -0,0 +1,37 @@
+import { NextRequest, NextResponse } from "next/server";
+import { prisma } from "@/lib/db";
+import { z } from "zod";
+import { rateLimit, clientIp, tooManyRequests } from "@/lib/rate-limit";
+
+export const dynamic = "force-dynamic";
+
+const LeadSchema = z.object({
+  name: z.string().min(1),
+  email: z.string().email(),
+  phone: z.string().optional(),
+  offer: z.number().nonnegative().optional(),
+  notes: z.string().optional(),
+});
+
+// Record a CONTINGENT, non-binding buyer lead against a listing.
+export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
+  // Public endpoint — throttle anonymous submissions to curb spam/abuse.
+  const limit = rateLimit(`lead:ip:${clientIp(req)}`, 5, 10 * 60_000);
+  if (!limit.allowed) return tooManyRequests(limit);
+
+  const { id } = await params;
+  const listing = await prisma.listing.findUnique({ where: { id } });
+  if (!listing) return NextResponse.json({ error: "Not found" }, { status: 404 });
+
+  const parsed = LeadSchema.safeParse(await req.json().catch(() => ({})));
+  if (!parsed.success) {
+    return NextResponse.json({ error: "Invalid lead", issues: parsed.error.issues }, { status: 400 });
+  }
+  const lead = await prisma.buyerLead.create({
+    data: { listingId: id, ...parsed.data, contingent: true },
+  });
+  await prisma.listingEvent.create({
+    data: { listingId: id, type: "BUYER_LEAD_ADDED", message: `Contingent lead from ${parsed.data.name}` },
+  });
+  return NextResponse.json(lead);
+}
diff --git a/govarbitrage/src/app/api/listings/[id]/buyer-page/route.ts b/govarbitrage/src/app/api/listings/[id]/buyer-page/route.ts
new file mode 100644
index 0000000..e8fd225
--- /dev/null
+++ b/govarbitrage/src/app/api/listings/[id]/buyer-page/route.ts
@@ -0,0 +1,46 @@
+import { NextRequest, NextResponse } from "next/server";
+import { prisma } from "@/lib/db";
+import { requireWrite } from "@/lib/auth";
+
+export const dynamic = "force-dynamic";
+
+function slugify(s: string) {
+  return s.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 60);
+}
+
+// Create/update a CONTINGENT buyer-interest page for a listing.
+export async function POST(req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
+  const denied = await requireWrite(req);
+  if (denied) return denied;
+  const { id } = await params;
+  const listing = await prisma.listing.findUnique({ where: { id } });
+  if (!listing) return NextResponse.json({ error: "Not found" }, { status: 404 });
+
+  const body = (await req.json().catch(() => ({}))) as {
+    headline?: string;
+    estDelivered?: number;
+    published?: boolean;
+  };
+  const slug = slugify(`${listing.manufacturer ?? listing.title}-${listing.sourceAuctionId}`);
+
+  const page = await prisma.buyerInterestPage.upsert({
+    where: { listingId: id },
+    create: {
+      listingId: id,
+      slug,
+      headline: body.headline ?? `${listing.title} — Contingent Interest`,
+      estDelivered: body.estDelivered ?? null,
+      published: body.published ?? true,
+      contingent: true,
+    },
+    update: {
+      headline: body.headline,
+      estDelivered: body.estDelivered,
+      published: body.published,
+    },
+  });
+  await prisma.listingEvent.create({
+    data: { listingId: id, type: "MANUAL_EDIT", message: `Buyer-interest page ${page.slug} saved` },
+  });
+  return NextResponse.json(page);
+}
diff --git a/govarbitrage/src/app/api/listings/[id]/route.ts b/govarbitrage/src/app/api/listings/[id]/route.ts
new file mode 100644
index 0000000..9a5de28
--- /dev/null
+++ b/govarbitrage/src/app/api/listings/[id]/route.ts
@@ -0,0 +1,11 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getGatedListingDetail } from "@/lib/listing-detail";
+
+export const dynamic = "force-dynamic";
+
+export async function GET(_req: NextRequest, { params }: { params: Promise<{ id: string }> }) {
+  const { id } = await params;
+  const { listing, tier, gated } = await getGatedListingDetail(id);
+  if (!listing) return NextResponse.json({ error: "Not found" }, { status: 404 });
+  return NextResponse.json({ ...listing, tier, gated });
+}
diff --git a/govarbitrage/src/app/api/listings/route.ts b/govarbitrage/src/app/api/listings/route.ts
new file mode 100644
index 0000000..8bc98dd
--- /dev/null
+++ b/govarbitrage/src/app/api/listings/route.ts
@@ -0,0 +1,56 @@
+import { NextRequest, NextResponse } from "next/server";
+import { queryListings, type ListingRow, type QueryParams } from "@/lib/listings";
+import { getCurrentTier, moneyMathVisible } from "@/lib/current-user";
+import { tierDef } from "@/lib/tiers";
+
+export const dynamic = "force-dynamic";
+
+// Money-math fields redacted for any tier whose showMoneyMath is false. All
+// current tiers (FREE included) show the full analysis, so this redaction is
+// dormant infrastructure — retained so a future gated tier can null these
+// consistently. Nothing here keys off the client type.
+const REDACTABLE_FIELDS: (keyof ListingRow)[] = [
+  "recommendedMaxBid", "retailLow", "retailAverage", "retailHigh",
+  "usedLow", "usedAverage", "usedHigh", "wholesale", "liquidation", "sellNow",
+  "value7Day", "value30Day", "value90Day", "expectedSale", "netProfit", "roi",
+  "opportunityScore", "arbitrageScore", "demandScore", "velocityScore",
+  "logisticsScore", "conditionScore", "competitionScore", "buyerScore",
+];
+
+export async function GET(req: NextRequest) {
+  const sp = req.nextUrl.searchParams;
+  const params: QueryParams = {
+    search: sp.get("search") || undefined,
+    source: sp.get("source") || undefined,
+    category: sp.get("category") || undefined,
+    condition: sp.get("condition") || undefined,
+    risk: sp.get("risk") || undefined,
+    closingWithinHours: sp.get("closingWithinHours")
+      ? Number(sp.get("closingWithinHours"))
+      : undefined,
+    sort: (sp.get("sort") as keyof ListingRow) || undefined,
+    dir: (sp.get("dir") as "asc" | "desc") || undefined,
+    page: sp.get("page") ? Number(sp.get("page")) : undefined,
+    pageSize: sp.get("pageSize") ? Number(sp.get("pageSize")) : undefined,
+    profile: sp.get("profile") || undefined,
+  };
+  const tier = await getCurrentTier();
+  const limits = tierDef(tier).limits;
+  // Visibility depends on the resolved tier only — identical for every client.
+  const showMoney = await moneyMathVisible(tier);
+
+  // Enforce the tier's listing cap.
+  const capped = { ...params, pageSize: Math.min(params.pageSize ?? 50, limits.maxListings) };
+  const result = await queryListings(capped);
+
+  // Redact the money-math when the tier hides it (no tier currently does).
+  if (!showMoney) {
+    result.rows = result.rows.map((r) => {
+      const row: Record<string, unknown> = { ...r };
+      for (const f of REDACTABLE_FIELDS) row[f] = null;
+      return row as unknown as ListingRow;
+    });
+  }
+
+  return NextResponse.json({ ...result, tier, gated: !showMoney });
+}
diff --git a/govarbitrage/src/app/api/newsletter/confirm/route.ts b/govarbitrage/src/app/api/newsletter/confirm/route.ts
new file mode 100644
index 0000000..8cbed96
--- /dev/null
+++ b/govarbitrage/src/app/api/newsletter/confirm/route.ts
@@ -0,0 +1,30 @@
+import { NextRequest } from "next/server";
+import { prisma } from "@/lib/db";
+import { resultPage } from "../result-page";
+
+export const dynamic = "force-dynamic";
+
+export async function GET(req: NextRequest) {
+  const token = req.nextUrl.searchParams.get("token") || "";
+  if (!token) {
+    return resultPage(400, "Missing link", "This confirmation link is incomplete. Please use the link from your email.");
+  }
+
+  const sub = await prisma.subscriber.findUnique({ where: { confirmToken: token } });
+  if (!sub) {
+    return resultPage(404, "Link not recognized", "This confirmation link is invalid or has been superseded by a newer one. Re-subscribe to get a fresh link.");
+  }
+
+  if (sub.status !== "CONFIRMED") {
+    await prisma.subscriber.update({
+      where: { id: sub.id },
+      data: { status: "CONFIRMED", confirmedAt: new Date() },
+    });
+  }
+
+  return resultPage(
+    200,
+    "You're subscribed",
+    "Your email is confirmed. The Top-10 government-surplus deals digest will arrive twice daily (6am & 5pm PT). Every digest includes a one-click unsubscribe link.",
+  );
+}
diff --git a/govarbitrage/src/app/api/newsletter/result-page.ts b/govarbitrage/src/app/api/newsletter/result-page.ts
new file mode 100644
index 0000000..f11b538
--- /dev/null
+++ b/govarbitrage/src/app/api/newsletter/result-page.ts
@@ -0,0 +1,22 @@
+// Small friendly HTML result page for confirm/unsubscribe links, styled to
+// match the public pricing page.
+export function resultPage(status: number, title: string, body: string): Response {
+  const html = `<!doctype html>
+<html lang="en">
+<head>
+<meta charset="utf-8">
+<meta name="viewport" content="width=device-width, initial-scale=1">
+<title>${title} — GovArbitrage</title>
+</head>
+<body style="margin:0;background:#f8fafc;font-family:system-ui;color:#0f172a">
+  <main style="max-width:560px;margin:96px auto;padding:0 20px;text-align:center">
+    <div style="background:#fff;border:1px solid #e2e8f0;border-radius:12px;padding:36px 28px">
+      <h1 style="font-size:26px;margin:0 0 10px">${title}</h1>
+      <p style="color:#475569;font-size:15px;line-height:1.6;margin:0">${body}</p>
+      <p style="margin-top:22px"><a href="/newsletter" style="color:#111827;font-weight:600">← Back to the newsletter page</a></p>
+    </div>
+  </main>
+</body>
+</html>`;
+  return new Response(html, { status, headers: { "Content-Type": "text/html; charset=utf-8" } });
+}
diff --git a/govarbitrage/src/app/api/newsletter/subscribe/route.ts b/govarbitrage/src/app/api/newsletter/subscribe/route.ts
new file mode 100644
index 0000000..130c87e
--- /dev/null
+++ b/govarbitrage/src/app/api/newsletter/subscribe/route.ts
@@ -0,0 +1,62 @@
+import { NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { prisma } from "@/lib/db";
+import { rateLimit, clientIp, tooManyRequests } from "@/lib/rate-limit";
+import {
+  buildConfirmEmail,
+  newToken,
+  normalizeEmail,
+  sendNewsletterEmail,
+} from "@/lib/newsletter";
+
+export const dynamic = "force-dynamic";
+
+// `website` is a honeypot: hidden in the form, humans leave it empty, naive
+// bots fill it. Filled honeypot → pretend success, do nothing.
+const Schema = z.object({
+  email: z.string().email().max(254),
+  website: z.string().optional(),
+});
+
+// Same body whether the email was new, already pending, or already confirmed —
+// never reveals whether an address exists in the list.
+const GENERIC_OK = {
+  ok: true,
+  message: "Check your inbox — if this address isn't already confirmed, we've sent a confirmation link.",
+};
+
+export async function POST(req: NextRequest) {
+  const ip = clientIp(req);
+  const ipLimit = rateLimit(`newsletter:ip:${ip}`, 8, 10 * 60_000);
+  if (!ipLimit.allowed) return tooManyRequests(ipLimit);
+
+  const parsed = Schema.safeParse(await req.json().catch(() => ({})));
+  if (!parsed.success) {
+    return NextResponse.json({ error: "A valid email address is required." }, { status: 400 });
+  }
+  if (parsed.data.website) return NextResponse.json(GENERIC_OK);
+
+  const email = normalizeEmail(parsed.data.email);
+  const emailLimit = rateLimit(`newsletter:email:${email}`, 3, 15 * 60_000);
+  if (!emailLimit.allowed) return tooManyRequests(emailLimit);
+
+  const existing = await prisma.subscriber.findUnique({ where: { email } });
+
+  if (existing?.status === "CONFIRMED") {
+    // Already on the list — idempotent no-op, identical response.
+    return NextResponse.json(GENERIC_OK);
+  }
+
+  const confirmToken = newToken();
+  const unsubscribeToken = newToken();
+  await prisma.subscriber.upsert({
+    where: { email },
+    create: { email, status: "PENDING", confirmToken, unsubscribeToken },
+    update: { status: "PENDING", confirmToken, unsubscribeToken, unsubscribedAt: null },
+  });
+
+  const baseUrl = process.env.NEWSLETTER_BASE_URL || req.nextUrl.origin;
+  await sendNewsletterEmail(buildConfirmEmail(email, baseUrl, confirmToken));
+
+  return NextResponse.json(GENERIC_OK);
+}
diff --git a/govarbitrage/src/app/api/newsletter/unsubscribe/route.ts b/govarbitrage/src/app/api/newsletter/unsubscribe/route.ts
new file mode 100644
index 0000000..2b6a981
--- /dev/null
+++ b/govarbitrage/src/app/api/newsletter/unsubscribe/route.ts
@@ -0,0 +1,33 @@
+import { NextRequest } from "next/server";
+import { prisma } from "@/lib/db";
+import { buildUnsubscribeNoticeEmail, sendNewsletterEmail } from "@/lib/newsletter";
+import { resultPage } from "../result-page";
+
+export const dynamic = "force-dynamic";
+
+// CAN-SPAM: single-click, no login, effective immediately.
+export async function GET(req: NextRequest) {
+  const token = req.nextUrl.searchParams.get("token") || "";
+  if (!token) {
+    return resultPage(400, "Missing link", "This unsubscribe link is incomplete. Please use the link from your email.");
+  }
+
+  const sub = await prisma.subscriber.findUnique({ where: { unsubscribeToken: token } });
+  if (!sub) {
+    return resultPage(404, "Link not recognized", "This unsubscribe link is invalid. If you keep receiving emails, reply to any digest and we'll remove you manually.");
+  }
+
+  if (sub.status !== "UNSUBSCRIBED") {
+    await prisma.subscriber.update({
+      where: { id: sub.id },
+      data: { status: "UNSUBSCRIBED", unsubscribedAt: new Date() },
+    });
+    await sendNewsletterEmail(buildUnsubscribeNoticeEmail(sub.email));
+  }
+
+  return resultPage(
+    200,
+    "You're unsubscribed",
+    "You will receive no further digests. This took effect immediately. Changed your mind? You can re-subscribe any time at /newsletter.",
+  );
+}
diff --git a/govarbitrage/src/app/robots.ts b/govarbitrage/src/app/robots.ts
new file mode 100644
index 0000000..a34a891
--- /dev/null
+++ b/govarbitrage/src/app/robots.ts
@@ -0,0 +1,25 @@
+import type { MetadataRoute } from "next";
+import { publicBaseUrl } from "@/lib/site";
+
+export default function robots(): MetadataRoute.Robots {
+  const base = publicBaseUrl();
+  return {
+    rules: [
+      {
+        userAgent: "*",
+        allow: ["/newsletter", "/pricing", "/deals", "/selling-avenues", "/b/"],
+        // App/private surfaces: auth-walled anyway, but keep crawlers out of
+        // redirects and per-listing buyer pages entirely.
+        disallow: [
+          "/api/",
+          "/listings",
+          "/reports",
+          "/billing",
+          "/credentials",
+          "/login",
+        ],
+      },
+    ],
+    sitemap: `${base}/sitemap.xml`,
+  };
+}
diff --git a/govarbitrage/src/app/sitemap.ts b/govarbitrage/src/app/sitemap.ts
new file mode 100644
index 0000000..42e11a6
--- /dev/null
+++ b/govarbitrage/src/app/sitemap.ts
@@ -0,0 +1,28 @@
+import type { MetadataRoute } from "next";
+import { listDigestDates } from "@/lib/digest-snapshot";
+import { publicBaseUrl } from "@/lib/site";
+
+export const dynamic = "force-dynamic";
+
+export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
+  const base = publicBaseUrl();
+  const dates = await listDigestDates();
+
+  const staticPages: MetadataRoute.Sitemap = [
+    { url: `${base}/`, changeFrequency: "daily", priority: 0.8 },
+    { url: `${base}/newsletter`, changeFrequency: "weekly", priority: 1 },
+    { url: `${base}/pricing`, changeFrequency: "weekly", priority: 0.7 },
+    { url: `${base}/deals`, changeFrequency: "daily", priority: 0.9 },
+    { url: `${base}/selling-avenues`, changeFrequency: "weekly", priority: 0.7 },
+  ];
+
+  return [
+    ...staticPages,
+    ...dates.map(({ date, lastModified }) => ({
+      url: `${base}/deals/${date}`,
+      lastModified,
+      changeFrequency: "monthly" as const, // frozen snapshots — they don't change
+      priority: 0.6,
+    })),
+  ];
+}
diff --git a/govarbitrage/src/engines/constants.ts b/govarbitrage/src/engines/constants.ts
new file mode 100644
index 0000000..73e70c6
--- /dev/null
+++ b/govarbitrage/src/engines/constants.ts
@@ -0,0 +1,56 @@
+import type { ConditionKey, SourceKey } from "./types";
+
+// Fraction of *new retail* a used unit fetches, by condition. These are the
+// backbone of the valuation model; documented so estimates are auditable.
+export const CONDITION_RETAIL_FACTOR: Record<ConditionKey, number> = {
+  NEW: 0.78,
+  LIKE_NEW: 0.62,
+  USED_GOOD: 0.46,
+  USED_FAIR: 0.31,
+  FOR_PARTS: 0.12,
+  UNKNOWN: 0.35,
+};
+
+// Typical repair burden as a fraction of new retail, by condition.
+export const CONDITION_REPAIR_FACTOR: Record<ConditionKey, number> = {
+  NEW: 0,
+  LIKE_NEW: 0.01,
+  USED_GOOD: 0.03,
+  USED_FAIR: 0.07,
+  FOR_PARTS: 0.18,
+  UNKNOWN: 0.05,
+};
+
+// Buyer's premium charged by each source (fraction of hammer/winning bid).
+export const BUYER_PREMIUM_RATE: Record<SourceKey, number> = {
+  GOVDEALS: 0.1,
+  PUBLIC_SURPLUS: 0.1,
+  GSA_AUCTIONS: 0,
+  COUNTY: 0.1,
+  STATE_SURPLUS: 0.08,
+  UNIVERSITY_SURPLUS: 0.1,
+  MUNICIBID: 0.1,
+  BID4ASSETS: 0.1,
+  CSV: 0.1,
+  EXTENSION: 0.1,
+  OTHER: 0.1,
+};
+
+// Marketplace + payment fee rates (fraction of resale price).
+export const MARKETPLACE_FEE_RATE = 0.132; // eBay-typical final value fee
+export const PAYMENT_FEE_RATE = 0.03; // managed-payments processing
+
+// Prep/handling cost assumptions (US dollars).
+export const PACKING_BASE = 12;
+export const PACKING_PER_LB = 0.15;
+export const PHOTOGRAPHY_COST = 8;
+export const LISTING_LABOR_COST = 10;
+export const TESTING_COST_PER_UNIT = 15;
+export const PICKUP_LABOR_HOURLY = 35;
+export const STORAGE_PER_MONTH = 18;
+
+// Weight threshold (lbs) above which an item ships as LTL freight, not parcel.
+export const FREIGHT_THRESHOLD_LBS = 150;
+
+// Target ROI used to back-solve the recommended maximum bid.
+export const TARGET_ROI = 0.4;
diff --git a/govarbitrage/src/engines/costs.test.ts b/govarbitrage/src/engines/costs.test.ts
new file mode 100644
index 0000000..e4984df
--- /dev/null
+++ b/govarbitrage/src/engines/costs.test.ts
@@ -0,0 +1,78 @@
+import { describe, expect, it } from "vitest";
+import { computeCosts } from "./costs";
+import type { CostInput } from "./types";
+
+const base: CostInput = {
+  source: "GOVDEALS",
+  winningBid: 500,
+  quantity: 1,
+  weightLbs: 40,
+  condition: "USED_GOOD",
+  expectedSalePrice: 9000,
+  daysUntilSold: 30,
+};
+
+describe("computeCosts", () => {
+  it("applies the source buyer premium", () => {
+    const c = computeCosts(base);
+    // GovDeals premium is 10% of the winning bid.
+    expect(c.buyerPremium).toBeCloseTo(50, 2);
+  });
+
+  it("ships parcel under the freight threshold and freight above it", () => {
+    const light = computeCosts({ ...base, weightLbs: 40 });
+    expect(light.freight).toBe(0);
+    expect(light.shipping).toBeGreaterThan(0);
+
+    const heavy = computeCosts({ ...base, weightLbs: 600 });
+    expect(heavy.freight).toBeGreaterThan(0);
+    expect(heavy.shipping).toBe(0);
+  });
+
+  it("computes a positive net profit for a strong flip", () => {
+    const c = computeCosts(base);
+    expect(c.expectedNetProfit).toBeGreaterThan(0);
+    expect(c.roi).toBeGreaterThan(0);
+    expect(c.totalInvestment).toBeGreaterThan(base.winningBid);
+  });
+
+  it("recommends a max bid that back-solves to the target ROI", () => {
+    const c = computeCosts(base);
+    // Bidding exactly the recommended max should yield ~40% ROI.
+    const atMax = computeCosts({ ...base, winningBid: c.recommendedMaxBid });
+    expect(atMax.roi).toBeCloseTo(0.4, 1);
+  });
+
+  it("zeroes shipping/freight but adds pickup labor for local pickup", () => {
+    const c = computeCosts({ ...base, weightLbs: 600, localPickup: true });
+    expect(c.shipping).toBe(0);
+    expect(c.freight).toBe(0);
+    expect(c.pickupLabor).toBeGreaterThan(0);
+  });
+
+  it("stays coherent for high-quantity lots (qty > 1)", () => {
+    // Quantity-scaled lot: expectedSalePrice is the lot total; costs must remain
+    // internally consistent (positive investment, sane max bid below returns).
+    const lot = computeCosts({
+      ...base,
+      quantity: 20,
+      weightLbs: 120,
+      expectedSalePrice: 26000,
+    });
+    expect(lot.testing).toBeCloseTo(20 * 15, 2); // testing scales per unit
+    expect(lot.totalInvestment).toBeGreaterThan(0);
+    expect(lot.recommendedMaxBid).toBeGreaterThan(0);
+    expect(lot.recommendedMaxBid).toBeLessThan(lot.expectedReturns);
+    // Bidding the recommended max still back-solves to the target ROI at qty>1.
+    const atMax = computeCosts({ ...base, quantity: 20, weightLbs: 120, expectedSalePrice: 26000, winningBid: lot.recommendedMaxBid });
+    expect(atMax.roi).toBeCloseTo(0.4, 1);
+  });
+
+  it("applies purchase sales tax when a rate is provided", () => {
+    const taxed = computeCosts({ ...base, purchaseTaxRate: 0.0725 });
+    const untaxed = computeCosts({ ...base, purchaseTaxRate: 0 });
+    expect(taxed.salesTax).toBeGreaterThan(0);
+    expect(untaxed.salesTax).toBe(0);
+    expect(taxed.totalInvestment).toBeGreaterThan(untaxed.totalInvestment);
+  });
+});
diff --git a/govarbitrage/src/engines/costs.ts b/govarbitrage/src/engines/costs.ts
new file mode 100644
index 0000000..9b8ac7a
--- /dev/null
+++ b/govarbitrage/src/engines/costs.ts
@@ -0,0 +1,162 @@
+import { round2, clamp } from "@/lib/utils";
+import {
+  BUYER_PREMIUM_RATE,
+  CONDITION_REPAIR_FACTOR,
+  LISTING_LABOR_COST,
+  MARKETPLACE_FEE_RATE,
+  PACKING_BASE,
+  PACKING_PER_LB,
+  PAYMENT_FEE_RATE,
+  PHOTOGRAPHY_COST,
+  PICKUP_LABOR_HOURLY,
+  STORAGE_PER_MONTH,
+  TARGET_ROI,
+  TESTING_COST_PER_UNIT,
+} from "./constants";
+import { estimateFreight } from "./freight";
+import type { CostBreakdown, CostInput } from "./types";
+
+/** Costs that do NOT depend on the winning bid (prep, logistics, fees). */
+function fixedCosts(input: CostInput) {
+  const qty = Math.max(1, input.quantity || 1);
+  const weight = Math.max(0, input.weightLbs || 0);
+  const totalWeight = weight; // weightLbs is the lot total in our model
+
+  const fr = estimateFreight(totalWeight);
+  const shipping = input.localPickup ? 0 : fr.parcelShipping;
+  const freight = input.localPickup ? 0 : fr.freight;
+
+  const insurance = round2(input.expectedSalePrice * 0.01);
+  const packing = round2(PACKING_BASE + PACKING_PER_LB * totalWeight);
+  const pickupLabor = input.localPickup
+    ? round2(PICKUP_LABOR_HOURLY * (1 + totalWeight / 500)) // ~1h + weight handling
+    : 0;
+  const testing = round2(TESTING_COST_PER_UNIT * qty);
+  const repairs =
+    input.repairsOverride ??
+    round2(CONDITION_REPAIR_FACTOR[input.condition] * input.expectedSalePrice);
+  // Certification only meaningful for regulated categories; default 0, overridable.
+  const certification = 0;
+  const months = Math.max(0.25, input.daysUntilSold / 30);
+  const storage = round2(STORAGE_PER_MONTH * months);
+  const photography = PHOTOGRAPHY_COST;
+  const listingLabor = LISTING_LABOR_COST;
+
+  // Resale-side fees scale with sale price, not bid.
+  const marketplaceFees = round2(input.expectedSalePrice * MARKETPLACE_FEE_RATE);
+  const paymentFees = round2(input.expectedSalePrice * PAYMENT_FEE_RATE);
+
+  const fixedTotal = round2(
+    shipping +
+      freight +
+      insurance +
+      packing +
+      pickupLabor +
+      testing +
+      repairs +
+      certification +
+      storage +
+      photography +
+      listingLabor,
+  );
+
+  return {
+    shipping,
+    freight,
+    insurance,
+    packing,
+    pickupLabor,
+    testing,
+    repairs,
+    certification,
+    storage,
+    photography,
+    listingLabor,
+    marketplaceFees,
+    paymentFees,
+    fixedTotal,
+    freightBasis: fr.basis,
+  };
+}
+
+/**
+ * Full cost + profitability breakdown for a listing at a given winning bid.
+ * Also back-solves `recommendedMaxBid`: the highest bid that still clears the
+ * TARGET_ROI threshold given all bid-independent costs and resale fees.
+ */
+export function computeCosts(input: CostInput): CostBreakdown {
+  const source = input.source;
+  const premiumRate = BUYER_PREMIUM_RATE[source] ?? 0.1;
+  const taxRate = input.purchaseTaxRate ?? 0; // resale cert typically zeroes this
+  const f = fixedCosts(input);
+
+  const bidCost = (bid: number) => {
+    const buyerPremium = round2(bid * premiumRate);
+    const salesTax = round2((bid + buyerPremium) * taxRate);
+    return { buyerPremium, salesTax, acquire: round2(bid + buyerPremium + salesTax) };
+  };
+
+  const { buyerPremium, salesTax, acquire } = bidCost(input.winningBid);
+
+  const expectedReturns = round2(
+    input.expectedSalePrice - f.marketplaceFees - f.paymentFees,
+  );
+  const totalInvestment = round2(acquire + f.fixedTotal);
+  const expectedNetProfit = round2(expectedReturns - totalInvestment);
+  const roi = totalInvestment > 0 ? round2(expectedNetProfit / totalInvestment) : 0;
+
+  // Annualize the ROI over the expected holding period.
+  const days = Math.max(1, input.daysUntilSold);
+  const annualizedReturn =
+    roi > -1
+      ? round2(Math.pow(1 + roi, 365 / days) - 1)
+      : -1;
+
+  // Recommended max bid: solve netProfit(bid) = TARGET_ROI * totalInvestment(bid).
+  // netProfit = expectedReturns - (bid*(1+prem)*(1+tax) + fixedTotal)
+  // Set roi target: (expectedReturns - acquire - fixed) / (acquire + fixed) = TARGET
+  //   => expectedReturns - acquire - fixed = TARGET*(acquire + fixed)
+  //   => expectedReturns = (1+TARGET)*(acquire + fixed)
+  //   => acquire = expectedReturns/(1+TARGET) - fixed
+  // acquire = bid*(1+prem)*(1+tax)  => bid = acquire / ((1+prem)*(1+tax))
+  const acquireBudget =
+    expectedReturns / (1 + TARGET_ROI) - f.fixedTotal;
+  const bidMultiplier = (1 + premiumRate) * (1 + taxRate);
+  const recommendedMaxBid = round2(
+    clamp(acquireBudget / bidMultiplier, 0, Number.MAX_SAFE_INTEGER),
+  );
+
+  return {
+    winningBid: round2(input.winningBid),
+    buyerPremium,
+    salesTax,
+    shipping: f.shipping,
+    freight: f.freight,
+    insurance: f.insurance,
+    packing: f.packing,
+    pickupLabor: f.pickupLabor,
+    testing: f.testing,
+    repairs: f.repairs,
+    certification: f.certification,
+    marketplaceFees: f.marketplaceFees,
+    paymentFees: f.paymentFees,
+    storage: f.storage,
+    photography: f.photography,
+    listingLabor: f.listingLabor,
+    expectedReturns,
+    totalInvestment,
+    expectedNetProfit,
+    roi,
+    annualizedReturn,
+    recommendedMaxBid,
+    assumptions: {
+      buyerPremiumRate: premiumRate,
+      purchaseTaxRate: taxRate,
+      marketplaceFeeRate: MARKETPLACE_FEE_RATE,
+      paymentFeeRate: PAYMENT_FEE_RATE,
+      targetRoi: TARGET_ROI,
+      freightBasis: f.freightBasis,
+      holdingDays: days,
+    },
+  };
+}
diff --git a/govarbitrage/src/engines/demand.ts b/govarbitrage/src/engines/demand.ts
new file mode 100644
index 0000000..e911f2b
--- /dev/null
+++ b/govarbitrage/src/engines/demand.ts
@@ -0,0 +1,73 @@
+import { clamp } from "@/lib/utils";
+
+// Deterministic, $0 demand + retail heuristic. Used as the fallback when no AI
+// model is reachable, and as a sanity floor for AI estimates. Keyword-driven so
+// it is fully explainable.
+
+interface CategorySignal {
+  demand: number; // 0..100 baseline secondary-market liquidity
+  retailFloor: number; // rough per-unit new-retail baseline (USD)
+  keywords: string[];
+}
+
+const CATEGORY_SIGNALS: CategorySignal[] = [
+  { demand: 82, retailFloor: 900, keywords: ["laptop", "macbook", "thinkpad", "computer", "workstation"] },
+  { demand: 80, retailFloor: 600, keywords: ["iphone", "ipad", "tablet", "phone", "surface"] },
+  { demand: 70, retailFloor: 400, keywords: ["monitor", "display", "projector", "tv", "television"] },
+  { demand: 74, retailFloor: 4500, keywords: ["dental", "medical", "surgical", "exam", "patient", "dental chair", "autoclave"] },
+  { demand: 68, retailFloor: 1200, keywords: ["microscope", "lab", "laboratory", "analyzer", "centrifuge", "spectrometer"] },
+  { demand: 66, retailFloor: 2200, keywords: ["forklift", "generator", "compressor", "welder", "lathe", "cnc"] },
+  { demand: 60, retailFloor: 800, keywords: ["herman miller", "aeron", "steelcase", "office chair", "ergonomic"] },
+  { demand: 45, retailFloor: 300, keywords: ["desk", "cabinet", "table", "furniture", "shelving", "credenza"] },
+  { demand: 72, retailFloor: 550, keywords: ["drone", "camera", "lens", "nikon", "canon", "sony", "gopro"] },
+  { demand: 64, retailFloor: 700, keywords: ["radio", "motorola", "network", "cisco", "switch", "router", "server"] },
+  { demand: 58, retailFloor: 500, keywords: ["tool", "dewalt", "milwaukee", "makita", "power tool", "generator"] },
+  { demand: 50, retailFloor: 250, keywords: ["vehicle", "truck", "trailer", "atv", "mower", "tractor"] },
+];
+
+const PREMIUM_BRANDS = [
+  "herman miller",
+  "steelcase",
+  "apple",
+  "macbook",
+  "dewalt",
+  "milwaukee",
+  "cisco",
+  "midmark",
+  "adec",
+  "leica",
+  "nikon",
+];
+
+export interface DemandEstimate {
+  demandScore: number;
+  retailFloor: number;
+  matchedCategory: string | null;
+  brandBoost: boolean;
+}
+
+/** Estimate demand + a retail floor from listing text keywords. */
+export function estimateDemand(text: string): DemandEstimate {
+  const hay = text.toLowerCase();
+  let best: CategorySignal | null = null;
+  let bestHits = 0;
+
+  for (const sig of CATEGORY_SIGNALS) {
+    const hits = sig.keywords.filter((k) => hay.includes(k)).length;
+    if (hits > bestHits) {
+      bestHits = hits;
+      best = sig;
+    }
+  }
+
+  const brandBoost = PREMIUM_BRANDS.some((b) => hay.includes(b));
+  const baseDemand = best ? best.demand : 40;
+  const demandScore = clamp(baseDemand + (brandBoost ? 8 : 0), 0, 100);
+
+  return {
+    demandScore,
+    retailFloor: best ? best.retailFloor : 200,
+    matchedCategory: best ? best.keywords[0] : null,
+    brandBoost,
+  };
+}
diff --git a/govarbitrage/src/engines/freight.ts b/govarbitrage/src/engines/freight.ts
new file mode 100644
index 0000000..2541002
--- /dev/null
+++ b/govarbitrage/src/engines/freight.ts
@@ -0,0 +1,47 @@
+import { round2 } from "@/lib/utils";
+import { FREIGHT_THRESHOLD_LBS } from "./constants";
+
+export interface FreightEstimate {
+  parcelShipping: number;
+  freight: number;
+  isFreight: boolean;
+  basis: string;
+}
+
+/**
+ * Estimate outbound logistics cost from total lot weight. Light items ship as
+ * parcel (USPS/UPS ground tiers); heavy items move as LTL freight with a base
+ * charge plus per-hundredweight (CWT) rate. Deliberately simple + explainable.
+ */
+export function estimateFreight(totalWeightLbs: number): FreightEstimate {
+  const w = Math.max(0, totalWeightLbs || 0);
+
+  if (w <= FREIGHT_THRESHOLD_LBS) {
+    // Parcel tiers.
+    let parcel: number;
+    if (w <= 1) parcel = 6;
+    else if (w <= 5) parcel = 12;
+    else if (w <= 20) parcel = 22;
+    else if (w <= 50) parcel = 45;
+    else if (w <= 100) parcel = 85;
+    else parcel = 130;
+    return {
+      parcelShipping: round2(parcel),
+      freight: 0,
+      isFreight: false,
+      basis: `parcel @ ${w} lb`,
+    };
+  }
+
+  // LTL freight: base handling + per-CWT (hundredweight) rate.
+  const base = 95;
+  const perCwt = 28;
+  const cwt = w / 100;
+  const freight = round2(base + perCwt * cwt);
+  return {
+    parcelShipping: 0,
+    freight,
+    isFreight: true,
+    basis: `LTL: $${base} base + $${perCwt}/cwt × ${cwt.toFixed(2)}cwt`,
+  };
+}
diff --git a/govarbitrage/src/engines/scoring.test.ts b/govarbitrage/src/engines/scoring.test.ts
new file mode 100644
index 0000000..acc3bbe
--- /dev/null
+++ b/govarbitrage/src/engines/scoring.test.ts
@@ -0,0 +1,72 @@
+import { describe, expect, it } from "vitest";
+import { computeScores, computeComponents } from "./scoring";
+import type { ScoreInput } from "./scoring";
+
+const base: ScoreInput = {
+  roi: 0.6,
+  expectedNetProfit: 3000,
+  demandScore: 70,
+  daysUntilSold: 25,
+  weightLbs: 40,
+  condition: "USED_GOOD",
+  bidCount: 2,
+  localPickup: false,
+  hasBuyerLeads: false,
+  confidenceScore: 80,
+  probabilityOfSale: 0.7,
+  isFreight: false,
+};
+
+describe("computeScores", () => {
+  it("returns all nine scoring profiles", () => {
+    const scores = computeScores(base);
+    expect(scores).toHaveLength(9);
+    const profiles = scores.map((s) => s.profile).sort();
+    expect(profiles).toContain("OVERALL_OPPORTUNITY");
+    expect(profiles).toContain("BEST_ARBITRAGE");
+    expect(profiles).toContain("PARTS_ONLY");
+  });
+
+  it("every score is 0..100 and carries a non-empty explanation", () => {
+    for (const s of computeScores(base)) {
+      expect(s.value).toBeGreaterThanOrEqual(0);
+      expect(s.value).toBeLessThanOrEqual(100);
+      expect(s.explanation.length).toBeGreaterThan(20);
+      expect(s.factors.length).toBeGreaterThan(0);
+    }
+  });
+
+  it("rewards a genuine salvage lot under PARTS_ONLY and penalizes non-parts", () => {
+    const parts = computeScores({ ...base, condition: "FOR_PARTS" }).find(
+      (s) => s.profile === "PARTS_ONLY",
+    )!;
+    const notParts = computeScores({ ...base, condition: "NEW" }).find(
+      (s) => s.profile === "PARTS_ONLY",
+    )!;
+    expect(parts.value).toBeGreaterThan(notParts.value);
+  });
+
+  it("higher ROI raises the arbitrage component and BEST_ARBITRAGE score", () => {
+    const low = computeScores({ ...base, roi: 0.05 }).find((s) => s.profile === "BEST_ARBITRAGE")!;
+    const high = computeScores({ ...base, roi: 1.5 }).find((s) => s.profile === "BEST_ARBITRAGE")!;
+    expect(high.value).toBeGreaterThan(low.value);
+  });
+
+  it("flags HIGH risk on low confidence + thin margin", () => {
+    const risky = computeScores({
+      ...base,
+      confidenceScore: 30,
+      roi: 0.05,
+      condition: "UNKNOWN",
+      daysUntilSold: 100,
+    })[0];
+    expect(risky.risk).toBe("HIGH");
+  });
+
+  it("marks light non-pickup items as EASY drop-ship, heavy freight as harder", () => {
+    const easy = computeComponents(base);
+    expect(easy.logistics).toBeGreaterThan(50);
+    const heavy = computeScores({ ...base, weightLbs: 700, isFreight: true })[0];
+    expect(["DIFFICULT", "INFEASIBLE", "MODERATE"]).toContain(heavy.dropShip);
+  });
+});
diff --git a/govarbitrage/src/engines/scoring.ts b/govarbitrage/src/engines/scoring.ts
new file mode 100644
index 0000000..8f45118
--- /dev/null
+++ b/govarbitrage/src/engines/scoring.ts
@@ -0,0 +1,196 @@
+import { clamp, round2, formatPercent, formatMoney } from "@/lib/utils";
+import type {
+  ConditionKey,
+  DropShipKey,
+  ProfileScore,
+  RiskKey,
+  ScoreComponents,
+  ScoreProfileKey,
+} from "./types";
+
+export interface ScoreInput {
+  roi: number;
+  expectedNetProfit: number;
+  demandScore: number; // 0..100
+  daysUntilSold: number;
+  weightLbs: number;
+  condition: ConditionKey;
+  bidCount: number;
+  localPickup: boolean;
+  hasBuyerLeads: boolean;
+  confidenceScore: number; // 0..100
+  probabilityOfSale: number; // 0..1
+  isFreight: boolean;
+}
+
+const CONDITION_SCORE: Record<ConditionKey, number> = {
+  NEW: 95,
+  LIKE_NEW: 82,
+  USED_GOOD: 65,
+  USED_FAIR: 45,
+  FOR_PARTS: 22,
+  UNKNOWN: 40,
+};
+
+/** Map an ROI ratio onto a 0..100 arbitrage score with diminishing returns. */
+function arbitrageFromRoi(roi: number): number {
+  // roi -0.5 -> ~5, 0 -> 35, 0.4 -> 70, 1.0 -> 88, 2.0+ -> ~98
+  const s = 35 + 55 * (1 - Math.exp(-1.15 * Math.max(0, roi))) + (roi < 0 ? roi * 60 : 0);
+  return clamp(s, 0, 100);
+}
+
+/** Compute the seven component sub-scores. */
+export function computeComponents(input: ScoreInput): ScoreComponents {
+  const arbitrage = round2(arbitrageFromRoi(input.roi));
+  const demand = round2(clamp(input.demandScore, 0, 100));
+  const velocity = round2(clamp(100 - input.daysUntilSold * 0.72, 0, 100));
+
+  // Logistics: lighter + parcel-shippable is best; freight and heavy items hurt.
+  let logistics = 100 - Math.min(input.weightLbs, 1000) * 0.06;
+  if (input.isFreight) logistics -= 25;
+  if (input.localPickup) logistics -= 10; // pickup adds friction unless local buyer
+  logistics = round2(clamp(logistics, 0, 100));
+
+  const condition = CONDITION_SCORE[input.condition] ?? 40;
+  const competition = round2(clamp(95 - input.bidCount * 6, 5, 100));
+  const buyer = round2(
+    clamp(input.probabilityOfSale * 70 + (input.hasBuyerLeads ? 25 : 0) + input.demandScore * 0.1, 0, 100),
+  );
+
+  return { arbitrage, demand, velocity, logistics, condition, competition, buyer };
+}
+
+export function computeRisk(input: ScoreInput, c: ScoreComponents): RiskKey {
+  let risk = 0;
+  if (input.confidenceScore < 45) risk += 2;
+  else if (input.confidenceScore < 70) risk += 1;
+  if (input.roi < 0.15) risk += 2;
+  else if (input.roi < 0.35) risk += 1;
+  if (input.condition === "FOR_PARTS" || input.condition === "UNKNOWN") risk += 1;
+  if (c.velocity < 40) risk += 1;
+  if (risk >= 4) return "HIGH";
+  if (risk >= 2) return "MEDIUM";
+  return "LOW";
+}
+
+export function computeDropShip(input: ScoreInput): DropShipKey {
+  // Drop-ship feasibility: can this move seller -> buyer without you touching it?
+  if (input.localPickup && input.weightLbs > 300) return "INFEASIBLE";
+  if (input.isFreight) return input.weightLbs > 500 ? "DIFFICULT" : "MODERATE";
+  if (input.weightLbs <= 50 && !input.localPickup) return "EASY";
+  if (input.weightLbs <= 150) return "MODERATE";
+  return "DIFFICULT";
+}
+
+// Per-profile component weights. Each row sums to ~1 across the 7 components.
+const PROFILE_WEIGHTS: Record<
+  ScoreProfileKey,
+  Partial<ScoreComponents> & { _confidence?: number }
+> = {
+  OVERALL_OPPORTUNITY: { arbitrage: 0.28, demand: 0.18, velocity: 0.14, logistics: 0.12, condition: 0.1, competition: 0.1, buyer: 0.08 },
+  BEST_ARBITRAGE: { arbitrage: 0.5, demand: 0.2, competition: 0.15, condition: 0.15 },
+  QUICK_FLIP: { velocity: 0.4, demand: 0.25, logistics: 0.2, arbitrage: 0.15 },
+  COLLECTOR: { demand: 0.4, condition: 0.35, arbitrage: 0.25 },
+  LOCAL_PICKUP: { arbitrage: 0.4, demand: 0.25, condition: 0.2, competition: 0.15 },
+  EASY_FREIGHT: { logistics: 0.45, arbitrage: 0.3, demand: 0.25 },
+  PARTS_ONLY: { arbitrage: 0.55, demand: 0.25, competition: 0.2 },
+  HIGH_CONFIDENCE: { arbitrage: 0.3, demand: 0.2, condition: 0.2, buyer: 0.15, velocity: 0.15 },
+  HIGH_PROFIT: { arbitrage: 0.45, demand: 0.3, velocity: 0.15, logistics: 0.1 },
+};
+
+const PROFILE_LABEL: Record<ScoreProfileKey, string> = {
+  OVERALL_OPPORTUNITY: "Overall Opportunity",
+  BEST_ARBITRAGE: "Best Arbitrage",
+  QUICK_FLIP: "Quick Flip",
+  COLLECTOR: "Collector",
+  LOCAL_PICKUP: "Local Pickup",
+  EASY_FREIGHT: "Easy Freight",
+  PARTS_ONLY: "Parts Only",
+  HIGH_CONFIDENCE: "High Confidence",
+  HIGH_PROFIT: "High Profit",
+};
+
+const COMPONENT_LABEL: Record<keyof ScoreComponents, string> = {
+  arbitrage: "arbitrage margin",
+  demand: "market demand",
+  velocity: "sale velocity",
+  logistics: "logistics ease",
+  condition: "item condition",
+  competition: "low competition",
+  buyer: "buyer readiness",
+};
+
+function scoreProfile(
+  profile: ScoreProfileKey,
+  c: ScoreComponents,
+  input: ScoreInput,
+  risk: RiskKey,
+  dropShip: DropShipKey,
+): ProfileScore {
+  const weights = PROFILE_WEIGHTS[profile];
+  const factors: ProfileScore["factors"] = [];
+  let value = 0;
+
+  for (const [key, w] of Object.entries(weights) as [keyof ScoreComponents, number][]) {
+    if (key === ("_confidence" as keyof ScoreComponents)) continue;
+    const comp = c[key] ?? 0;
+    const contribution = round2(comp * w);
+    value += contribution;
+    factors.push({ label: COMPONENT_LABEL[key], weight: w, contribution });
+  }
+
+  // Profile-specific adjustments beyond the weighted base.
+  let adjustment = 0;
+  const notes: string[] = [];
+  if (profile === "HIGH_CONFIDENCE") {
+    adjustment = (input.confidenceScore - 50) * 0.3;
+    notes.push(`confidence ${input.confidenceScore.toFixed(0)}/100`);
+  }
+  if (profile === "PARTS_ONLY" && input.condition !== "FOR_PARTS") {
+    adjustment -= 20; // parts-only profile penalizes non-parts items
+    notes.push("not a parts/salvage lot");
+  }
+  if (profile === "PARTS_ONLY" && input.condition === "FOR_PARTS") {
+    adjustment += 12;
+    notes.push("genuine salvage lot");
+  }
+  if (profile === "LOCAL_PICKUP") {
+    adjustment += input.localPickup ? 8 : -12;
+    notes.push(input.localPickup ? "local pickup available" : "no local pickup");
+  }
+  if (profile === "EASY_FREIGHT" && input.isFreight) {
+    adjustment -= 15;
+    notes.push("ships as LTL freight");
+  }
+  if (profile === "HIGH_PROFIT") {
+    // Reward large absolute dollars, not just ratio.
+    adjustment += clamp(input.expectedNetProfit / 250, -10, 20);
+    notes.push(`${formatMoney(input.expectedNetProfit)} est. net`);
+  }
+
+  value = round2(clamp(value + adjustment, 0, 100));
+
+  // Build the "why" explanation from the top contributors.
+  const top = [...factors].sort((a, b) => b.contribution - a.contribution).slice(0, 3);
+  const drivers = top
+    .map((f) => `${f.label} (${f.contribution.toFixed(0)} pts)`)
+    .join(", ");
+  const roiPhrase = `ROI ${formatPercent(input.roi)}`;
+  const extra = notes.length ? ` Adjustments: ${notes.join("; ")}.` : "";
+  const explanation =
+    `${PROFILE_LABEL[profile]} scored ${value.toFixed(0)}/100. ` +
+    `Top drivers: ${drivers}. ${roiPhrase}, ${input.daysUntilSold}d to sell, ` +
+    `risk ${risk}, drop-ship ${dropShip}.${extra}`;
+
+  return { profile, value, components: c, risk, dropShip, explanation, factors };
+}
+
+/** Compute all nine profile scores for a listing. */
+export function computeScores(input: ScoreInput): ProfileScore[] {
+  const c = computeComponents(input);
+  const risk = computeRisk(input, c);
+  const dropShip = computeDropShip(input);
+  return (Object.keys(PROFILE_WEIGHTS) as ScoreProfileKey[]).map((p) =>
+    scoreProfile(p, c, input, risk, dropShip),
+  );
+}
diff --git a/govarbitrage/src/engines/types.ts b/govarbitrage/src/engines/types.ts
new file mode 100644
index 0000000..e9b95c5
--- /dev/null
+++ b/govarbitrage/src/engines/types.ts
@@ -0,0 +1,133 @@
+// Shared engine types. Engines operate on plain numbers (US dollars) so they
+// are pure, deterministic, and unit-testable independent of Prisma/Decimal.
+
+export type ConditionKey =
+  | "NEW"
+  | "LIKE_NEW"
+  | "USED_GOOD"
+  | "USED_FAIR"
+  | "FOR_PARTS"
+  | "UNKNOWN";
+
+export type SourceKey =
+  | "GOVDEALS"
+  | "PUBLIC_SURPLUS"
+  | "GSA_AUCTIONS"
+  | "COUNTY"
+  | "STATE_SURPLUS"
+  | "UNIVERSITY_SURPLUS"
+  | "MUNICIBID"
+  | "BID4ASSETS"
+  | "CSV"
+  | "EXTENSION"
+  | "OTHER";
+
+export type RiskKey = "LOW" | "MEDIUM" | "HIGH";
+export type DropShipKey = "EASY" | "MODERATE" | "DIFFICULT" | "INFEASIBLE";
+
+export type ScoreProfileKey =
+  | "OVERALL_OPPORTUNITY"
+  | "BEST_ARBITRAGE"
+  | "QUICK_FLIP"
+  | "COLLECTOR"
+  | "LOCAL_PICKUP"
+  | "EASY_FREIGHT"
+  | "PARTS_ONLY"
+  | "HIGH_CONFIDENCE"
+  | "HIGH_PROFIT";
+
+/** Inputs the valuation engine needs (anchor + item facts). */
+export interface ValuationInput {
+  /** Best estimate of new retail price; the anchor for all derived values. */
+  newRetail: number;
+  condition: ConditionKey;
+  quantity: number;
+  /** 0..100 demand for this category/brand (from research/AI or a default). */
+  demandScore: number;
+  /** How well the item was identified (0..1); drives confidence. */
+  identificationConfidence: number;
+  /** Count of real comparable sales found; drives confidence. */
+  comparableCount?: number;
+}
+
+export interface Valuation {
+  newRetail: number;
+  newReplacement: number;
+  avgRetail: number;
+  usedSoldPrice: number;
+  usedAskingPrice: number;
+  usedLow: number;
+  usedHigh: number;
+  wholesaleValue: number;
+  liquidationValue: number;
+  sellTodayValue: number;
+  value7Day: number;
+  value30Day: number;
+  value90Day: number;
+  expectedSalePrice: number;
+  probabilityOfSale: number; // 0..1
+  daysUntilSold: number;
+  confidenceScore: number; // 0..100
+}
+
+export interface CostInput {
+  source: SourceKey;
+  winningBid: number;
+  quantity: number;
+  weightLbs: number;
+  condition: ConditionKey;
+  expectedSalePrice: number;
+  daysUntilSold: number;
+  /** true when the item must be picked up in person (no shipping from seller). */
+  localPickup?: boolean;
+  /** destination sales-tax rate applied to the purchase (resale certs may zero this). */
+  purchaseTaxRate?: number;
+  /** repair estimate override (else derived from condition). */
+  repairsOverride?: number;
+}
+
+export interface CostBreakdown {
+  winningBid: number;
+  buyerPremium: number;
+  salesTax: number;
+  shipping: number;
+  freight: number;
+  insurance: number;
+  packing: number;
+  pickupLabor: number;
+  testing: number;
+  repairs: number;
+  certification: number;
+  marketplaceFees: number;
+  paymentFees: number;
+  storage: number;
+  photography: number;
+  listingLabor: number;
+  expectedReturns: number;
+  totalInvestment: number;
+  expectedNetProfit: number;
+  roi: number;
+  annualizedReturn: number;
+  recommendedMaxBid: number;
+  assumptions: Record<string, string | number>;
+}
+
+export interface ScoreComponents {
+  arbitrage: number;
+  demand: number;
+  velocity: number;
+  logistics: number;
+  condition: number;
+  competition: number;
+  buyer: number;
+}
+
+export interface ProfileScore {
+  profile: ScoreProfileKey;
+  value: number;
+  components: ScoreComponents;
+  risk: RiskKey;
+  dropShip: DropShipKey;
+  explanation: string;
+  factors: { label: string; weight: number; contribution: number }[];
+}
diff --git a/govarbitrage/src/engines/valuation.test.ts b/govarbitrage/src/engines/valuation.test.ts
new file mode 100644
index 0000000..a8af7ee
--- /dev/null
+++ b/govarbitrage/src/engines/valuation.test.ts
@@ -0,0 +1,85 @@
+import { describe, expect, it } from "vitest";
+import { computeValuation } from "./valuation";
+
+describe("computeValuation", () => {
+  it("derives a full valuation ladder from a retail anchor", () => {
+    const v = computeValuation({
+      newRetail: 20000,
+      condition: "USED_GOOD",
+      quantity: 1,
+      demandScore: 70,
+      identificationConfidence: 0.9,
+      comparableCount: 6,
+    });
+
+    // Used-good sits at ~46% of retail.
+    expect(v.usedSoldPrice).toBeCloseTo(9200, 0);
+    expect(v.usedAskingPrice).toBeGreaterThan(v.usedSoldPrice);
+    expect(v.usedLow).toBeLessThan(v.usedSoldPrice);
+    expect(v.usedHigh).toBeGreaterThan(v.usedSoldPrice);
+    // Channel ordering: liquidation < wholesale < used.
+    expect(v.liquidationValue).toBeLessThan(v.wholesaleValue);
+    expect(v.wholesaleValue).toBeLessThan(v.usedSoldPrice);
+    // Longer horizon fetches more than a fire sale.
+    expect(v.value90Day).toBeGreaterThan(v.sellTodayValue);
+    expect(v.expectedSalePrice).toBeGreaterThan(0);
+  });
+
+  it("scales expected sale price by quantity", () => {
+    const one = computeValuation({
+      newRetail: 500,
+      condition: "LIKE_NEW",
+      quantity: 1,
+      demandScore: 50,
+      identificationConfidence: 0.6,
+    });
+    const ten = computeValuation({
+      newRetail: 500,
+      condition: "LIKE_NEW",
+      quantity: 10,
+      demandScore: 50,
+      identificationConfidence: 0.6,
+    });
+    expect(ten.expectedSalePrice).toBeCloseTo(one.expectedSalePrice * 10, 0);
+  });
+
+  it("gives higher confidence with more comparables and better ID", () => {
+    const weak = computeValuation({
+      newRetail: 1000,
+      condition: "UNKNOWN",
+      quantity: 1,
+      demandScore: 40,
+      identificationConfidence: 0.2,
+      comparableCount: 0,
+    });
+    const strong = computeValuation({
+      newRetail: 1000,
+      condition: "USED_GOOD",
+      quantity: 1,
+      demandScore: 40,
+      identificationConfidence: 0.95,
+      comparableCount: 8,
+    });
+    expect(strong.confidenceScore).toBeGreaterThan(weak.confidenceScore);
+    expect(strong.confidenceScore).toBeLessThanOrEqual(100);
+  });
+
+  it("rewards high demand with faster sale and higher probability", () => {
+    const lowDemand = computeValuation({
+      newRetail: 1000,
+      condition: "USED_GOOD",
+      quantity: 1,
+      demandScore: 10,
+      identificationConfidence: 0.7,
+    });
+    const highDemand = computeValuation({
+      newRetail: 1000,
+      condition: "USED_GOOD",
+      quantity: 1,
+      demandScore: 95,
+      identificationConfidence: 0.7,
+    });
+    expect(highDemand.daysUntilSold).toBeLessThan(lowDemand.daysUntilSold);
+    expect(highDemand.probabilityOfSale).toBeGreaterThan(lowDemand.probabilityOfSale);
+  });
+});
diff --git a/govarbitrage/src/engines/valuation.ts b/govarbitrage/src/engines/valuation.ts
new file mode 100644
index 0000000..1f80eaf
--- /dev/null
+++ b/govarbitrage/src/engines/valuation.ts
@@ -0,0 +1,84 @@
+import { clamp, round2 } from "@/lib/utils";
+import { CONDITION_RETAIL_FACTOR } from "./constants";
+import type { Valuation, ValuationInput } from "./types";
+
+/**
+ * Derive the full valuation ladder from a single retail anchor plus item facts.
+ *
+ * The AI/research layer supplies the hard-to-know inputs (a `newRetail` anchor,
+ * a `demandScore`, identification confidence, comparable count); everything
+ * else is derived deterministically here so the numbers are reproducible and
+ * explainable rather than a black box.
+ */
+export function computeValuation(input: ValuationInput): Valuation {
+  const qty = Math.max(1, input.quantity || 1);
+  const perUnitRetail = Math.max(0, input.newRetail);
+  const condFactor = CONDITION_RETAIL_FACTOR[input.condition] ?? 0.35;
+  const demand = clamp(input.demandScore ?? 50, 0, 100);
+
+  // Per-unit retail figures.
+  const newRetail = perUnitRetail;
+  const newReplacement = round2(perUnitRetail * 1.05); // like-for-like buy-new cost
+  const avgRetail = round2(perUnitRetail * 0.92); // street average vs MSRP
+
+  // Used market, anchored off condition.
+  const usedSoldPrice = round2(perUnitRetail * condFactor);
+  const usedAskingPrice = round2(usedSoldPrice * 1.25);
+  const usedLow = round2(usedSoldPrice * 0.8);
+  const usedHigh = round2(usedSoldPrice * 1.3);
+
+  // Channel values (per unit).
+  const wholesaleValue = round2(usedSoldPrice * 0.6);
+  const liquidationValue = round2(usedSoldPrice * 0.42);
+  const sellTodayValue = round2(liquidationValue * 1.1); // fire-sale, today
+
+  // Time-horizon values: the longer you wait, the closer to asking you get,
+  // scaled by how much demand there is.
+  const patience = 0.5 + demand / 200; // 0.5..1.0
+  const value7Day = round2(usedSoldPrice * (0.8 + 0.1 * patience));
+  const value30Day = round2(usedSoldPrice * (0.95 + 0.1 * patience));
+  const value90Day = round2(usedAskingPrice * (0.85 + 0.1 * patience));
+
+  // Expected sale price: demand-weighted blend across horizons.
+  const expectedSalePrice = round2(
+    value7Day * 0.2 + value30Day * 0.5 + value90Day * 0.3,
+  );
+
+  // Probability of sale within 90 days, driven by demand + condition quality.
+  const probabilityOfSale = round2(
+    clamp(0.35 + demand / 200 + (condFactor - 0.35) * 0.4, 0.1, 0.98),
+  );
+
+  // Days until sold: high demand sells fast; low demand lingers.
+  const daysUntilSold = Math.round(clamp(90 - demand * 0.75, 5, 120));
+
+  // Confidence: how much do we trust these numbers? Built from identification
+  // confidence and the number of real comparables backing the estimate.
+  const idConf = clamp(input.identificationConfidence ?? 0.5, 0, 1);
+  const comps = input.comparableCount ?? 0;
+  const confidenceScore = round2(
+    clamp(idConf * 60 + Math.min(comps, 8) * 5, 0, 100),
+  );
+
+  // Return per-unit valuations but scale the aggregate resale-relevant figure
+  // (expectedSalePrice) by quantity, since a lot of N sells N units.
+  return {
+    newRetail,
+    newReplacement,
+    avgRetail,
+    usedSoldPrice,
+    usedAskingPrice,
+    usedLow,
+    usedHigh,
+    wholesaleValue,
+    liquidationValue,
+    sellTodayValue,
+    value7Day,
+    value30Day,
+    value90Day,
+    expectedSalePrice: round2(expectedSalePrice * qty),
+    probabilityOfSale,
+    daysUntilSold,
+    confidenceScore,
+  };
+}
diff --git a/govarbitrage/src/importers/apify-govdeals.ts b/govarbitrage/src/importers/apify-govdeals.ts
new file mode 100644
index 0000000..e4fc9ef
--- /dev/null
+++ b/govarbitrage/src/importers/apify-govdeals.ts
@@ -0,0 +1,148 @@
+import type { RawListing } from "./ingest";
+
+// GovDeals via the Apify actor `parseforge/govdeals-scraper` — bypasses GovDeals'
+// Akamai bot-wall (Apify handles it). We READ finished datasets (free); running
+// the actor for FRESH data costs Apify compute (~$0.0037/listing) and is gated.
+//
+// Env: APIFY_TOKEN (required). Optionally APIFY_GOVDEALS_ACTOR (default the known
+// actor) and APIFY_GOVDEALS_DATASET (a specific dataset id to read).
+
+const API = "https://api.apify.com/v2";
+const DEFAULT_ACTOR = process.env.APIFY_GOVDEALS_ACTOR || "SZdBCF9FpwOzvByvM"; // parseforge/govdeals-scraper
+
+interface ApifyItem {
+  title?: string;
+  url?: string;
+  imageUrl?: string;
+  photos?: (string | { url?: string })[];
+  accountId?: number;
+  assetId?: number;
+  auctionId?: number;
+  category?: string;
+  parentCategory?: string;
+  make?: string | null;
+  model?: string | null;
+  condition?: string | null;
+  quantity?: number;
+  weight?: number | null;
+  description?: string;
+  locationCity?: string;
+  locationState?: string;
+  locationZip?: string;
+  currentBid?: number;
+  bidCount?: number;
+  auctionEnd?: string;
+  auctionEndUtc?: string;
+  specialInstructions?: string;
+  removalInstructions?: string;
+  paymentInstructions?: string;
+}
+
+const CONDITION_MAP: Record<string, RawListing["condition"]> = {
+  N: "NEW",
+  U: "USED_GOOD",
+  R: "LIKE_NEW",
+  F: "USED_FAIR",
+  S: "FOR_PARTS",
+  P: "FOR_PARTS",
+};
+
+const stripHtml = (s?: string) =>
+  (s || "").replace(/<[^>]*>/g, " ").replace(/&nbsp;/g, " ").replace(/\s+/g, " ").trim() || undefined;
+
+function photoUrls(item: ApifyItem): string[] {
+  if (Array.isArray(item.photos) && item.photos.length) {
+    return item.photos.map((p) => (typeof p === "string" ? p : p?.url || "")).filter(Boolean).slice(0, 8);
+  }
+  return item.imageUrl ? [item.imageUrl] : [];
+}
+
+function mapItem(it: ApifyItem): RawListing | null {
+  const title = it.title?.trim();
+  if (!title || it.assetId == null) return null;
+  return {
+    source: "GOVDEALS",
+    sourceAuctionId: `${it.accountId ?? "x"}-${it.assetId}`,
+    sourceUrl: it.url || "https://www.govdeals.com",
+    title,
+    description: stripHtml(it.description),
+    category: it.category || it.parentCategory || undefined,
+    manufacturer: it.make || undefined,
+    model: it.model || undefined,
+    condition: CONDITION_MAP[(it.condition || "").trim().toUpperCase()] || "UNKNOWN",
+    quantity: it.quantity && it.quantity > 0 ? it.quantity : 1,
+    weightLbs: it.weight ?? undefined,
+    locationCity: it.locationCity,
+    locationState: it.locationState,
+    locationZip: it.locationZip,
+    currentBid: it.currentBid ?? 0,
+    bidCount: it.bidCount ?? 0,
+    closingAt: it.auctionEndUtc || it.auctionEnd || null,
+    imageUrls: photoUrls(it),
+    auctionTerms: [it.specialInstructions, it.removalInstructions, it.paymentInstructions]
+      .map(stripHtml)
+      .filter(Boolean)
+      .join(" ") || undefined,
+  };
+}
+
+async function apifyJson(path: string): Promise<unknown> {
+  const token = process.env.APIFY_TOKEN;
+  if (!token) throw new Error("APIFY_TOKEN not set");
+  const sep = path.includes("?") ? "&" : "?";
+  const res = await fetch(`${API}${path}${sep}token=${token}&clean=true`);
+  if (!res.ok) throw new Error(`Apify ${res.status}: ${(await res.text()).slice(0, 160)}`);
+  return res.json();
+}
+
+/** Read an already-scraped dataset by id (free). */
+export async function fetchApifyDataset(datasetId: string, limit = 200): Promise<RawListing[]> {
+  const items = (await apifyJson(`/datasets/${datasetId}/items?limit=${limit}`)) as ApifyItem[];
+  return items.map(mapItem).filter((x): x is RawListing => x !== null);
+}
+
+/** Read the most recent SUCCEEDED run's dataset for the GovDeals actor (free). */
+export async function fetchApifyLastRun(limit = 200): Promise<RawListing[]> {
+  const items = (await apifyJson(
+    `/acts/${DEFAULT_ACTOR}/runs/last/dataset/items?status=SUCCEEDED&limit=${limit}`,
+  )) as ApifyItem[];
+  return items.map(mapItem).filter((x): x is RawListing => x !== null);
+}
+
+/**
+ * Trigger a NEW GovDeals scrape run (PAID — ~$0.0037/listing) and return the
+ * scraped rows plus the run's actual USD cost. Steve-authorized for the daily
+ * 100-listing job. Polls up to ~8 min for completion.
+ */
+export async function triggerApifyRun(
+  maxItems = 100,
+): Promise<{ rows: RawListing[]; costUsd: number; runId: string }> {
+  const token = process.env.APIFY_TOKEN;
+  if (!token) throw new Error("APIFY_TOKEN not set");
+
+  const startRes = await fetch(`${API}/acts/${DEFAULT_ACTOR}/runs?token=${token}`, {
+    method: "POST",
+    headers: { "Content-Type": "application/json" },
+    body: JSON.stringify({ maxItems, sortOrder: "asc" }),
+  });
+  if (!startRes.ok) throw new Error(`Apify run start ${startRes.status}: ${(await startRes.text()).slice(0, 160)}`);
+  const run = ((await startRes.json()) as { data: { id: string; status: string; defaultDatasetId: string } }).data;
+
+  const deadline = Date.now() + 8 * 60 * 1000;
+  let status = run.status;
+  let datasetId = run.defaultDatasetId;
+  let costUsd = 0;
+  while (Date.now() < deadline) {
+    await new Promise((r) => setTimeout(r, 5000));
+    const res = await fetch(`${API}/actor-runs/${run.id}?token=${token}`);
+    const s = ((await res.json()) as { data: { status: string; defaultDatasetId: string; usageTotalUsd?: number } }).data;
+    status = s.status;
+    datasetId = s.defaultDatasetId;
+    costUsd = s.usageTotalUsd ?? 0;
+    if (["SUCCEEDED", "FAILED", "ABORTED", "TIMED-OUT"].includes(status)) break;
+  }
+  if (status !== "SUCCEEDED") throw new Error(`Apify run ${run.id} ended ${status}`);
+
+  const rows = await fetchApifyDataset(datasetId, maxItems);
+  return { rows, costUsd, runId: run.id };
+}
diff --git a/govarbitrage/src/importers/csv.ts b/govarbitrage/src/importers/csv.ts
new file mode 100644
index 0000000..5e255fe
--- /dev/null
+++ b/govarbitrage/src/importers/csv.ts
@@ -0,0 +1,71 @@
+import { parse } from "csv-parse/sync";
+import type { RawListing } from "./ingest";
+
+// Column aliases → RawListing fields, so operators can paste varied CSVs.
+const FIELD_ALIASES: Record<string, keyof RawListing> = {
+  source: "source",
+  auction: "sourceAuctionId",
+  "auction #": "sourceAuctionId",
+  auction_id: "sourceAuctionId",
+  sourceauctionid: "sourceAuctionId",
+  url: "sourceUrl",
+  link: "sourceUrl",
+  title: "title",
+  name: "title",
+  description: "description",
+  desc: "description",
+  category: "category",
+  manufacturer: "manufacturer",
+  mfr: "manufacturer",
+  brand: "manufacturer",
+  model: "model",
+  condition: "condition",
+  quantity: "quantity",
+  qty: "quantity",
+  weight: "weightLbs",
+  weightlbs: "weightLbs",
+  dimensions: "dimensions",
+  city: "locationCity",
+  state: "locationState",
+  zip: "locationZip",
+  bid: "currentBid",
+  currentbid: "currentBid",
+  "current bid": "currentBid",
+  bidcount: "bidCount",
+  bids: "bidCount",
+  closing: "closingAt",
+  closingat: "closingAt",
+  "closing date": "closingAt",
+  images: "imageUrls",
+  imageurls: "imageUrls",
+  terms: "auctionTerms",
+};
+
+const NUMERIC: (keyof RawListing)[] = ["quantity", "weightLbs", "currentBid", "bidCount"];
+
+/** Parse a CSV string into normalized RawListing rows (header-driven, alias-aware). */
+export function parseListingsCsv(text: string): RawListing[] {
+  const records = parse(text, {
+    columns: (header: string[]) => header.map((h) => h.trim().toLowerCase()),
+    skip_empty_lines: true,
+    trim: true,
+    relax_column_count: true,
+  }) as Record<string, string>[];
+
+  return records.map((rec) => {
+    const raw: Record<string, unknown> = {};
+    for (const [key, value] of Object.entries(rec)) {
+      const field = FIELD_ALIASES[key];
+      if (!field || value === "" || value == null) continue;
+      if (NUMERIC.includes(field)) {
+        const num = Number(String(value).replace(/[$,]/g, ""));
+        if (!Number.isNaN(num)) raw[field] = num;
+      } else if (field === "imageUrls") {
+        raw[field] = String(value).split(/[|;]/).map((s) => s.trim()).filter(Boolean);
+      } else {
+        raw[field] = value;
+      }
+    }
+    return raw as unknown as RawListing;
+  });
+}
diff --git a/govarbitrage/src/importers/govdeals-free.ts b/govarbitrage/src/importers/govdeals-free.ts
new file mode 100644
index 0000000..ef95144
--- /dev/null
+++ b/govarbitrage/src/importers/govdeals-free.ts
@@ -0,0 +1,127 @@
+import type { RawListing } from "./ingest";
+
+// FREE GovDeals importer — hits GovDeals' own backend search API directly
+// (maestro.lqdt1.com/search/list), the same call the site's Angular app makes.
+// No Apify, no scraping, no login, $0. The x-api-key is GovDeals' PUBLIC client
+// key (shipped in their public JS bundle), overridable via GOVDEALS_MAESTRO_KEY.
+//
+// The same endpoint serves sibling Liquidity Services marketplaces by businessId:
+//   GD = GovDeals · GI = GoIndustry DoveBid · NI = Network Int'l.
+
+const MAESTRO = "https://maestro.lqdt1.com/search/list";
+const API_KEY = process.env.GOVDEALS_MAESTRO_KEY || "af93060f-337e-428c-87b8-c74b5837d6cd";
+const PHOTO_BASE = "https://webassets.lqdt1.com/assets/photos";
+
+interface Asset {
+  accountId?: number;
+  assetId?: number;
+  assetShortDescription?: string;
+  assetLongDescription?: string | null;
+  makebrand?: string | null;
+  model?: string | null;
+  modelYear?: string | null;
+  categoryDescription?: string | null;
+  currentBid?: number | null;
+  bidCount?: number | null;
+  assetAuctionEndDate?: string | null;
+  locationCity?: string;
+  locationState?: string;
+  locationZip?: string;
+  photo?: string | null;
+}
+
+const uuid = () => globalThis.crypto.randomUUID();
+
+// Liquidity Services marketplaces served by the same maestro API, keyed by businessId.
+export const MARKETS: Record<string, { source: string; base: string }> = {
+  GD: { source: "GOVDEALS", base: "https://www.govdeals.com" },
+  GI: { source: "GOINDUSTRY", base: "https://www.go-dove.com" },
+  NI: { source: "NETWORK_INTL", base: "https://www.networkintl.com" },
+};
+
+function mapAsset(a: Asset, market: { source: string; base: string }): RawListing | null {
+  if (!a.assetShortDescription || a.assetId == null) return null;
+  return {
+    source: market.source,
+    sourceAuctionId: `${a.accountId ?? "x"}-${a.assetId}`,
+    sourceUrl: `${market.base}/en/asset/${a.accountId}/${a.assetId}`,
+    title: a.assetShortDescription.trim(),
+    description: a.assetLongDescription || undefined,
+    category: a.categoryDescription || undefined,
+    manufacturer: a.makebrand || undefined,
+    model: a.model || undefined,
+    condition: "UNKNOWN",
+    quantity: 1,
+    locationCity: a.locationCity,
+    locationState: a.locationState,
+    locationZip: a.locationZip,
+    currentBid: Number(a.currentBid) || 0,
+    bidCount: Number(a.bidCount) || 0,
+    closingAt: a.assetAuctionEndDate || null,
+    imageUrls: a.photo ? [`${PHOTO_BASE}/${a.accountId}/${a.photo}`] : [],
+  };
+}
+
+async function fetchPage(businessId: string, page: number, displayRows: number): Promise<Asset[]> {
+  const body = {
+    categoryIds: "",
+    businessId,
+    searchText: "*",
+    isQAL: false,
+    locationId: null,
+    model: "",
+    makebrand: "",
+    auctionTypeId: null,
+    page,
+    displayRows,
+    sortField: "bestfit",
+    sortOrder: "",
+    sessionId: uuid(),
+    requestType: "search",
+    responseStyle: "fullResponse",
+    facets: [
+      "categoryName", "auctionTypeID", "condition", "saleEventName", "sellerDisplayName",
+      "product_pricecents", "isReserveMet", "hasBuyNowPrice", "isReserveNotMet", "sellerType",
+      "warehouseId", "region", "currencyTypeCode", "tierId",
+    ],
+    facetsFilter: [],
+    timeType: "newListings",
+    sellerTypeId: null,
+    accountIds: [],
+  };
+  const res = await fetch(MAESTRO, {
+    method: "POST",
+    headers: {
+      "x-api-key": API_KEY,
+      "Content-Type": "application/json",
+      audience: "Ecom",
+      "x-user-id": "-1",
+      "x-api-correlation-id": uuid(),
+    },
+    body: JSON.stringify(body),
+  });
+  if (!res.ok) throw new Error(`maestro ${res.status}: ${(await res.text()).slice(0, 160)}`);
+  const j = (await res.json()) as { assetSearchResults?: Asset[] };
+  return j.assetSearchResults ?? [];
+}
+
+/** Fetch newest GovDeals (or GI/NI) listings, paging until `limit`. Free, $0. */
+export async function fetchGovdealsFree(
+  opts: { limit?: number; businessId?: string } = {},
+): Promise<RawListing[]> {
+  const businessId = (opts.businessId || "GD").toUpperCase();
+  const market = MARKETS[businessId] ?? MARKETS.GD;
+  const limit = opts.limit ?? 120;
+  const perPage = 120;
+  const out: RawListing[] = [];
+  for (let page = 1; out.length < limit && page <= 20; page++) {
+    const assets = await fetchPage(businessId, page, perPage);
+    if (!assets.length) break;
+    for (const a of assets) {
+      const m = mapAsset(a, market);
+      if (m) out.push(m);
+    }
+    if (assets.length < perPage) break;
+  }
+  return out.slice(0, limit);
+}
diff --git a/govarbitrage/src/importers/govplanet-free.ts b/govarbitrage/src/importers/govplanet-free.ts
new file mode 100644
index 0000000..a9bf76f
--- /dev/null
+++ b/govarbitrage/src/importers/govplanet-free.ts
@@ -0,0 +1,227 @@
+import type { RawListing } from "./ingest";
+
+// FREE GovPlanet importer. $0, no browser at runtime.
+//
+// GovPlanet runs the IronPlanet / Ritchie Bros commerce stack (Java, `.ips`
+// URLs). It is NOT on the Liquidity Services `maestro.lqdt1.com` API (verified:
+// businessIds GP/GOVPLANET/IP/IRONPLANET all return 0 results). Its category
+// landing pages are JS-hydrated, BUT the item search page embeds a per-item
+// `quickviews.push({...})` JSON object with every field we need — served to
+// plain curl (HTTP 200, no bot wall). So the crack is: fetch the search HTML,
+// extract each quickviews object with a brace/string-aware scanner, JSON.parse.
+//
+// Verified 2026-07-10. Endpoints (each returns ~60 items):
+//   https://www.govplanet.com/buy-now
+//   https://www.govplanet.com/searchResults.ips?keyword=<kw>   (empty kw = all)
+// quickviews object fields:
+//   equipId, description (title), price/convPrice (USD), currency ("USD"),
+//   eumeLocation, itemPageUri, photoThumb, timeLeft (relative), bidCnt, features
+// Prices are already USD (US government surplus) — no FX conversion needed.
+// Native pagination is AJAX-only, so we broaden coverage across the two base
+// endpoints plus a set of high-value category keywords, deduped by equipId.
+
+const BASE = "https://www.govplanet.com";
+const UA =
+  "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
+  "(KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36";
+
+// High-value US-resale categories to broaden coverage beyond the ~60-item
+// default page (GovPlanet's on-page pagination is AJAX-only).
+const KEYWORDS = [
+  "", // all
+  "generator",
+  "truck",
+  "trailer",
+  "forklift",
+  "excavator",
+  "loader",
+  "trailer",
+  "tractor",
+  "compressor",
+  "welder",
+  "hmmwv",
+  "humvee",
+];
+
+function stripTags(s: string): string {
+  return s
+    .replace(/<[^>]*>/g, " ")
+    .replace(/&amp;/g, "&")
+    .replace(/&#39;/g, "'")
+    .replace(/&quot;/g, '"')
+    .replace(/&nbsp;/g, " ")
+    .replace(/\s+/g, " ")
+    .trim();
+}
+
+function toNumber(s: string | undefined): number {
+  if (!s) return 0;
+  const m = /([\d,]+(?:\.\d+)?)/.exec(s.replace(/[^0-9.,]/g, ""));
+  return m ? Number(m[1].replace(/,/g, "")) : 0;
+}
+
+// Parse a relative "timeLeft" like "2 days", "4 hrs", "15 mins" -> ISO close.
+// GovPlanet often renders "&nbsp;" (blank) for offer-only items -> null.
+function parseRelativeClose(raw: string | undefined): string | null {
+  if (!raw) return null;
+  const txt = stripTags(raw).toLowerCase();
+  if (!txt || /ended|closed/.test(txt)) return null;
+  const units: [RegExp, number][] = [
+    [/(\d+)\s*day/, 86400_000],
+    [/(\d+)\s*h(?:ou)?r/, 3600_000],
+    [/(\d+)\s*min/, 60_000],
+    [/(\d+)\s*sec/, 1000],
+  ];
+  let deltaMs = 0;
+  let matched = false;
+  for (const [re, ms] of units) {
+    const m = re.exec(txt);
+    if (m) {
+      deltaMs += Number(m[1]) * ms;
+      matched = true;
+    }
+  }
+  if (!matched) return null;
+  return new Date(Date.now() + deltaMs).toISOString();
+}
+
+/**
+ * Extract every `quickviews.push({...})` object from a GovPlanet search page.
+ * Uses a string/brace-aware scanner because values contain unescaped `)` and
+ * `{` inside strings (e.g. "(2,424 mi away)"), which a greedy regex would break.
+ */
+function extractQuickviews(html: string): Record<string, unknown>[] {
+  const out: Record<string, unknown>[] = [];
+  const marker = "quickviews.push(";
+  let idx = html.indexOf(marker);
+  while (idx !== -1) {
+    let i = idx + marker.length;
+    // Expect the object to start at '{'.
+    while (i < html.length && html[i] !== "{") i++;
+    if (i >= html.length) break;
+    const start = i;
+    let depth = 0;
+    let inStr = false;
+    let quote = "";
+    for (; i < html.length; i++) {
+      const ch = html[i];
+      if (inStr) {
+        if (ch === "\\") {
+          i++; // skip escaped char
+          continue;
+        }
+        if (ch === quote) inStr = false;
+      } else {
+        if (ch === '"' || ch === "'") {
+          inStr = true;
+          quote = ch;
+        } else if (ch === "{") depth++;
+        else if (ch === "}") {
+          depth--;
+          if (depth === 0) {
+            i++;
+            break;
+          }
+        }
+      }
+    }
+    const objStr = html.slice(start, i);
+    try {
+      out.push(JSON.parse(objStr));
+    } catch {
+      // Some pages use single-quoted values in a few fields; best-effort skip.
+    }
+    idx = html.indexOf(marker, i);
+  }
+  return out;
+}
+
+function qvToListing(qv: Record<string, unknown>): RawListing | null {
+  const id = String(qv.equipId || qv.itemId || "").trim();
+  const title = stripTags(String(qv.description || "")).trim();
+  if (!id || !title) return null;
+
+  const priceHtml = String(qv.price || qv.convPrice || "");
+  const currentBid = toNumber(priceHtml);
+
+  const uri = String(qv.itemPageUri || `/item/${id}`);
+  const sourceUrl = uri.startsWith("http") ? uri : `${BASE}${uri}`;
+
+  const state = String(qv.eumeLocation || "").trim() || undefined;
+  const img = qv.photoThumb ? String(qv.photoThumb) : undefined;
+  const bidCount = toNumber(String(qv.bidCnt || "")) || 0;
+  const closingAt = parseRelativeClose(qv.timeLeft ? String(qv.timeLeft) : undefined);
+  const features = stripTags(String(qv.features || "")) || undefined;
+
+  return {
+    source: "GOVPLANET",
+    sourceAuctionId: id,
+    sourceUrl,
+    title,
+    description: features,
+    locationState: state,
+    currentBid,
+    bidCount,
+    closingAt,
+    imageUrls: img ? [img] : [],
+  };
+}
+
+async function fetchSearch(keyword: string): Promise<string> {
+  const url = keyword
+    ? `${BASE}/searchResults.ips?keyword=${encodeURIComponent(keyword)}`
+    : `${BASE}/buy-now`;
+  const res = await fetch(url, {
+    headers: {
+      "User-Agent": UA,
+      Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
+      "Accept-Language": "en-US,en;q=0.9",
+    },
+  });
+  if (!res.ok) throw new Error(`GovPlanet "${keyword || "buy-now"}" -> HTTP ${res.status}`);
+  return res.text();
+}
+
+export interface FetchGovPlanetOpts {
+  limit?: number;
+}
+
+/**
+ * Fetch up to `limit` active GovPlanet items by scanning the buy-now page and a
+ * set of high-value category keyword searches, deduped by equipId. All prices
+ * are USD. $0 — plain HTTPS, no browser, no API key.
+ */
+export async function fetchGovPlanetFree(
+  opts: FetchGovPlanetOpts = {},
+): Promise<RawListing[]> {
+  const limit = opts.limit ?? 100;
+  const seen = new Set<string>();
+  const rows: RawListing[] = [];
+
+  // Always start with the dedicated buy-now page, then keyword searches.
+  const queries = ["", ...KEYWORDS];
+  const uniqueQueries = [...new Set(queries)];
+
+  for (const kw of uniqueQueries) {
+    if (rows.length >= limit) break;
+    let html: string;
+    try {
+      html = await fetchSearch(kw);
+    } catch (e) {
+      console.warn(`[govplanet-free] ${(e as Error).message}`);
+      continue;
+    }
+    const qvs = extractQuickviews(html);
+    for (const qv of qvs) {
+      const listing = qvToListing(qv);
+      if (!listing) continue;
+      if (seen.has(listing.sourceAuctionId)) continue;
+      seen.add(listing.sourceAuctionId);
+      rows.push(listing);
+      if (rows.length >= limit) break;
+    }
+    await new Promise((r) => setTimeout(r, 350));
+  }
+
+  return rows.slice(0, limit);
+}
diff --git a/govarbitrage/src/importers/grays-free.ts b/govarbitrage/src/importers/grays-free.ts
new file mode 100644
index 0000000..67cc4fd
--- /dev/null
+++ b/govarbitrage/src/importers/grays-free.ts
@@ -0,0 +1,88 @@
+import type { RawListing } from "./ingest";
+
+// FREE GraysOnline (Australia) importer via its public Algolia search index —
+// harvested App-Id + search key from the site's client (feed-first). $0, no
+// scraping. Grays prices are AUD; converted to USD so the valuation engine stays
+// coherent (rough fixed rate — refine with a live FX feed if needed).
+
+const APP_ID = process.env.GRAYS_ALGOLIA_APP_ID || "CKPAMVUUBE";
+const API_KEY = process.env.GRAYS_ALGOLIA_KEY || "1a31357659d5c40f4641cc0d46c172d0";
+const INDEX = "GOL_MAIN";
+const AUD_TO_USD = Number(process.env.AUD_USD || 0.66);
+
+interface Hit {
+  objectID?: string;
+  ObjectTitle?: string;
+  ObjectType?: string;
+  Category?: string;
+  ParentCategory?: string;
+  CONDITION?: string;
+  UnitQuantity?: number;
+  ObjectPrice?: number;
+  BidActionCount?: number;
+  LOT_DEFAULT_END_TIME?: number;
+  LOT_SKU?: string;
+  LOT_ID?: number;
+  ITEM_URL?: string;
+  ImageURLS?: string[];
+  SaleSuburb?: string;
+  SaleState?: string;
+}
+
+function mapHit(h: Hit): RawListing | null {
+  if (!h.ObjectTitle || !(h.LOT_SKU || h.LOT_ID)) return null;
+  const closing = h.LOT_DEFAULT_END_TIME ? new Date(h.LOT_DEFAULT_END_TIME * 1000).toISOString() : null;
+  return {
+    source: "GRAYS_AU",
+    sourceAuctionId: String(h.LOT_SKU || h.LOT_ID),
+    sourceUrl: h.ITEM_URL ? `https://www.grays.com${h.ITEM_URL}` : "https://www.grays.com",
+    title: h.ObjectTitle.trim(),
+    category: h.Category || h.ParentCategory || undefined,
+    condition: (h.CONDITION || "").toUpperCase().includes("NEW") ? "NEW" : "UNKNOWN",
+    quantity: h.UnitQuantity && h.UnitQuantity > 0 ? Math.floor(h.UnitQuantity) : 1,
+    locationCity: h.SaleSuburb,
+    locationState: h.SaleState,
+    // AUD → USD so downstream USD math is coherent.
+    currentBid: h.ObjectPrice ? Math.round(h.ObjectPrice * AUD_TO_USD * 100) / 100 : 0,
+    bidCount: h.BidActionCount ?? 0,
+    closingAt: closing,
+    imageUrls: Array.isArray(h.ImageURLS) ? h.ImageURLS.slice(0, 6) : [],
+    auctionTerms: "GraysOnline (AU). Prices shown converted AUD→USD; freight from Australia applies.",
+  };
+}
+
+/** Fetch newest Grays lots via Algolia, paging until `limit`. Free, $0. */
+export async function fetchGraysFree(opts: { limit?: number } = {}): Promise<RawListing[]> {
+  const limit = opts.limit ?? 100;
+  const perPage = 100;
+  const out: RawListing[] = [];
+  for (let page = 0; out.length < limit && page < 20; page++) {
+    const res = await fetch(`https://${APP_ID}-dsn.algolia.net/1/indexes/*/queries`, {
+      method: "POST",
+      headers: {
+        "X-Algolia-Application-Id": APP_ID,
+        "X-Algolia-API-Key": API_KEY,
+        "Content-Type": "application/json",
+      },
+      body: JSON.stringify({
+        requests: [
+          {
+            indexName: INDEX,
+            query: "",
+            params: `hitsPerPage=${perPage}&page=${page}&filters=${encodeURIComponent("ObjectType:LOT OR ObjectType:RETAIL")}`,
+          },
+        ],
+      }),
+    });
+    if (!res.ok) throw new Error(`Grays Algolia ${res.status}: ${(await res.text()).slice(0, 160)}`);
+    const j = (await res.json()) as { results?: { hits?: Hit[] }[] };
+    const hits = j.results?.[0]?.hits ?? [];
+    if (!hits.length) break;
+    for (const h of hits) {
+      const m = mapHit(h);
+      if (m) out.push(m);
+    }
+    if (hits.length < perPage) break;
+  }
+  return out.slice(0, limit);
+}
diff --git a/govarbitrage/src/importers/gsa.ts b/govarbitrage/src/importers/gsa.ts
new file mode 100644
index 0000000..7e3c490
--- /dev/null
+++ b/govarbitrage/src/importers/gsa.ts
@@ -0,0 +1,91 @@
+import type { RawListing } from "./ingest";
+
+// Official GSA Auctions API — free, JSON, no scraping/bot-wall. Federal surplus
+// auction listings from all participating agencies.
+//   GET https://api.gsa.gov/assets/gsaauctions/v2/auctions  (X-API-KEY header)
+// The endpoint 303-redirects to a pre-generated active-auctions.json on S3;
+// fetch() follows the redirect automatically.
+//
+// GSA_API_KEY defaults to the public DEMO_KEY (rate-limited). Supply an
+// api.data.gov key for production volume.
+
+const GSA_URL = "https://api.gsa.gov/assets/gsaauctions/v2/auctions";
+
+// The API returns lowercase-first field names; type them loosely + read defensively.
+interface GsaRow {
+  saleNo?: string;
+  lotNo?: string;
+  itemName?: string;
+  itemDescURL?: string;
+  lotInfo?: string;
+  aucStartDt?: string;
+  aucEndDt?: string;
+  auctionStatus?: string;
+  highBidAmount?: string | number;
+  reserve?: string | number;
+  aucIncrement?: string | number;
+  biddersCount?: string | number;
+  propertyCity?: string;
+  propertyState?: string;
+  propertyZip?: string;
+  agencyName?: string;
+  bureauName?: string;
+  imageURL?: string;
+  instruction1?: string;
+  instruction2?: string;
+  instruction3?: string;
+}
+
+const numOr = (v: unknown, d = 0): number => {
+  const n = Number(String(v ?? "").replace(/[$,]/g, ""));
+  return Number.isFinite(n) ? n : d;
+};
+
+function mapRow(r: GsaRow): RawListing | null {
+  const sale = r.saleNo?.trim();
+  const lot = r.lotNo?.trim();
+  const title = r.itemName?.trim();
+  if (!sale || !title) return null;
+
+  const terms = [r.instruction1, r.instruction2, r.instruction3, r.reserve ? `Reserve: ${r.reserve}` : "", r.aucIncrement ? `Bid increment: ${r.aucIncrement}` : ""]
+    .filter(Boolean)
+    .join(" ");
+
+  return {
+    source: "GSA_AUCTIONS",
+    sourceAuctionId: [sale, lot].filter(Boolean).join("-"),
+    sourceUrl: r.itemDescURL || "https://gsaauctions.gov",
+    title,
+    description: [r.lotInfo, r.agencyName, r.bureauName].filter(Boolean).join(" · ") || undefined,
+    category: r.agencyName || undefined,
+    condition: "UNKNOWN",
+    quantity: 1,
+    locationCity: r.propertyCity,
+    locationState: r.propertyState,
+    locationZip: r.propertyZip,
+    currentBid: numOr(r.highBidAmount),
+    bidCount: numOr(r.biddersCount),
+    closingAt: r.aucEndDt || null,
+    imageUrls: r.imageURL ? [r.imageURL] : [],
+    auctionTerms: terms || undefined,
+  };
+}
+
+/** Fetch active GSA auction listings and normalize them to RawListing[]. */
+export async function fetchGsaAuctions(opts: { limit?: number; onlyOpen?: boolean } = {}): Promise<RawListing[]> {
+  const key = process.env.GSA_API_KEY || "DEMO_KEY";
+  const res = await fetch(GSA_URL, {
+    headers: { "X-API-KEY": key, Accept: "application/json" },
+    // fetch follows the 303 → S3 automatically.
+  });
+  if (!res.ok) {
+    throw new Error(`GSA Auctions API ${res.status}: ${(await res.text()).slice(0, 200)}`);
+  }
+  const data = (await res.json()) as { Results?: GsaRow[] };
+  const rows = (data.Results ?? []).map(mapRow).filter((x): x is RawListing => x !== null);
+
+  const filtered = opts.onlyOpen
+    ? rows.filter((r) => r.closingAt && new Date(r.closingAt).getTime() >= Date.now() - 86_400_000)
+    : rows;
+  return opts.limit ? filtered.slice(0, opts.limit) : filtered;
+}
diff --git a/govarbitrage/src/importers/ingest.ts b/govarbitrage/src/importers/ingest.ts
new file mode 100644
index 0000000..df822df
--- /dev/null
+++ b/govarbitrage/src/importers/ingest.ts
@@ -0,0 +1,159 @@
+import { prisma } from "@/lib/db";
+import { runResearch } from "@/pipeline/research";
+import type { AuctionSource, Condition } from "@prisma/client";
+
+// Normalized import shape every importer (CSV, extension, URL scrape) produces.
+export interface RawListing {
+  source: string;
+  sourceAuctionId: string;
+  sourceUrl?: string;
+  title: string;
+  description?: string;
+  category?: string;
+  manufacturer?: string;
+  model?: string;
+  condition?: string;
+  quantity?: number;
+  weightLbs?: number;
+  dimensions?: string;
+  locationCity?: string;
+  locationState?: string;
+  locationZip?: string;
+  currentBid?: number;
+  bidCount?: number;
+  closingAt?: string | Date | null;
+  imageUrls?: string[];
+  auctionTerms?: string;
+}
+
+const SOURCE_MAP: Record<string, AuctionSource> = {
+  GOVDEALS: "GOVDEALS",
+  PUBLIC_SURPLUS: "PUBLIC_SURPLUS",
+  PUBLICSURPLUS: "PUBLIC_SURPLUS",
+  GSA_AUCTIONS: "GSA_AUCTIONS",
+  GSA: "GSA_AUCTIONS",
+  COUNTY: "COUNTY",
+  STATE_SURPLUS: "STATE_SURPLUS",
+  UNIVERSITY_SURPLUS: "UNIVERSITY_SURPLUS",
+  MUNICIBID: "MUNICIBID",
+  BID4ASSETS: "BID4ASSETS",
+  GOINDUSTRY: "GOINDUSTRY",
+  NETWORK_INTL: "NETWORK_INTL",
+  GRAYS_AU: "GRAYS_AU",
+  GOVPLANET: "GOVPLANET",
+  CSV: "CSV",
+  EXTENSION: "EXTENSION",
+};
+
+const CONDITION_MAP: Record<string, Condition> = {
+  NEW: "NEW",
+  LIKE_NEW: "LIKE_NEW",
+  LIKENEW: "LIKE_NEW",
+  USED_GOOD: "USED_GOOD",
+  GOOD: "USED_GOOD",
+  USED: "USED_GOOD",
+  USED_FAIR: "USED_FAIR",
+  FAIR: "USED_FAIR",
+  FOR_PARTS: "FOR_PARTS",
+  PARTS: "FOR_PARTS",
+  SALVAGE: "FOR_PARTS",
+  UNKNOWN: "UNKNOWN",
+};
+
+export function normalizeSource(s: string | undefined): AuctionSource {
+  return SOURCE_MAP[(s || "").toUpperCase().replace(/[\s-]/g, "_")] ?? "OTHER";
+}
+
+export function normalizeCondition(c: string | undefined): Condition {
+  return CONDITION_MAP[(c || "").toUpperCase().replace(/[\s-]/g, "_")] ?? "UNKNOWN";
+}
+
+export interface IngestResult {
+  listingId: string;
+  created: boolean;
+  research?: Awaited<ReturnType<typeof runResearch>>;
+}
+
+/**
+ * Ingest one normalized listing: upsert by (source, sourceAuctionId), then run
+ * the research pipeline (local AI identification + engines). Idempotent.
+ */
+export async function ingest(
+  raw: RawListing,
+  opts: { research?: boolean; useAI?: boolean } = {},
+): Promise<IngestResult> {
+  if (!raw.title || !raw.sourceAuctionId) {
+    throw new Error("Import requires at least title and sourceAuctionId");
+  }
+  const source = normalizeSource(raw.source);
+  const closingAt = raw.closingAt ? new Date(raw.closingAt) : null;
+
+  const existing = await prisma.listing.findUnique({
+    where: { source_sourceAuctionId: { source, sourceAuctionId: raw.sourceAuctionId } },
+  });
+
+  const data = {
+    source,
+    sourceAuctionId: raw.sourceAuctionId,
+    sourceUrl: raw.sourceUrl ?? null,
+    title: raw.title,
+    description: raw.description ?? null,
+    category: raw.category ?? null,
+    manufacturer: raw.manufacturer ?? null,
+    model: raw.model ?? null,
+    condition: normalizeCondition(raw.condition),
+    quantity: raw.quantity && raw.quantity > 0 ? Math.floor(raw.quantity) : 1,
+    weightLbs: raw.weightLbs ?? null,
+    dimensions: raw.dimensions ?? null,
+    locationCity: raw.locationCity ?? null,
+    locationState: raw.locationState ?? null,
+    locationZip: raw.locationZip ?? null,
+    currentBid: raw.currentBid ?? 0,
+    bidCount: raw.bidCount ?? 0,
+    closingAt: closingAt && !isNaN(closingAt.getTime()) ? closingAt : null,
+    imageUrls: raw.imageUrls ?? [],
+    auctionTerms: raw.auctionTerms ?? null,
+    // Seeing the item in an import = it's live on the source right now.
+    // Refresh lastSeenAt and reactivate (an item that reappears is live again).
+    lastSeenAt: new Date(),
+    listingStatus: "ACTIVE" as const,
+    endedAt: null,
+  };
+
+  const listing = existing
+    ? await prisma.listing.update({ where: { id: existing.id }, data })
+    : await prisma.listing.create({ data: { ...data, researchStatus: "PENDING" } });
+
+  await prisma.listingEvent.create({
+    data: {
+      listingId: listing.id,
+      type: existing ? "MANUAL_EDIT" : "IMPORTED",
+      message: existing ? `Re-imported from ${source}` : `Imported from ${source}`,
+    },
+  });
+
+  let research;
+  if (opts.research !== false) {
+    // Live single imports use the local AI identifier; bulk imports pass
+    // useAI:false for speed (the worker AI-enriches later). Failures degrade
+    // to the heuristic path inside runResearch either way.
+    const useAI = opts.useAI !== false;
+    research = await runResearch(listing.id, { useAI, writeIdentity: useAI });
+  }
+
+  return { listingId: listing.id, created: !existing, research };
+}
+
+/** Bulk ingest with per-row error capture. */
+export async function ingestMany(rows: RawListing[], opts: { research?: boolean; useAI?: boolean } = {}) {
+  const results: { ok: boolean; listingId?: string; created?: boolean; error?: string; title: string }[] = [];
+  for (const row of rows) {
+    try {
+      const r = await ingest(row, opts);
+      results.push({ ok: true, listingId: r.listingId, created: r.created, title: row.title });
+    } catch (e) {
+      results.push({ ok: false, error: (e as Error).message, title: row.title || "(untitled)" });
+    }
+  }
+  return results;
+}
diff --git a/govarbitrage/src/importers/municibid-free.ts b/govarbitrage/src/importers/municibid-free.ts
new file mode 100644
index 0000000..1e5dcc8
--- /dev/null
+++ b/govarbitrage/src/importers/municibid-free.ts
@@ -0,0 +1,246 @@
+import type { RawListing } from "./ingest";
+
+// FREE Municibid importer. $0, no browser at runtime.
+//
+// Municibid is a classic server-side-rendered ASP.NET site (jQuery + SignalR
+// for live bid pushes) — there is NO JSON search/listings API to replay. The
+// listing cards arrive inside the initial /browse HTML document, and plain
+// curl gets HTTP 200 (no Cloudflare wall). So the crack is straight HTML
+// parsing of the `.browse-item` cards, paginated via `?page=N`.
+//
+// Verified 2026-07-10 via openclaw real-Chrome capture:
+//   - performance.getEntriesByType('resource') showed ZERO listings API —
+//     only analytics (Google/Clarity/Customer.io), FontAwesome, wisepops, and
+//     SignalR (/signalr/* on `listinghub` = live bid-price push, not data).
+//   - The "gist.build" lead from an earlier pass was a red herring: it's the
+//     Customer.io "Gist" support-chat widget, unrelated to auctions.
+//
+// Card shape (per `div.browse-item[data-listingid]`):
+//   id      -> data-listingid
+//   title   -> desktop <h2 class="text-card"><a>FULL TITLE</a>
+//   url     -> /Listing/Details/{id}/{slug}
+//   bid     -> <span class="awe-rt-CurrentPrice ...">$<span class="NumberPart">235.00</span>
+//   bids    -> <span class="awe-rt-AcceptedListingActionCount" ...>8</span>
+//   close   -> <span data-epoch="ending" data-action-time="MM/DD/YYYY HH:MM:SS">
+//   image   -> storagemunicibidpro.blob.core.windows.net/assets/media/{uuid}_thumbcrop.jpg
+//   loc     -> <p class="card-subtitle">City , ST | Seller Agency</p>
+
+const BASE = "https://municibid.com";
+const UA =
+  "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
+  "(KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36";
+
+const STATE_ABBR = new Set([
+  "AL","AK","AZ","AR","CA","CO","CT","DE","FL","GA","HI","ID","IL","IN","IA","KS",
+  "KY","LA","ME","MD","MA","MI","MN","MS","MO","MT","NE","NV","NH","NJ","NM","NY",
+  "NC","ND","OH","OK","OR","PA","RI","SC","SD","TN","TX","UT","VT","VA","WA","WV",
+  "WI","WY","DC","PR",
+]);
+
+function decode(s: string | undefined): string {
+  if (!s) return "";
+  return s
+    .replace(/&amp;/g, "&")
+    .replace(/&#39;/g, "'")
+    .replace(/&#039;/g, "'")
+    .replace(/&quot;/g, '"')
+    .replace(/&nbsp;/g, " ")
+    .replace(/&lt;/g, "<")
+    .replace(/&gt;/g, ">")
+    .replace(/\s+/g, " ")
+    .trim();
+}
+
+function first(re: RegExp, s: string): string | undefined {
+  const m = re.exec(s);
+  return m ? m[1] : undefined;
+}
+
+// Parse "MM/DD/YYYY HH:MM:SS" (US Eastern, Municibid's server zone) to ISO.
+// We treat it as local wall-clock; exactness to the minute isn't required by
+// the research pipeline, and a naive Date parse of this format is stable.
+function parseCloseTime(raw: string | undefined): string | null {
+  if (!raw) return null;
+  const m = /(\d{1,2})\/(\d{1,2})\/(\d{4})\s+(\d{1,2}):(\d{2}):(\d{2})/.exec(raw);
+  if (!m) return null;
+  const [, mo, d, y, h, mi, s] = m;
+  const dt = new Date(
+    Number(y),
+    Number(mo) - 1,
+    Number(d),
+    Number(h),
+    Number(mi),
+    Number(s),
+  );
+  return isNaN(dt.getTime()) ? null : dt.toISOString();
+}
+
+function parseCard(block: string): RawListing | null {
+  const id = first(/data-listingid="(\d+)"/, block);
+  if (!id) return null;
+
+  // Full (untruncated) title from the desktop `.text-card` <h2><a>…</a>.
+  // Fall back to the slug in the detail URL, or the truncated card-title.
+  const detailRe = new RegExp(
+    `/Listing/Details/${id}/([A-Za-z0-9%\\-_.]+)`,
+  );
+  const slug = first(detailRe, block);
+  let title = first(
+    /class="text-card"[^>]*>\s*<a[^>]*>([\s\S]*?)<\/a>/,
+    block,
+  );
+  title = decode(title);
+  if (!title && slug) {
+    title = decode(decodeURIComponent(slug).replace(/[-_]+/g, " "));
+  }
+  if (!title) {
+    title = decode(
+      first(/class="card-title[^"]*"[^>]*>\s*<a[^>]*>([\s\S]*?)<\/a>/, block),
+    ).replace(/\.\.\.$/, "");
+  }
+  if (!title) return null;
+
+  const url = slug
+    ? `${BASE}/Listing/Details/${id}/${slug}`
+    : `${BASE}/Listing/Details/${id}`;
+
+  // Current bid: $<span class="NumberPart">235.00</span>
+  const priceStr = first(
+    /awe-rt-CurrentPrice[^>]*>\s*\$?\s*<span class="NumberPart">([\d,]+(?:\.\d+)?)<\/span>/,
+    block,
+  );
+  const currentBid = priceStr ? Number(priceStr.replace(/,/g, "")) : 0;
+
+  // Bid count.
+  const bidStr = first(
+    /awe-rt-AcceptedListingActionCount[^>]*>\s*(\d+)\s*</,
+    block,
+  );
+  const bidCount = bidStr ? Number(bidStr) : 0;
+
+  // Absolute close time.
+  const closingAt = parseCloseTime(
+    first(/data-epoch="ending"[^>]*data-action-time="([^"]+)"/, block) ||
+      first(/data-action-time="([^"]+)"[^>]*data-epoch="ending"/, block),
+  );
+
+  // Image (thumbcrop → strip to a larger asset when possible).
+  let img = first(
+    /(https:\/\/storagemunicibidpro\.blob\.core\.windows\.net\/assets\/media\/[A-Za-z0-9-]+_thumbcrop\.jpg)/,
+    block,
+  );
+  const imageUrls = img ? [img] : [];
+
+  // Location + seller from card-subtitle: "City , ST | Seller Agency".
+  const subtitle = decode(
+    first(/class="card-subtitle[^"]*"[^>]*>([\s\S]*?)<\/p>/, block),
+  );
+  let locationCity: string | undefined;
+  let locationState: string | undefined;
+  let seller: string | undefined;
+  if (subtitle) {
+    const [locPart, ...rest] = subtitle.split("|");
+    seller = rest.join("|").trim() || undefined;
+    const lm = /^(.*?)[,\s]+([A-Z]{2})\s*$/.exec(locPart.trim());
+    if (lm && STATE_ABBR.has(lm[2])) {
+      locationCity = lm[1].replace(/,\s*$/, "").trim() || undefined;
+      locationState = lm[2];
+    } else {
+      locationCity = locPart.trim() || undefined;
+    }
+  }
+
+  const descParts = [
+    seller ? `Seller: ${seller}` : "",
+    locationCity || locationState
+      ? `Location: ${[locationCity, locationState].filter(Boolean).join(", ")}`
+      : "",
+  ].filter(Boolean);
+
+  return {
+    source: "MUNICIBID",
+    sourceAuctionId: id,
+    sourceUrl: url,
+    title,
+    description: descParts.join(" · ") || undefined,
+    // NB: `seller` is the government agency, NOT the product maker — keep it in
+    // description only. Leaving `manufacturer` unset lets the research pipeline
+    // extract the real brand from the title.
+    locationCity,
+    locationState,
+    currentBid,
+    bidCount,
+    closingAt,
+    imageUrls,
+  };
+}
+
+async function fetchPage(page: number): Promise<string> {
+  const url = page <= 1 ? `${BASE}/browse` : `${BASE}/browse?page=${page}`;
+  const res = await fetch(url, {
+    headers: {
+      "User-Agent": UA,
+      Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
+      "Accept-Language": "en-US,en;q=0.9",
+    },
+  });
+  if (!res.ok) throw new Error(`Municibid /browse page ${page} -> HTTP ${res.status}`);
+  return res.text();
+}
+
+function parsePage(html: string): RawListing[] {
+  // Split into per-card blocks on the data-listingid boundary. Each card's
+  // markup (mobile + desktop) lives between one data-listingid and the next.
+  const parts = html.split(/(?=<div class="row browse-item)/);
+  const out: RawListing[] = [];
+  for (const part of parts) {
+    if (!/data-listingid="\d+"/.test(part)) continue;
+    const card = parseCard(part);
+    if (card) out.push(card);
+  }
+  return out;
+}
+
+export interface FetchMunicibidOpts {
+  limit?: number;
+  maxPages?: number;
+}
+
+/**
+ * Fetch up to `limit` active Municibid listings by paginating /browse?page=N.
+ * Dedupes by listing id. $0 — plain HTTPS, no browser, no API key.
+ */
+export async function fetchMunicibidFree(
+  opts: FetchMunicibidOpts = {},
+): Promise<RawListing[]> {
+  const limit = opts.limit ?? 100;
+  const maxPages = opts.maxPages ?? 40;
+  const seen = new Set<string>();
+  const rows: RawListing[] = [];
+
+  for (let page = 1; page <= maxPages && rows.length < limit; page++) {
+    let html: string;
+    try {
+      html = await fetchPage(page);
+    } catch (e) {
+      console.warn(`[municibid-free] page ${page} fetch failed: ${(e as Error).message}`);
+      break;
+    }
+    const cards = parsePage(html);
+    if (cards.length === 0) break; // ran past the last page
+    let added = 0;
+    for (const c of cards) {
+      if (seen.has(c.sourceAuctionId)) continue;
+      seen.add(c.sourceAuctionId);
+      rows.push(c);
+      added++;
+      if (rows.length >= limit) break;
+    }
+    // If a full page yielded no NEW ids, we've looped back to the start.
+    if (added === 0) break;
+    // Be polite: small jitter between page fetches.
+    await new Promise((r) => setTimeout(r, 350 + Math.floor(page % 3) * 120));
+  }
+
+  return rows.slice(0, limit);
+}
diff --git a/govarbitrage/src/importers/publicsurplus-free.ts b/govarbitrage/src/importers/publicsurplus-free.ts
new file mode 100644
index 0000000..8c03d1a
--- /dev/null
+++ b/govarbitrage/src/importers/publicsurplus-free.ts
@@ -0,0 +1,211 @@
+import type { RawListing } from "./ingest";
+
+// FREE Public Surplus importer. $0, no browser at runtime.
+//
+// Public Surplus is server-rendered (Java/Spring `/sms/…` app). The category
+// listing pages return fully-rendered auction cards to plain curl (HTTP 200,
+// no bot wall), so the crack is HTML parsing of the `.auction-item` cards.
+//
+// Verified 2026-07-10:
+//   list  -> GET /sms/browse/cataucs?catid={C}&page={P}   (25 cards/page, 0-idx)
+//   item  -> /sms/auction/view?auc={id}
+//   card (per `div.auction-item[id="{id}catGrid"]`):
+//     id    -> id="{id}catGrid"
+//     title -> <a href="/sms/auction/view?auc={id}" title="#{id} - {TITLE}">
+//     state -> <span class="auction-item-state"> CA </span>
+//     price -> <b id="val_{id}catGrid"> $35.00 </b>
+//     time  -> <span id="timeLeftValue{id}catGrid" ...> 4 mins </span>  (RELATIVE)
+//     image -> https://d37qv0n5b4mbzm.cloudfront.net/.../thumb-b/{id}/{doc}
+//
+// Close time is relative ("4 mins" / "2 days" / "1 hour"), not absolute, so we
+// compute closingAt = now + parsed delta. Categories enumerated from the home
+// page (catid 1..29). No API key, no browser.
+
+const BASE = "https://www.publicsurplus.com";
+const UA =
+  "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
+  "(KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36";
+
+// Category ids present on the browse home (2026-07-10). Enumerated rather than
+// hardcoded 1..N because 7 is absent; kept as a static list for stability.
+const CATEGORY_IDS = [
+  1, 2, 3, 4, 5, 6, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22,
+  23, 24, 25, 26, 27, 28, 29,
+];
+
+function decode(s: string | undefined): string {
+  if (!s) return "";
+  return s
+    .replace(/&amp;/g, "&")
+    .replace(/&#39;/g, "'")
+    .replace(/&#039;/g, "'")
+    .replace(/&quot;/g, '"')
+    .replace(/&nbsp;/g, " ")
+    .replace(/&lt;/g, "<")
+    .replace(/&gt;/g, ">")
+    .replace(/\s+/g, " ")
+    .trim();
+}
+
+function first(re: RegExp, s: string): string | undefined {
+  const m = re.exec(s);
+  return m ? m[1] : undefined;
+}
+
+// Parse relative "Time Left" like "4 mins", "2 days", "1 hour", "3 hrs",
+// "5 days, 2 hours" -> ISO close time (now + delta). Returns null if unparseable
+// or already ended.
+function parseRelativeClose(raw: string | undefined): string | null {
+  if (!raw) return null;
+  const txt = raw.toLowerCase();
+  if (/ended|closed|over/.test(txt)) return null;
+  const units: [RegExp, number][] = [
+    [/(\d+)\s*day/, 86400_000],
+    [/(\d+)\s*h(?:ou)?r/, 3600_000],
+    [/(\d+)\s*min/, 60_000],
+    [/(\d+)\s*sec/, 1000],
+  ];
+  let deltaMs = 0;
+  let matched = false;
+  for (const [re, ms] of units) {
+    const m = re.exec(txt);
+    if (m) {
+      deltaMs += Number(m[1]) * ms;
+      matched = true;
+    }
+  }
+  if (!matched) return null;
+  return new Date(Date.now() + deltaMs).toISOString();
+}
+
+function parseCard(block: string, id: string): RawListing | null {
+  // Title: prefer the <a title="#id - TITLE"> attribute (full), strip the
+  // leading "#id - " prefix.
+  let title = decode(
+    first(
+      new RegExp(`/sms/auction/view\\?auc=${id}"[^>]*title="([^"]+)"`),
+      block,
+    ),
+  );
+  title = title.replace(new RegExp(`^#?${id}\\s*[-–]\\s*`), "").trim();
+  if (!title) {
+    // fallback: visible anchor text
+    title = decode(
+      first(
+        new RegExp(`/sms/auction/view\\?auc=${id}"[^>]*>([\\s\\S]*?)</a>`),
+        block,
+      ),
+    ).replace(new RegExp(`^#?${id}\\s*[-–]\\s*`), "");
+  }
+  if (!title || title === "...") return null;
+
+  const priceStr = first(
+    new RegExp(`id="val_${id}catGrid"[^>]*>\\s*\\$?([\\d,]+(?:\\.\\d+)?)`),
+    block,
+  );
+  const currentBid = priceStr ? Number(priceStr.replace(/,/g, "")) : 0;
+
+  const timeRaw = decode(
+    first(
+      new RegExp(`id="timeLeftValue${id}catGrid"[^>]*>([\\s\\S]*?)</span>`),
+      block,
+    ),
+  );
+  const closingAt = parseRelativeClose(timeRaw);
+
+  const state = decode(
+    first(/class="auction-item-state"[^>]*>([\s\S]*?)<\/span>/, block),
+  );
+
+  const img = first(
+    /(https:\/\/[a-z0-9]+\.cloudfront\.net\/sms\/docviewer\/[^\s"']+)/,
+    block,
+  );
+
+  return {
+    source: "PUBLIC_SURPLUS",
+    sourceAuctionId: id,
+    sourceUrl: `${BASE}/sms/auction/view?auc=${id}`,
+    title,
+    locationState: state && /^[A-Z]{2}$/.test(state) ? state : undefined,
+    currentBid,
+    bidCount: 0, // not shown on the list card; enriched later if needed
+    closingAt,
+    imageUrls: img ? [img] : [],
+  };
+}
+
+function parsePage(html: string): RawListing[] {
+  const out: RawListing[] = [];
+  // Each card is `<div class="auction-item" id="{id}catGrid">`. Split on that
+  // boundary and parse each block against its own id.
+  const re = /id="(\d+)catGrid"/g;
+  const ids: { id: string; idx: number }[] = [];
+  let m: RegExpExecArray | null;
+  while ((m = re.exec(html))) ids.push({ id: m[1], idx: m.index });
+  for (let i = 0; i < ids.length; i++) {
+    const start = ids[i].idx;
+    const end = i + 1 < ids.length ? ids[i + 1].idx : Math.min(html.length, start + 4000);
+    const block = html.slice(start, end);
+    const card = parseCard(block, ids[i].id);
+    if (card) out.push(card);
+  }
+  return out;
+}
+
+async function fetchList(catid: number, page: number): Promise<string> {
+  const url = `${BASE}/sms/browse/cataucs?catid=${catid}&page=${page}`;
+  const res = await fetch(url, {
+    headers: {
+      "User-Agent": UA,
+      Accept: "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
+      "Accept-Language": "en-US,en;q=0.9",
+    },
+  });
+  if (!res.ok) throw new Error(`PublicSurplus cataucs c=${catid} p=${page} -> HTTP ${res.status}`);
+  return res.text();
+}
+
+export interface FetchPublicSurplusOpts {
+  limit?: number;
+  pagesPerCategory?: number;
+}
+
+/**
+ * Fetch up to `limit` active Public Surplus listings by walking categories and
+ * paginating each. Dedupes by auction id. $0 — plain HTTPS, no browser/API key.
+ */
+export async function fetchPublicSurplusFree(
+  opts: FetchPublicSurplusOpts = {},
+): Promise<RawListing[]> {
+  const limit = opts.limit ?? 100;
+  const pagesPerCategory = opts.pagesPerCategory ?? 4;
+  const seen = new Set<string>();
+  const rows: RawListing[] = [];
+
+  outer: for (const catid of CATEGORY_IDS) {
+    for (let page = 0; page < pagesPerCategory; page++) {
+      let html: string;
+      try {
+        html = await fetchList(catid, page);
+      } catch (e) {
+        console.warn(`[publicsurplus-free] ${(e as Error).message}`);
+        break; // next category
+      }
+      const cards = parsePage(html);
+      if (cards.length === 0) break; // past the last page of this category
+      let added = 0;
+      for (const c of cards) {
+        if (seen.has(c.sourceAuctionId)) continue;
+        seen.add(c.sourceAuctionId);
+        rows.push(c);
+        added++;
+        if (rows.length >= limit) break outer;
+      }
+      if (added === 0) break; // looped back — next category
+      await new Promise((r) => setTimeout(r, 300 + (page % 3) * 120));
+    }
+  }
+
+  return rows.slice(0, limit);
+}
diff --git a/govarbitrage/src/importers/scrape.ts b/govarbitrage/src/importers/scrape.ts
new file mode 100644
index 0000000..cf79eca
--- /dev/null
+++ b/govarbitrage/src/importers/scrape.ts
@@ -0,0 +1,80 @@
+import type { RawListing } from "./ingest";
+
+// Best-effort single-URL importer. Uses Playwright (a devDependency) to render
+// the page, then extracts fields with host-specific selectors and generic
+// fallbacks (og: tags, $-regex). Live auction-site DOMs change often, so this is
+// intentionally defensive: whatever it can't find is left undefined and the
+// research pipeline's AI identifier fills gaps from the title/description.
+
+function sourceFromHost(host: string): string {
+  if (host.includes("govdeals")) return "GOVDEALS";
+  if (host.includes("publicsurplus")) return "PUBLIC_SURPLUS";
+  if (host.includes("gsaauctions")) return "GSA_AUCTIONS";
+  if (host.includes("municibid")) return "MUNICIBID";
+  if (host.includes("bid4assets")) return "BID4ASSETS";
+  return "OTHER";
+}
+
+function auctionIdFromUrl(u: URL): string {
+  const q =
+    u.searchParams.get("itemid") ||
+    u.searchParams.get("auc") ||
+    u.searchParams.get("id") ||
+    u.searchParams.get("ac");
+  if (q) return q;
+  const segs = u.pathname.split("/").filter(Boolean);
+  return segs[segs.length - 1] || u.pathname;
+}
+
+export async function scrapeUrl(url: string): Promise<RawListing> {
+  const u = new URL(url);
+  const source = sourceFromHost(u.hostname);
+
+  // Dynamic import so the main app bundle never hard-depends on Playwright.
+  let chromium: typeof import("playwright").chromium;
+  try {
+    ({ chromium } = await import("playwright"));
+  } catch {
+    throw new Error(
+      "Playwright is not installed. Run `npx playwright install chromium` to enable URL import.",
+    );
+  }
+
+  const browser = await chromium.launch({ headless: true });
+  try {
+    const page = await browser.newPage({ userAgent: "Mozilla/5.0 GovArbitrageBot/0.1" });
+    await page.goto(url, { waitUntil: "domcontentloaded", timeout: 30_000 });
+
+    const extracted = await page.evaluate(() => {
+      const meta = (p: string) =>
+        (document.querySelector(`meta[property="${p}"]`) as HTMLMetaElement | null)?.content ||
+        (document.querySelector(`meta[name="${p}"]`) as HTMLMetaElement | null)?.content ||
+        undefined;
+      const text = document.body?.innerText || "";
+      const priceMatch = text.match(/\$\s?([\d,]+(?:\.\d{2})?)/);
+      const images = Array.from(document.querySelectorAll("img"))
+        .map((i) => (i as HTMLImageElement).src)
+        .filter((s) => s && s.startsWith("http"))
+        .slice(0, 6);
+      const ogImage = meta("og:image");
+      return {
+        title: meta("og:title") || document.querySelector("h1")?.textContent?.trim() || document.title,
+        description: meta("og:description") || undefined,
+        currentBid: priceMatch ? Number(priceMatch[1].replace(/,/g, "")) : undefined,
+        imageUrls: ogImage ? [ogImage, ...images] : images,
+      };
+    });
+
+    return {
+      source,
+      sourceAuctionId: auctionIdFromUrl(u),
+      sourceUrl: url,
+      title: extracted.title || `${source} lot ${auctionIdFromUrl(u)}`,
+      description: extracted.description,
+      currentBid: extracted.currentBid,
+      imageUrls: extracted.imageUrls,
+    };
+  } finally {
+    await browser.close();
+  }
+}
diff --git a/govarbitrage/src/lib/ai.ts b/govarbitrage/src/lib/ai.ts
new file mode 100644
index 0000000..725384a
--- /dev/null
+++ b/govarbitrage/src/lib/ai.ts
@@ -0,0 +1,122 @@
+// Local-first AI layer. Product identification runs on Ollama (this machine,
+// $0/query). Anthropic is an opt-in fallback only, enabled by setting
+// AI_PROVIDER=anthropic and ANTHROPIC_API_KEY. If no model is reachable, callers
+// fall back to the deterministic heuristic in engines/demand.ts.
+
+const OLLAMA_BASE = process.env.OLLAMA_BASE_URL || "http://localhost:11434";
+const TEXT_MODEL = process.env.OLLAMA_TEXT_MODEL || "qwen3:14b";
+const VISION_MODEL = process.env.OLLAMA_VISION_MODEL || "qwen2.5vl:7b";
+
+export interface AiIdentification {
+  manufacturer?: string;
+  model?: string;
+  category?: string;
+  estimatedNewRetail?: number;
+  demandScore?: number; // 0..100
+  confidence?: number; // 0..1
+  reasoning?: string;
+}
+
+const SYSTEM = `You are a resale-arbitrage product analyst. Given a government-surplus
+auction listing, identify the product and estimate its market. Respond ONLY with
+compact JSON matching:
+{"manufacturer":string,"model":string,"category":string,"estimatedNewRetail":number,"demandScore":number,"confidence":number,"reasoning":string}
+demandScore is 0-100 (secondary-market liquidity). confidence is 0-1. Use USD.`;
+
+async function ollamaJson(prompt: string, model = TEXT_MODEL, images?: string[]): Promise<AiIdentification | null> {
+  try {
+    const controller = new AbortController();
+    const timeout = setTimeout(() => controller.abort(), 45_000);
+    const res = await fetch(`${OLLAMA_BASE}/api/generate`, {
+      method: "POST",
+      headers: { "Content-Type": "application/json" },
+      signal: controller.signal,
+      body: JSON.stringify({
+        model,
+        prompt: `${SYSTEM}\n\nLISTING:\n${prompt}`,
+        stream: false,
+        format: "json",
+        options: { temperature: 0.1 },
+        ...(images && images.length ? { images } : {}),
+      }),
+    });
+    clearTimeout(timeout);
+    if (!res.ok) return null;
+    const data = (await res.json()) as { response?: string };
+    if (!data.response) return null;
+    return JSON.parse(data.response) as AiIdentification;
+  } catch {
+    return null;
+  }
+}
+
+/** Identify a product from listing text (and optionally base64 images). */
+export async function identifyProduct(args: {
+  title: string;
+  description?: string | null;
+  category?: string | null;
+  imagesBase64?: string[];
+}): Promise<{ result: AiIdentification | null; model: string }> {
+  const provider = process.env.AI_PROVIDER || "ollama";
+  const text = [
+    `Title: ${args.title}`,
+    args.category ? `Category: ${args.category}` : "",
+    args.description ? `Description: ${args.description}` : "",
+  ]
+    .filter(Boolean)
+    .join("\n");
+
+  // Vision path when images are supplied and a vision model is configured.
+  if (args.imagesBase64 && args.imagesBase64.length) {
+    const v = await ollamaJson(text, VISION_MODEL, args.imagesBase64);
+    if (v) return { result: v, model: `ollama:${VISION_MODEL}` };
+  }
+
+  const t = await ollamaJson(text, TEXT_MODEL);
+  if (t) return { result: t, model: `ollama:${TEXT_MODEL}` };
+
+  // Opt-in cloud fallback.
+  if (provider === "anthropic" && process.env.ANTHROPIC_API_KEY) {
+    const a = await anthropicJson(text);
+    if (a) return { result: a, model: "anthropic:claude" };
+  }
+
+  return { result: null, model: "heuristic" };
+}
+
+async function anthropicJson(text: string): Promise<AiIdentification | null> {
+  try {
+    const res = await fetch("https://api.anthropic.com/v1/messages", {
+      method: "POST",
+      headers: {
+        "Content-Type": "application/json",
+        "x-api-key": process.env.ANTHROPIC_API_KEY as string,
+        "anthropic-version": "2023-06-01",
+      },
+      body: JSON.stringify({
+        model: "claude-haiku-4-5-20251001",
+        max_tokens: 512,
+        system: SYSTEM,
+        messages: [{ role: "user", content: `LISTING:\n${text}` }],
+      }),
+    });
+    if (!res.ok) return null;
+    const data = (await res.json()) as { content?: { text?: string }[] };
+    const raw = data.content?.[0]?.text;
+    if (!raw) return null;
+    const match = raw.match(/\{[\s\S]*\}/);
+    return match ? (JSON.parse(match[0]) as AiIdentification) : null;
+  } catch {
+    return null;
+  }
+}
+
+/** Is a local model reachable? Used by health checks + the worker banner. */
+export async function ollamaReachable(): Promise<boolean> {
+  try {
+    const res = await fetch(`${OLLAMA_BASE}/api/tags`, { signal: AbortSignal.timeout(3000) });
+    return res.ok;
+  } catch {
+    return false;
+  }
+}
diff --git a/govarbitrage/src/lib/auth.test.ts b/govarbitrage/src/lib/auth.test.ts
new file mode 100644
index 0000000..cf22604
--- /dev/null
+++ b/govarbitrage/src/lib/auth.test.ts
@@ -0,0 +1,41 @@
+import { describe, expect, it, beforeAll } from "vitest";
+import { hashPassword, verifyPassword } from "./password";
+import { createSession, verifySession } from "./session";
+
+beforeAll(() => {
+  process.env.AUTH_SECRET = "test-secret-please-change";
+});
+
+describe("password hashing (scrypt)", () => {
+  it("verifies a correct password and rejects a wrong one", () => {
+    const hash = hashPassword("s3cret-pw");
+    expect(hash.startsWith("scrypt$")).toBe(true);
+    expect(verifyPassword("s3cret-pw", hash)).toBe(true);
+    expect(verifyPassword("wrong", hash)).toBe(false);
+  });
+
+  it("salts so identical passwords hash differently", () => {
+    expect(hashPassword("same")).not.toBe(hashPassword("same"));
+  });
+
+  it("rejects malformed stored hashes", () => {
+    expect(verifyPassword("x", "$demo$changeme")).toBe(false);
+    expect(verifyPassword("x", "garbage")).toBe(false);
+  });
+});
+
+describe("session JWT", () => {
+  it("round-trips a session payload", async () => {
+    const token = await createSession({ sub: "u1", email: "a@b.com", role: "ADMIN" });
+    const payload = await verifySession(token);
+    expect(payload?.sub).toBe("u1");
+    expect(payload?.email).toBe("a@b.com");
+    expect(payload?.role).toBe("ADMIN");
+  });
+
+  it("rejects a tampered/garbage token", async () => {
+    expect(await verifySession("not.a.jwt")).toBeNull();
+    const token = await createSession({ sub: "u1", email: "a@b.com", role: "ADMIN" });
+    expect(await verifySession(token + "x")).toBeNull();
+  });
+});
diff --git a/govarbitrage/src/lib/auth.ts b/govarbitrage/src/lib/auth.ts
new file mode 100644
index 0000000..c9b639e
--- /dev/null
+++ b/govarbitrage/src/lib/auth.ts
@@ -0,0 +1,48 @@
+import { NextRequest, NextResponse } from "next/server";
+import { SESSION_COOKIE, verifySession } from "@/lib/session";
+
+export type Role = "ADMIN" | "ANALYST" | "VIEWER";
+const RANK: Record<Role, number> = { VIEWER: 0, ANALYST: 1, ADMIN: 2 };
+
+/** Resolve the caller's role from the session cookie, or null if unauthenticated. */
+async function sessionRole(req: NextRequest): Promise<Role | null> {
+  const token = req.cookies.get(SESSION_COOKIE)?.value;
+  const s = token ? await verifySession(token) : null;
+  if (!s) return null;
+  return ((s.role as Role) ?? "VIEWER");
+}
+
+function deny(role: Role | null): NextResponse {
+  // Distinguish unauthenticated (401) from authenticated-but-insufficient (403).
+  const status = role ? 403 : 401;
+  return NextResponse.json({ error: status === 403 ? "Forbidden" : "Unauthorized" }, { status });
+}
+
+/**
+ * Write-guard for import + operational endpoints (defense-in-depth behind the
+ * middleware). Allows the request when EITHER:
+ *   • a logged-in user whose role meets `minRole` (default ANALYST — a VIEWER
+ *     session, including one minted from the fleet SSO cookie, CANNOT write), OR
+ *   • the machine `x-import-token` header matches IMPORT_TOKEN (extension / CSV).
+ * Returns a 401/403 NextResponse when denied, or null when allowed.
+ */
+export async function requireWrite(req: NextRequest, minRole: Role = "ANALYST"): Promise<NextResponse | null> {
+  const role = await sessionRole(req);
+  if (role && RANK[role] >= RANK[minRole]) return null;
+
+  const importToken = process.env.IMPORT_TOKEN;
+  if (importToken && req.headers.get("x-import-token") === importToken) return null;
+
+  return deny(role);
+}
+
+/**
+ * Admin-only guard for tier-0 surfaces (stored provider credentials/secrets).
+ * NO machine-token bypass and NO fleet-SSO VIEWER access — a full ADMIN session
+ * is required, which on this app means the local username/password login.
+ */
+export async function requireAdmin(req: NextRequest): Promise<NextResponse | null> {
+  const role = await sessionRole(req);
+  if (role === "ADMIN") return null;
+  return deny(role);
+}
diff --git a/govarbitrage/src/lib/billing.ts b/govarbitrage/src/lib/billing.ts
new file mode 100644
index 0000000..063dff4
--- /dev/null
+++ b/govarbitrage/src/lib/billing.ts
@@ -0,0 +1,86 @@
+// Stripe billing for the paid-alerts SaaS — TEST/MOCK ONLY.
+// HARD RAIL: a live secret key (sk_live_) is REFUSED — going live is Steve
+// flipping the switch himself, never us. With no key we run in MOCK mode: no
+// real Stripe call, but the upgrade flow still completes so it's demoable.
+import Stripe from "stripe";
+import type { Tier } from "@prisma/client";
+import { TIERS } from "@/lib/tiers";
+
+const KEY = process.env.STRIPE_TEST_SECRET_KEY || "";
+export const LIVE_KEY_REFUSED = KEY.startsWith("sk_live_");
+export const STRIPE_MODE: "test" | "mock" =
+  KEY.startsWith("sk_test_") && !LIVE_KEY_REFUSED ? "test" : "mock";
+
+let _stripe: Stripe | null = null;
+function stripe(): Stripe | null {
+  if (STRIPE_MODE !== "test") return null;
+  if (!_stripe) _stripe = new Stripe(KEY);
+  return _stripe;
+}
+
+export interface CheckoutResult {
+  ok: boolean;
+  mode: "test" | "mock";
+  url: string;
+  error?: string;
+}
+
+/**
+ * Create a (TEST) subscription Checkout session for a tier. In mock mode returns
+ * a local success URL that simulates the upgrade so the flow is demoable.
+ */
+export async function createCheckout(opts: {
+  userId: string;
+  email: string;
+  tier: Tier;
+  baseUrl: string;
+}): Promise<CheckoutResult> {
+  const def = TIERS[opts.tier];
+  if (!def || def.priceUsd <= 0) {
+    return { ok: false, mode: STRIPE_MODE, url: "", error: "not a paid tier" };
+  }
+  if (LIVE_KEY_REFUSED) {
+    return { ok: false, mode: "mock", url: "", error: "live key refused — TEST only" };
+  }
+
+  // MOCK: no Stripe call; simulate the successful upgrade locally.
+  if (STRIPE_MODE === "mock") {
+    const url = `${opts.baseUrl}/billing/success?tier=${opts.tier}&mock=1&session=mock_${opts.userId.slice(0, 8)}`;
+    return { ok: true, mode: "mock", url };
+  }
+
+  // TEST: real Stripe test-mode subscription checkout with inline recurring price.
+  const s = stripe()!;
+  const session = await s.checkout.sessions.create({
+    mode: "subscription",
+    customer_email: opts.email,
+    client_reference_id: opts.userId,
+    metadata: { userId: opts.userId, tier: opts.tier },
+    line_items: [
+      {
+        quantity: 1,
+        price_data: {
+          currency: "usd",
+          recurring: { interval: "month" },
+          unit_amount: Math.round(def.priceUsd * 100),
+          product_data: { name: `GovArbitrage ${def.label} — hot-deal alerts` },
+        },
+      },
+    ],
+    success_url: `${opts.baseUrl}/billing/success?tier=${opts.tier}&session={CHECKOUT_SESSION_ID}`,
+    cancel_url: `${opts.baseUrl}/pricing?canceled=1`,
+  });
+  return { ok: true, mode: "test", url: session.url || "" };
+}
+
+/** Verify a Stripe webhook (TEST). Returns the event or null if unverifiable. */
+export function verifyWebhook(rawBody: string, sig: string | null): Stripe.Event | null {
+  const s = stripe();
+  const secret = process.env.STRIPE_WEBHOOK_SECRET || "";
+  if (!s || !sig || !secret) return null;
+  try {
+    return s.webhooks.constructEvent(rawBody, sig, secret);
+  } catch {
+    return null;
+  }
+}
diff --git a/govarbitrage/src/lib/crypto.test.ts b/govarbitrage/src/lib/crypto.test.ts
new file mode 100644
index 0000000..e32e8a7
--- /dev/null
+++ b/govarbitrage/src/lib/crypto.test.ts
@@ -0,0 +1,26 @@
+import { describe, expect, it, beforeAll } from "vitest";
+import { encryptSecret, decryptSecret } from "./crypto";
+
+beforeAll(() => {
+  process.env.ENCRYPTION_KEY = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
+});
+
+describe("crypto (AES-256-GCM secret storage)", () => {
+  it("round-trips a secret", () => {
+    const enc = encryptSecret("sk-super-secret-token");
+    expect(enc.ciphertext).not.toContain("super-secret");
+    expect(decryptSecret(enc)).toBe("sk-super-secret-token");
+  });
+
+  it("produces a fresh IV each time (non-deterministic ciphertext)", () => {
+    const a = encryptSecret("same");
+    const b = encryptSecret("same");
+    expect(a.iv).not.toBe(b.iv);
+    expect(a.ciphertext).not.toBe(b.ciphertext);
+  });
+
+  it("rejects a tampered auth tag", () => {
+    const enc = encryptSecret("payload");
+    expect(() => decryptSecret({ ...enc, authTag: Buffer.from("00".repeat(16), "hex").toString("base64") })).toThrow();
+  });
+});
diff --git a/govarbitrage/src/lib/crypto.ts b/govarbitrage/src/lib/crypto.ts
new file mode 100644
index 0000000..571ad21
--- /dev/null
+++ b/govarbitrage/src/lib/crypto.ts
@@ -0,0 +1,40 @@
+import crypto from "node:crypto";
+
+// AES-256-GCM encryption for secrets at rest (e.g. provider API keys stored in
+// ApiCredential). ENCRYPTION_KEY must be 32 bytes as 64 hex chars.
+
+export interface Encrypted {
+  ciphertext: string; // base64
+  iv: string; // base64
+  authTag: string; // base64
+}
+
+function key(): Buffer {
+  const hex = process.env.ENCRYPTION_KEY || "";
+  const buf = Buffer.from(hex, "hex");
+  if (buf.length !== 32) {
+    throw new Error("ENCRYPTION_KEY must be 32 bytes (64 hex chars) for AES-256-GCM");
+  }
+  return buf;
+}
+
+export function encryptSecret(plaintext: string): Encrypted {
+  const iv = crypto.randomBytes(12);
+  const cipher = crypto.createCipheriv("aes-256-gcm", key(), iv);
+  const ct = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
+  return {
+    ciphertext: ct.toString("base64"),
+    iv: iv.toString("base64"),
+    authTag: cipher.getAuthTag().toString("base64"),
+  };
+}
+
+export function decryptSecret(enc: Encrypted): string {
+  const decipher = crypto.createDecipheriv("aes-256-gcm", key(), Buffer.from(enc.iv, "base64"));
+  decipher.setAuthTag(Buffer.from(enc.authTag, "base64"));
+  const pt = Buffer.concat([
+    decipher.update(Buffer.from(enc.ciphertext, "base64")),
+    decipher.final(),
+  ]);
+  return pt.toString("utf8");
+}
diff --git a/govarbitrage/src/lib/current-user.ts b/govarbitrage/src/lib/current-user.ts
new file mode 100644
index 0000000..6d38562
--- /dev/null
+++ b/govarbitrage/src/lib/current-user.ts
@@ -0,0 +1,53 @@
+import { cookies } from "next/headers";
+import { SESSION_COOKIE, verifySession, type SessionPayload } from "./session";
+import { tierDef } from "./tiers";
+
+/** Read + verify the session for the current request (server components / routes). */
+export async function getCurrentUser(): Promise<SessionPayload | null> {
+  const store = await cookies();
+  const token = store.get(SESSION_COOKIE)?.value;
+  if (!token) return null;
+  return verifySession(token);
+}
+
+export type Role = "ADMIN" | "ANALYST" | "VIEWER";
+const RANK: Record<Role, number> = { VIEWER: 0, ANALYST: 1, ADMIN: 2 };
+
+/** True if the user's role meets or exceeds the required role. */
+export function hasRole(user: SessionPayload | null, required: Role): boolean {
+  if (!user) return false;
+  return (RANK[(user.role as Role) ?? "VIEWER"] ?? 0) >= RANK[required];
+}
+
+/**
+ * Resolve the current request's subscription tier from the DB (not the JWT — a
+ * subscription can change mid-session). Unauthenticated → FREE. ADMIN → PREMIUM
+ * (Steve always sees everything). Imported lazily to keep this edge-safe module light.
+ */
+export async function getCurrentTier(): Promise<import("@prisma/client").Tier> {
+  const user = await getCurrentUser();
+  if (!user) return "FREE";
+  if (user.role === "ADMIN") return "PREMIUM";
+  // The synthetic fleet-SSO session is Steve arriving via the fleet login — an
+  // internal operator, so show full data (money-math). It is still only ANALYST,
+  // so the ADMIN-gated credential surface stays closed.
+  if (user.sub === "fleet-sso") return "PREMIUM";
+  const { prisma } = await import("./db");
+  const row = await prisma.user.findUnique({ where: { id: user.sub }, select: { tier: true } });
+  return row?.tier ?? "FREE";
+}
+
+/**
+ * Whether the money-math (recommended max bid / ROI / valuations / opportunity
+ * scores) is visible for this request. Visibility is a function of the resolved
+ * subscription tier ONLY — every client (iOS app, browser, curl) sees identical
+ * data for a given tier. The FREE tier includes the full analysis, so a fresh
+ * install with no account and no purchase gets the same JSON a signed-in user or
+ * an anonymous browser gets. Nothing keys off the client, a header, or any other
+ * undisclosed signal.
+ */
+export async function moneyMathVisible(
+  tier: import("@prisma/client").Tier
+): Promise<boolean> {
+  return tierDef(tier).limits.showMoneyMath;
+}
diff --git a/govarbitrage/src/lib/dashboard.ts b/govarbitrage/src/lib/dashboard.ts
new file mode 100644
index 0000000..1e90cab
--- /dev/null
+++ b/govarbitrage/src/lib/dashboard.ts
@@ -0,0 +1,41 @@
+import { prisma } from "@/lib/db";
+
+export interface DashboardStats {
+  activeAuctions: number;
+  closingToday: number;
+  highestProfit: number;
+  highestRoi: number;
+  cashRequired: number; // sum of recommended max bids across active opportunities
+  expectedProfit: number; // sum of expected net profit
+  highestScore: number;
+  pendingResearch: number;
+}
+
+/** Aggregate the summary-card metrics in a single pass. */
+export async function getDashboardStats(): Promise<DashboardStats> {
+  const now = new Date();
+  const endOfDay = new Date(now);
+  endOfDay.setHours(23, 59, 59, 999);
+
+  const [activeAuctions, closingToday, pendingResearch, costAgg, topScore] = await Promise.all([
+    prisma.listing.count({ where: { listingStatus: "ACTIVE", closingAt: { gte: now } } }),
+    prisma.listing.count({ where: { listingStatus: "ACTIVE", closingAt: { gte: now, lte: endOfDay } } }),
+    prisma.listing.count({ where: { researchStatus: { in: ["PENDING", "QUEUED", "IN_PROGRESS"] } } }),
+    prisma.costBreakdown.aggregate({
+      _sum: { expectedNetProfit: true, recommendedMaxBid: true },
+      _max: { expectedNetProfit: true, roi: true },
+    }),
+    prisma.score.aggregate({ where: { profile: "OVERALL_OPPORTUNITY" }, _max: { value: true } }),
+  ]);
+
+  return {
+    activeAuctions,
+    closingToday,
+    highestProfit: Number(costAgg._max.expectedNetProfit ?? 0),
+    highestRoi: Number(costAgg._max.roi ?? 0),
+    cashRequired: Number(costAgg._sum.recommendedMaxBid ?? 0),
+    expectedProfit: Number(costAgg._sum.expectedNetProfit ?? 0),
+    highestScore: Number(topScore._max.value ?? 0),
+    pendingResearch,
+  };
+}
diff --git a/govarbitrage/src/lib/db.ts b/govarbitrage/src/lib/db.ts
new file mode 100644
index 0000000..84941e8
--- /dev/null
+++ b/govarbitrage/src/lib/db.ts
@@ -0,0 +1,13 @@
+import { PrismaClient } from "@prisma/client";
+
+// Reuse a single PrismaClient across hot-reloads in dev to avoid exhausting
+// Postgres connections (Next.js re-evaluates modules on every change).
+const globalForPrisma = globalThis as unknown as { prisma?: PrismaClient };
+
+export const prisma =
+  globalForPrisma.prisma ??
+  new PrismaClient({
+    log: process.env.NODE_ENV === "development" ? ["warn", "error"] : ["error"],
+  });
+
+if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
diff --git a/govarbitrage/src/lib/digest-snapshot.ts b/govarbitrage/src/lib/digest-snapshot.ts
new file mode 100644
index 0000000..1588270
--- /dev/null
+++ b/govarbitrage/src/lib/digest-snapshot.ts
@@ -0,0 +1,134 @@
+import { prisma } from "@/lib/db";
+import { findHotDeals, type HotDeal, type Confidence } from "@/lib/hot-deals";
+import type { DigestSlot, Prisma } from "@prisma/client";
+
+// Freezes each digest run into a DigestSnapshot so the public /deals archive
+// renders a stable, indexable record instead of re-querying live listings
+// that expire. Only PUBLIC-SAFE display fields are serialized — exactly what
+// the free-tier digest email already shows, never internal cost math.
+
+export interface SnapshotDeal {
+  rank: number;
+  title: string;
+  source: string;
+  locationCity: string | null;
+  locationState: string | null;
+  currentBid: number;
+  recMax: number;
+  expectedSaleLow: number;
+  confidence: Confidence;
+  roiConservative: number;
+  roiCapped: boolean;
+  disclaimer: string;
+  closingAt: string | null;
+  sourceUrl: string | null;
+}
+
+export function digestDateString(d = new Date()): string {
+  // en-CA renders YYYY-MM-DD; PT is the digest's home timezone (6am/5pm sends).
+  return d.toLocaleDateString("en-CA", { timeZone: "America/Los_Angeles" });
+}
+
+export function currentSlot(d = new Date()): DigestSlot {
+  const hour = Number(
+    d.toLocaleString("en-US", { timeZone: "America/Los_Angeles", hour: "numeric", hour12: false }),
+  );
+  return hour < 12 ? "AM" : "PM";
+}
+
+export function toSnapshotDeals(deals: HotDeal[]): SnapshotDeal[] {
+  return deals.map((d, i) => ({
+    rank: i + 1,
+    title: d.listing.title,
+    source: d.listing.source,
+    locationCity: d.listing.locationCity,
+    locationState: d.listing.locationState,
+    currentBid: d.currentBid,
+    recMax: d.recMax,
+    expectedSaleLow: d.expectedSaleLow,
+    confidence: d.confidence,
+    roiConservative: d.roiConservative,
+    roiCapped: d.roiCapped,
+    disclaimer: d.disclaimer,
+    closingAt: d.closingAt ? new Date(d.closingAt).toISOString() : null,
+    sourceUrl: d.listing.sourceUrl,
+  }));
+}
+
+/** Persist a snapshot from already-fetched deals (used at digest send time). */
+export async function persistDigestSnapshot(slot: DigestSlot, deals: HotDeal[], date = digestDateString()) {
+  const snapshotDeals = toSnapshotDeals(deals);
+  const dealsJson = snapshotDeals as unknown as Prisma.InputJsonValue;
+  return prisma.digestSnapshot.upsert({
+    where: { date_slot: { date, slot } },
+    create: { date, slot, dealsJson, dealCount: snapshotDeals.length },
+    update: { dealsJson, dealCount: snapshotDeals.length },
+  });
+}
+
+/** Standalone capture: run the honesty-gated Top-10 and freeze it. */
+export async function captureDigestSnapshot(slot: DigestSlot = currentSlot()) {
+  const deals = await findHotDeals({ limit: 10 });
+  return persistDigestSnapshot(slot, deals);
+}
+
+// ── read side for the /deals archive pages ─────────────────────────────────
+
+export interface DigestEdition {
+  date: string;
+  slot: DigestSlot;
+  dealCount: number;
+  createdAt: Date;
+  deals: SnapshotDeal[];
+}
+
+function parseDeals(dealsJson: Prisma.JsonValue): SnapshotDeal[] {
+  return Array.isArray(dealsJson) ? (dealsJson as unknown as SnapshotDeal[]) : [];
+}
+
+function toEdition(r: { date: string; slot: DigestSlot; dealCount: number; createdAt: Date; dealsJson: Prisma.JsonValue }): DigestEdition {
+  return {
+    date: r.date,
+    slot: r.slot,
+    dealCount: r.dealCount,
+    createdAt: r.createdAt,
+    deals: parseDeals(r.dealsJson),
+  };
+}
+
+// Fabricated seed editions never reach the public archive. Schema-level flag,
+// not a delete-before-launch convention — opt in locally with SHOW_SEED_DIGESTS=1.
+function seedFilter(): { isSeed?: false } {
+  return process.env.SHOW_SEED_DIGESTS === "1" ? {} : { isSeed: false };
+}
+
+export async function listDigestEditions(limit = 60): Promise<DigestEdition[]> {
+  const rows = await prisma.digestSnapshot.findMany({
+    where: seedFilter(),
+    orderBy: [{ date: "desc" }, { slot: "asc" }],
+    take: limit,
+  });
+  return rows.map(toEdition);
+}
+
+export async function getDigestEditionsForDate(date: string): Promise<DigestEdition[]> {
+  const rows = await prisma.digestSnapshot.findMany({
+    where: { date, ...seedFilter() },
+    orderBy: { slot: "asc" },
+  });
+  return rows.map(toEdition);
+}
+
+export async function listDigestDates(): Promise<{ date: string; lastModified: Date }[]> {
+  const rows = await prisma.digestSnapshot.findMany({
+    where: seedFilter(),
+    select: { date: true, createdAt: true },
+    orderBy: { date: "desc" },
+  });
+  const byDate = new Map<string, Date>();
+  for (const r of rows) {
+    const prev = byDate.get(r.date);
+    if (!prev || r.createdAt > prev) byDate.set(r.date, r.createdAt);
+  }
+  return [...byDate.entries()].map(([date, lastModified]) => ({ date, lastModified }));
+}
diff --git a/govarbitrage/src/lib/digest-top.ts b/govarbitrage/src/lib/digest-top.ts
new file mode 100644
index 0000000..072b665
--- /dev/null
+++ b/govarbitrage/src/lib/digest-top.ts
@@ -0,0 +1,32 @@
+import { queryListings, type ListingRow } from "@/lib/listings";
+
+// Shared "top ranked opportunities" selection — the single definition of how
+// the Top-10 digest (scripts/send-digest.ts) and the skeptic agent pick the
+// ranked head of the corpus, so the adversarial gate audits exactly what the
+// digest is about to email.
+
+export interface TopRankedOptions {
+  limit: number;
+  /**
+   * When true (digest behavior) only still-open auctions (closingAt in the
+   * future) are returned. The skeptic passes false so it can also catch
+   * stale rows — closed auctions still ranked as if live.
+   */
+  openOnly?: boolean;
+}
+
+/** ACTIVE listings ranked by Overall Opportunity, best first. */
+export async function topRankedOpportunities(opts: TopRankedOptions): Promise<ListingRow[]> {
+  const { limit, openOnly = true } = opts;
+  const { rows } = await queryListings({
+    profile: "OVERALL_OPPORTUNITY",
+    sort: "opportunityScore",
+    dir: "desc",
+    pageSize: Math.max(200, limit),
+  });
+  const now = Date.now();
+  const pool = openOnly
+    ? rows.filter((r) => r.closingAt && new Date(r.closingAt).getTime() > now)
+    : rows;
+  return pool.slice(0, limit);
+}
diff --git a/govarbitrage/src/lib/fleet-sso.ts b/govarbitrage/src/lib/fleet-sso.ts
new file mode 100644
index 0000000..427a034
--- /dev/null
+++ b/govarbitrage/src/lib/fleet-sso.ts
@@ -0,0 +1,44 @@
+// Fleet single-sign-on cookie ("aafleet") — a stateless HMAC token shared
+// across every *.agentabrams.com site, issued by the fleet-sso service
+// (/var/www/fleet-sso/server.mjs). Format: "v1.<exp>.<sig>" where
+//   sig = HMAC-SHA256(FLEET_SSO_SECRET, "v1." + exp)  (hex)
+// and exp is a unix-seconds expiry. Because the cookie's Domain is
+// ".agentabrams.com", auctions already receives it — validating it here lets a
+// single fleet login carry into this app with no second sign-in.
+//
+// This mirrors the verifier's valid() exactly and is Edge-safe (Web Crypto,
+// no Node `crypto`), so it runs inside the Next.js middleware.
+
+export const FLEET_SSO_COOKIE = "aafleet";
+
+const TOKEN_RE = /^v1\.(\d+)\.([0-9a-f]{64})$/;
+
+function toHex(buf: ArrayBuffer): string {
+  return Array.from(new Uint8Array(buf), (b) => b.toString(16).padStart(2, "0")).join("");
+}
+
+// Constant-time-ish compare of two equal-length hex strings.
+function safeEqualHex(a: string, b: string): boolean {
+  if (a.length !== b.length) return false;
+  let diff = 0;
+  for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
+  return diff === 0;
+}
+
+export async function fleetSsoOk(token: string | undefined | null): Promise<boolean> {
+  const secret = process.env.FLEET_SSO_SECRET;
+  if (!secret || !token) return false;
+  const m = TOKEN_RE.exec(token);
+  if (!m) return false;
+  const exp = parseInt(m[1], 10);
+  if (!(exp > Math.floor(Date.now() / 1000))) return false; // expired
+  const key = await crypto.subtle.importKey(
+    "raw",
+    new TextEncoder().encode(secret),
+    { name: "HMAC", hash: "SHA-256" },
+    false,
+    ["sign"],
+  );
+  const mac = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode("v1." + m[1]));
+  return safeEqualHex(toHex(mac), m[2]);
+}
diff --git a/govarbitrage/src/lib/hot-deals.ts b/govarbitrage/src/lib/hot-deals.ts
new file mode 100644
index 0000000..287ecc1
--- /dev/null
+++ b/govarbitrage/src/lib/hot-deals.ts
@@ -0,0 +1,191 @@
+import { prisma } from "@/lib/db";
+
+// A HOT DEAL is not merely a cheap lot — it is a lot where BOTH sides check out:
+//   BUY side  — there is still headroom to bid profitably (recommendedMaxBid is
+//               meaningfully above the current bid), the net profit and ROI clear
+//               the bar, and the auction is still open (or a make-offer item).
+//   SELL side — "real opportunity to sell before you buy": genuine resale demand
+//               (demand score + probability of sale) and a valued expected sale.
+// This is the gate behind the 🔥 alert. Thresholds are env-tunable so Steve can
+// dial the signal without a code change.
+
+export interface HotDealCriteria {
+  minScore: number; // OVERALL_OPPORTUNITY value 0..100
+  minArbitrage: number; // arbitrage sub-score
+  minDemand: number; // demand sub-score (sell-side proof)
+  minProbSale: number; // Research.probabilityOfSale 0..1
+  minNetProfit: number; // $ expected net profit
+  minRoi: number; // ratio, 0.5 = 50%
+  minHeadroomPct: number; // (recMaxBid - currentBid)/recMaxBid, room to still bid & profit
+}
+
+function envNum(key: string, dflt: number): number {
+  const v = process.env[key];
+  const n = v == null ? NaN : Number(v);
+  return Number.isFinite(n) ? n : dflt;
+}
+
+export function defaultCriteria(): HotDealCriteria {
+  // Tuned 2026-07-10 against the live corpus: the Overall score is bimodal —
+  // a loose 75-81 cluster (~9% of all listings, inflated by the heuristic,
+  // no-AI valuation engine) and a small genuine elite at 82+. Anchoring at
+  // score≥82 surfaces only true standouts instead of flooding. Every threshold
+  // is env-overridable so Steve can loosen/tighten without a code change.
+  // NOTE: valuations are heuristic (optimistic) until real marketplace-comp
+  // calibration lands — treat ROI as directional, verify comps before bidding.
+  return {
+    minScore: envNum("HOT_MIN_SCORE", 82),
+    minArbitrage: envNum("HOT_MIN_ARBITRAGE", 72),
+    minDemand: envNum("HOT_MIN_DEMAND", 55),
+    minProbSale: envNum("HOT_MIN_PROB_SALE", 0.55),
+    minNetProfit: envNum("HOT_MIN_NET", 300),
+    minRoi: envNum("HOT_MIN_ROI", 0.75),
+    minHeadroomPct: envNum("HOT_MIN_HEADROOM_PCT", 0.2),
+  };
+}
+
+export type HotDeal = Awaited<ReturnType<typeof findHotDeals>>[number];
+
+const num = (d: unknown): number => (d == null ? 0 : Number(d));
+
+// ── HONESTY GATE ──────────────────────────────────────────────────────────
+// The valuation engine is heuristic and runs OPTIMISTIC (a "727% ROI" is not a
+// promise we can keep). Charging for alerts built on inflated ROI is a
+// chargeback/trust trap. So every alert is discounted by our CONFIDENCE in it
+// and always carries the evidence basis + a verify-comps disclaimer. We sell
+// "curated + de-duped + early", never a guaranteed number. Under-promise.
+export type Confidence = "LOW" | "MEDIUM" | "HIGH";
+
+// Confidence from REAL evidence: how many comparable sales back the estimate,
+// the modeled probability of sale, and whether we have used-market comps at all.
+export function assessConfidence(
+  comparableCount: number,
+  probSale: number,
+  hasUsedComps: boolean,
+): Confidence {
+  if (comparableCount >= 5 && probSale >= 0.7) return "HIGH";
+  if (comparableCount >= 2 && probSale >= 0.55 && hasUsedComps) return "MEDIUM";
+  return "LOW";
+}
+
+// Haircut applied to the optimistic sale/ROI by confidence — the number we
+// actually SHOW is the conservative low band, so reality tends to beat it.
+export const CONFIDENCE_HAIRCUT: Record<Confidence, number> = {
+  LOW: 0.5,
+  MEDIUM: 0.7,
+  HIGH: 0.9,
+};
+
+// Displayed ROI is capped so a heuristic outlier can never render as hype.
+export const ROI_DISPLAY_CAP = 2.0; // 200%
+
+/**
+ * Find ACTIVE listings that pass the HOT DEAL gate. If `onlyUnalerted`, skip
+ * ones already alerted (hotAlertedAt set) — the alerter uses this to notify
+ * each deal exactly once.
+ */
+export async function findHotDeals(opts: {
+  criteria?: HotDealCriteria;
+  onlyUnalerted?: boolean;
+  limit?: number;
+} = {}) {
+  const c = opts.criteria ?? defaultCriteria();
+  const now = new Date();
+
+  // Pull ACTIVE listings with a strong overall score, then apply the full gate
+  // in JS (mixes Score + CostBreakdown + Research fields across relations).
+  const listings = await prisma.listing.findMany({
+    where: {
+      listingStatus: "ACTIVE",
+      ...(opts.onlyUnalerted ? { hotAlertedAt: null } : {}),
+      scores: { some: { profile: "OVERALL_OPPORTUNITY", value: { gte: c.minScore } } },
+    },
+    include: {
+      research: true,
+      costBreakdown: true,
+      scores: true,
+      buyerLeads: true,
+      buyerPage: true,
+      comparables: true,
+    },
+  });
+
+  const deals = [];
+  for (const l of listings) {
+    const s = l.scores.find((x) => x.profile === "OVERALL_OPPORTUNITY");
+    const cb = l.costBreakdown;
+    const r = l.research;
+    if (!s || !cb) continue;
+
+    const currentBid = num(l.currentBid);
+    const recMax = num(cb.recommendedMaxBid);
+    const netProfit = num(cb.expectedNetProfit);
+    const roi = num(cb.roi);
+    const probSale = num(r?.probabilityOfSale);
+
+    // Auction must still be live: either open (closingAt in the future) or a
+    // make-offer/no-deadline item (null closingAt).
+    const open = !l.closingAt || new Date(l.closingAt).getTime() > now.getTime();
+    if (!open) continue;
+
+    // BUY-side headroom: recommended max bid must sit above the current bid by
+    // at least minHeadroomPct — i.e. you can still bid and profit.
+    const headroom = recMax > 0 ? (recMax - currentBid) / recMax : 0;
+
+    const pass =
+      s.value >= c.minScore &&
+      s.arbitrage >= c.minArbitrage &&
+      s.demand >= c.minDemand &&
+      probSale >= c.minProbSale &&
+      netProfit >= c.minNetProfit &&
+      roi >= c.minRoi &&
+      recMax > currentBid &&
+      headroom >= c.minHeadroomPct;
+
+    if (!pass) continue;
+
+    // ── Honesty Gate: discount the optimistic estimate by our confidence ──
+    const comparableCount = l.comparables.length;
+    const hasUsedComps = r?.usedLow != null || r?.usedSoldPrice != null || r?.usedHigh != null;
+    const confidence = assessConfidence(comparableCount, probSale, hasUsedComps);
+    const haircut = CONFIDENCE_HAIRCUT[confidence];
+    const expectedSale = num(r?.expectedSalePrice);
+    const expectedSaleLow = Math.round(expectedSale * haircut);
+    // Conservative ROI we actually SHOW: haircut by confidence, then capped.
+    const roiConservative = Math.min(roi * haircut, ROI_DISPLAY_CAP);
+    const roiCapped = roi > ROI_DISPLAY_CAP; // flag when the raw number was hype
+    const disclaimer = `Heuristic estimate${comparableCount ? ` from ${comparableCount} comp${comparableCount === 1 ? "" : "s"}` : " (thin comp data)"} — verify before bidding.`;
+
+    deals.push({
+      listing: l,
+      score: s,
+      cost: cb,
+      research: r,
+      currentBid,
+      recMax,
+      headroom,
+      netProfit,
+      roi,
+      probSale,
+      expectedSale,
+      // Honesty Gate outputs (what the paid alert should DISPLAY):
+      confidence,
+      expectedSaleLow,
+      roiConservative,
+      roiCapped,
+      disclaimer,
+      demandScore: s.demand,
+      comparableCount,
+      buyerLeadCount: l.buyerLeads.length,
+      buyerLeadTop: l.buyerLeads
+        .map((b) => num(b.offer))
+        .filter((x) => x > 0)
+        .sort((a, b) => b - a)[0] ?? 0,
+      closingAt: l.closingAt,
+    });
+  }
+
+  // Best first: highest score, then net profit.
+  deals.sort((a, b) => b.score.value - a.score.value || b.netProfit - a.netProfit);
+  return typeof opts.limit === "number" ? deals.slice(0, opts.limit) : deals;
+}
diff --git a/govarbitrage/src/lib/listing-detail.test.ts b/govarbitrage/src/lib/listing-detail.test.ts
new file mode 100644
index 0000000..e874ee6
--- /dev/null
+++ b/govarbitrage/src/lib/listing-detail.test.ts
@@ -0,0 +1,85 @@
+import { describe, expect, it } from "vitest";
+import { Prisma } from "@prisma/client";
+import { decimalsToNumbers } from "./listing-detail";
+
+// Regression test for TK-10279: the listing DETAIL endpoint (unlike the LIST
+// endpoint's flattenListing(), which already ran every Decimal through
+// num()/numN()) used to return raw Prisma Decimal instances straight to
+// NextResponse.json(). JSON.stringify() serializes a Decimal via its own
+// toJSON() -> a STRING ("103.93"), which the mobile client's Number.isFinite()
+// render guards (fmtUSD/fmtPct/fmtScore) correctly treat as non-finite and
+// render "—" -- so a fully-researched listing showed an all-null Valuation
+// table + null Recommended Max Bid on the detail screen while the LIST screen
+// (same listing, same underlying data) showed real numbers. This test locks
+// in the fix: decimalsToNumbers() must turn every Decimal in the object graph
+// into a real `number` so it survives a JSON round-trip as a JSON number, not
+// a string.
+describe("decimalsToNumbers (TK-10279 detail-screen null-valuation regression)", () => {
+  it("converts a top-level Decimal to a real number", () => {
+    const out = decimalsToNumbers(new Prisma.Decimal("103.93"));
+    expect(typeof out).toBe("number");
+    expect(out).toBe(103.93);
+  });
+
+  it("converts nested Decimals inside relation objects (costBreakdown / research)", () => {
+    const listing = {
+      id: "l1",
+      currentBid: new Prisma.Decimal("10"),
+      research: {
+        newRetail: new Prisma.Decimal("900"),
+        avgRetail: new Prisma.Decimal("828"),
+        usedLow: null,
+      },
+      costBreakdown: {
+        recommendedMaxBid: new Prisma.Decimal("103.93"),
+        expectedNetProfit: new Prisma.Decimal("182.96"),
+        roi: 1.91, // Float column — already a plain number, must pass through unchanged
+      },
+      scores: [{ value: 82.07, risk: "MEDIUM" }],
+    };
+
+    const fixed = decimalsToNumbers(listing);
+
+    expect(typeof fixed.currentBid).toBe("number");
+    expect(fixed.currentBid).toBe(10);
+    expect(typeof fixed.research!.newRetail).toBe("number");
+    expect(fixed.research!.newRetail).toBe(900);
+    expect(fixed.research!.usedLow).toBeNull();
+    expect(typeof fixed.costBreakdown!.recommendedMaxBid).toBe("number");
+    expect(fixed.costBreakdown!.recommendedMaxBid).toBe(103.93);
+    expect(fixed.costBreakdown!.roi).toBe(1.91);
+    expect(fixed.scores[0].value).toBe(82.07);
+  });
+
+  it("survives a JSON.stringify round-trip as a JSON number, not a string (the actual client-visible bug)", () => {
+    const raw = { costBreakdown: { recommendedMaxBid: new Prisma.Decimal("4736.72") } };
+
+    // BEFORE the fix: JSON.stringify(raw) directly would produce
+    // '{"costBreakdown":{"recommendedMaxBid":"4736.72"}}' — a STRING.
+    const buggyRoundTrip = JSON.parse(JSON.stringify(raw));
+    expect(typeof buggyRoundTrip.costBreakdown.recommendedMaxBid).toBe("string");
+    expect(Number.isFinite(buggyRoundTrip.costBreakdown.recommendedMaxBid)).toBe(false);
+
+    // AFTER the fix: decimalsToNumbers() first, then serialize.
+    const fixedRoundTrip = JSON.parse(JSON.stringify(decimalsToNumbers(raw)));
+    expect(typeof fixedRoundTrip.costBreakdown.recommendedMaxBid).toBe("number");
+    expect(Number.isFinite(fixedRoundTrip.costBreakdown.recommendedMaxBid)).toBe(true);
+    expect(fixedRoundTrip.costBreakdown.recommendedMaxBid).toBe(4736.72);
+  });
+
+  it("leaves non-Decimal values (strings, Dates, null, arrays) untouched", () => {
+    const now = new Date("2026-09-03T00:00:00Z");
+    const out = decimalsToNumbers({
+      title: "Late 2013 Apple iMac 21.5\"",
+      sourceAuctionId: "29250-189",
+      closingAt: now,
+      imageUrls: ["https://example.com/a.jpg"],
+      description: null,
+    });
+    expect(out.title).toBe("Late 2013 Apple iMac 21.5\"");
+    expect(out.sourceAuctionId).toBe("29250-189");
+    expect(out.closingAt).toBe(now);
+    expect(out.imageUrls).toEqual(["https://example.com/a.jpg"]);
+    expect(out.description).toBeNull();
+  });
+});
diff --git a/govarbitrage/src/lib/listing-detail.ts b/govarbitrage/src/lib/listing-detail.ts
new file mode 100644
index 0000000..9b31dfc
--- /dev/null
+++ b/govarbitrage/src/lib/listing-detail.ts
@@ -0,0 +1,81 @@
+import { prisma } from "@/lib/db";
+import { getCurrentTier, moneyMathVisible } from "@/lib/current-user";
+import { Prisma, type Tier } from "@prisma/client";
+
+// Single source of truth for the listing DETAIL payload and its tier gate.
+// Visibility depends on the resolved tier only, never on the client type. All
+// current tiers (FREE included) show the full analysis, so the redaction below
+// is dormant — retained so a future gated tier can null the money-math
+// relations (costBreakdown, research valuations, scores, comparables, buyer
+// leads) consistently with the list endpoint.
+
+export type ListingDetail = NonNullable<Awaited<ReturnType<typeof queryListingDetail>>>;
+
+function queryListingDetail(id: string) {
+  return prisma.listing.findUnique({
+    where: { id },
+    include: {
+      research: true,
+      costBreakdown: true,
+      scores: { orderBy: { value: "desc" } },
+      comparables: { orderBy: { price: "desc" } },
+      notes: { orderBy: { createdAt: "desc" } },
+      events: { orderBy: { createdAt: "desc" }, take: 20 },
+      buyerLeads: { orderBy: { createdAt: "desc" } },
+      buyerPage: true,
+      outcome: true,
+    },
+  });
+}
+
+// Root-cause fix (TK-10279, 2026-09-03): every money-math field on Listing /
+// Research / CostBreakdown is a Prisma Decimal. JSON.stringify() (what
+// NextResponse.json() uses) serializes a Decimal via its own toJSON(), which
+// returns a STRING (e.g. "103.93"), not a JSON number. The LIST endpoint
+// (src/lib/listings.ts flattenListing()) already runs every Decimal through
+// num()/numN() so it emits real numbers — but this DETAIL path returned the
+// raw Prisma object untouched, so its JSON carried numeric-looking strings.
+// The mobile client's Number.isFinite() render guards (fmtUSD/fmtPct/fmtScore)
+// correctly reject a string as non-finite and render "—" — so a fully
+// populated listing (proven identical to the list's $183 net profit / 191%
+// ROI numbers) showed an all-null Valuation table + a null Recommended Max
+// Bid + "Current bid: —" on the detail screen only. Recursively converting
+// every Decimal to a plain number before the route serializes it makes the
+// detail payload's JSON shape match the list's (and match the mobile client's
+// own `ListingDetail`/`CostBreakdown`/`Research` types, which already declare
+// these fields as `number | null`).
+export function decimalsToNumbers<T>(value: T): T {
+  if (value instanceof Prisma.Decimal) return value.toNumber() as unknown as T;
+  if (Array.isArray(value)) return value.map((v) => decimalsToNumbers(v)) as unknown as T;
+  if (value instanceof Date) return value;
+  if (value && typeof value === "object") {
+    const out: Record<string, unknown> = {};
+    for (const [k, v] of Object.entries(value as Record<string, unknown>)) {
+      out[k] = decimalsToNumbers(v);
+    }
+    return out as T;
+  }
+  return value;
+}
+
+export async function getGatedListingDetail(
+  id: string
+): Promise<{ listing: ListingDetail | null; tier: Tier; gated: boolean }> {
+  const tier = await getCurrentTier();
+  const gated = !(await moneyMathVisible(tier));
+  const raw = await queryListingDetail(id);
+  const listing = raw ? decimalsToNumbers(raw) : raw;
+  if (!listing || !gated) return { listing, tier, gated };
+
+  const redacted: ListingDetail = {
+    ...listing,
+    research: null,
+    costBreakdown: null,
+    scores: [],
+    comparables: [],
+    buyerLeads: [],
+    buyerPage: null,
+    outcome: null,
+  };
+  return { listing: redacted, tier, gated };
+}
diff --git a/govarbitrage/src/lib/listings-sort.test.ts b/govarbitrage/src/lib/listings-sort.test.ts
new file mode 100644
index 0000000..38b4553
--- /dev/null
+++ b/govarbitrage/src/lib/listings-sort.test.ts
@@ -0,0 +1,57 @@
+import { describe, it, expect } from "vitest";
+import { Prisma } from "@prisma/client";
+
+// Regression guard for TK-11464: sorting the listings grid by a non-nullable
+// native column (title, source, condition, …) returned HTTP 500 because the
+// DB-side orderBy applied Prisma's { sort, nulls } object form to EVERY native
+// column. Prisma only accepts that object form on OPTIONAL (nullable) fields;
+// a required field must use the bare SortOrder string, else it throws
+// PrismaClientValidationError ("Expected SortOrder, provided Object.").
+//
+// listings.ts gates the object form behind NULLABLE_NATIVE_SORT_COLUMNS. This
+// test keeps that gate honest against the schema with no DB needed: a native
+// column is classified nullable iff the Prisma field is optional. It fails if
+// a field's nullability changes without updating the set, or a new native sort
+// column is added unclassified.
+
+// Mirror of the sets in src/lib/listings.ts (this test is their sync-check).
+const NATIVE_SORT_COLUMNS = [
+  "currentBid",
+  "closingAt",
+  "title",
+  "source",
+  "sourceAuctionId",
+  "category",
+  "manufacturer",
+  "model",
+  "condition",
+  "quantity",
+  "researchStatus",
+] as const;
+
+const NULLABLE_NATIVE_SORT_COLUMNS = new Set<string>([
+  "closingAt",
+  "category",
+  "manufacturer",
+  "model",
+]);
+
+describe("listings native sort classification (TK-11464)", () => {
+  const listing = Prisma.dmmf.datamodel.models.find((m) => m.name === "Listing");
+
+  it("resolves the Listing model from the Prisma DMMF", () => {
+    expect(listing).toBeDefined();
+  });
+
+  it("classifies a native sort column as nullable iff the schema field is optional", () => {
+    for (const name of NATIVE_SORT_COLUMNS) {
+      const field = listing!.fields.find((f) => f.name === name);
+      expect(field, `${name} is not a scalar field on Listing`).toBeDefined();
+      // Prisma: optional field => isRequired === false => may carry { sort, nulls }.
+      expect(
+        NULLABLE_NATIVE_SORT_COLUMNS.has(name),
+        `${name}: orderBy nullability classification must match schema (schema optional=${!field!.isRequired})`,
+      ).toBe(!field!.isRequired);
+    }
+  });
+});

← b6aa92b security: strip hardcoded secret -> env-first/passwordless.  ·  back to Japan Enrich  ·  security: strip hardcoded secret -> env-first/passwordless. 4f461fb →