← 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
A govarbitrage/src/app/api/import/csv/route.tsA govarbitrage/src/app/api/import/extension/route.tsA govarbitrage/src/app/api/import/gsa/route.tsA govarbitrage/src/app/api/import/url/route.tsA govarbitrage/src/app/api/listings/[id]/buyer-lead/route.tsA govarbitrage/src/app/api/listings/[id]/buyer-page/route.tsA govarbitrage/src/app/api/listings/[id]/route.tsA govarbitrage/src/app/api/listings/route.tsA govarbitrage/src/app/api/newsletter/confirm/route.tsA govarbitrage/src/app/api/newsletter/result-page.tsA govarbitrage/src/app/api/newsletter/subscribe/route.tsA govarbitrage/src/app/api/newsletter/unsubscribe/route.tsA govarbitrage/src/app/robots.tsA govarbitrage/src/app/sitemap.tsA govarbitrage/src/engines/constants.tsA govarbitrage/src/engines/costs.test.tsA govarbitrage/src/engines/costs.tsA govarbitrage/src/engines/demand.tsA govarbitrage/src/engines/freight.tsA govarbitrage/src/engines/scoring.test.tsA govarbitrage/src/engines/scoring.tsA govarbitrage/src/engines/types.tsA govarbitrage/src/engines/valuation.test.tsA govarbitrage/src/engines/valuation.tsA govarbitrage/src/importers/apify-govdeals.tsA govarbitrage/src/importers/csv.tsA govarbitrage/src/importers/govdeals-free.tsA govarbitrage/src/importers/govplanet-free.tsA govarbitrage/src/importers/grays-free.tsA govarbitrage/src/importers/gsa.tsA govarbitrage/src/importers/ingest.tsA govarbitrage/src/importers/municibid-free.tsA govarbitrage/src/importers/publicsurplus-free.tsA govarbitrage/src/importers/scrape.tsA govarbitrage/src/lib/ai.tsA govarbitrage/src/lib/auth.test.tsA govarbitrage/src/lib/auth.tsA govarbitrage/src/lib/billing.tsA govarbitrage/src/lib/crypto.test.tsA govarbitrage/src/lib/crypto.tsA govarbitrage/src/lib/current-user.tsA govarbitrage/src/lib/dashboard.tsA govarbitrage/src/lib/db.tsA govarbitrage/src/lib/digest-snapshot.tsA govarbitrage/src/lib/digest-top.tsA govarbitrage/src/lib/fleet-sso.tsA govarbitrage/src/lib/hot-deals.tsA govarbitrage/src/lib/listing-detail.test.tsA govarbitrage/src/lib/listing-detail.tsA govarbitrage/src/lib/listings-sort.test.ts
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(/ /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(/&/g, "&")
+ .replace(/'/g, "'")
+ .replace(/"/g, '"')
+ .replace(/ /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 " " (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(/&/g, "&")
+ .replace(/'/g, "'")
+ .replace(/'/g, "'")
+ .replace(/"/g, '"')
+ .replace(/ /g, " ")
+ .replace(/</g, "<")
+ .replace(/>/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(/&/g, "&")
+ .replace(/'/g, "'")
+ .replace(/'/g, "'")
+ .replace(/"/g, '"')
+ .replace(/ /g, " ")
+ .replace(/</g, "<")
+ .replace(/>/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 →