[object Object]

← back to Japan Enrich

security: strip hardcoded secret -> env-first/passwordless. No rotation/deploy.

b6aa92be2ecf8443bb84a81ad688cbc6d0695088 · 2026-09-13 00:00:44 -0700 · Steve

Files touched

Diff

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

    security: strip hardcoded secret -> env-first/passwordless. No rotation/deploy.
---
 govarbitrage/apps/mobile/index.ts                  |   1 +
 govarbitrage/apps/mobile/lib/api-error.ts          |  14 +
 govarbitrage/apps/mobile/lib/api.ts                | 145 ++++
 govarbitrage/apps/mobile/lib/auth-headers.ts       |  26 +
 govarbitrage/apps/mobile/lib/auth.ts               |  83 +++
 govarbitrage/apps/mobile/lib/format.ts             | 125 ++++
 govarbitrage/apps/mobile/lib/pnl.ts                |  13 +
 govarbitrage/apps/mobile/lib/settings.ts           |  97 +++
 govarbitrage/apps/mobile/lib/types.ts              | 240 ++++++
 govarbitrage/apps/mobile/lib/validate.ts           |  95 +++
 .../apps/mobile/scripts/lint-financial-format.mjs  | 128 ++++
 .../apps/mobile/tests/auth-headers.test.mjs        |  49 ++
 govarbitrage/apps/mobile/tests/format.test.mjs     |  99 +++
 govarbitrage/eslint.config.mjs                     |  11 +
 govarbitrage/extension/background.js               |  28 +
 govarbitrage/extension/content.js                  | 422 +++++++++++
 govarbitrage/extension/popup.js                    | 198 +++++
 govarbitrage/next.config.ts                        |  18 +
 govarbitrage/playwright.config.ts                  |  20 +
 govarbitrage/postcss.config.mjs                    |   8 +
 govarbitrage/prisma/seed.ts                        | 820 +++++++++++++++++++++
 govarbitrage/scripts/hot-deal-alert.ts             | 160 ++++
 govarbitrage/scripts/import-apify-govdeals.ts      |  30 +
 govarbitrage/scripts/import-govdeals-free.ts       |  37 +
 govarbitrage/scripts/import-govplanet-free.ts      |  29 +
 govarbitrage/scripts/import-grays.ts               |  25 +
 govarbitrage/scripts/import-gsa.ts                 |  31 +
 govarbitrage/scripts/import-municibid-free.ts      |  29 +
 govarbitrage/scripts/import-publicsurplus-free.ts  |  29 +
 govarbitrage/scripts/liveness-sweep.ts             | 141 ++++
 govarbitrage/scripts/probe-govdeals.ts             |  61 ++
 govarbitrage/scripts/seed-digest-snapshots.ts      | 117 +++
 govarbitrage/scripts/send-digest.ts                | 256 +++++++
 govarbitrage/scripts/send-newsletter-digest.ts     |  12 +
 govarbitrage/scripts/set-admin.ts                  |  42 ++
 govarbitrage/src/agents/appraiser.ts               |  85 +++
 govarbitrage/src/agents/framework.ts               |  91 +++
 govarbitrage/src/agents/run.ts                     |  80 ++
 govarbitrage/src/agents/scout.ts                   |  49 ++
 govarbitrage/src/agents/skeptic-rules.test.ts      |  82 +++
 govarbitrage/src/agents/skeptic-rules.ts           |  69 ++
 govarbitrage/src/agents/skeptic.ts                 |  93 +++
 govarbitrage/src/app/api/agents/search/route.ts    |  68 ++
 govarbitrage/src/app/api/auth/apple/route.ts       | 104 +++
 govarbitrage/src/app/api/auth/login/route.ts       |  73 ++
 govarbitrage/src/app/api/auth/logout/route.ts      |  10 +
 govarbitrage/src/app/api/auth/me/route.ts          |  10 +
 govarbitrage/src/app/api/billing/checkout/route.ts |  22 +
 govarbitrage/src/app/api/billing/webhook/route.ts  |  48 ++
 .../src/app/api/import/apify-govdeals/route.ts     |  28 +
 50 files changed, 4551 insertions(+)

diff --git a/govarbitrage/apps/mobile/index.ts b/govarbitrage/apps/mobile/index.ts
new file mode 100644
index 0000000..80d3d99
--- /dev/null
+++ b/govarbitrage/apps/mobile/index.ts
@@ -0,0 +1 @@
+import "expo-router/entry";
diff --git a/govarbitrage/apps/mobile/lib/api-error.ts b/govarbitrage/apps/mobile/lib/api-error.ts
new file mode 100644
index 0000000..1c0036b
--- /dev/null
+++ b/govarbitrage/apps/mobile/lib/api-error.ts
@@ -0,0 +1,14 @@
+/**
+ * ApiError lives in its own module so both the API client (api.ts) and the
+ * payload validator (validate.ts) can throw/import it without a circular
+ * import between them.
+ */
+export class ApiError extends Error {
+  constructor(
+    public readonly status: number,
+    message: string
+  ) {
+    super(message);
+    this.name = "ApiError";
+  }
+}
diff --git a/govarbitrage/apps/mobile/lib/api.ts b/govarbitrage/apps/mobile/lib/api.ts
new file mode 100644
index 0000000..6400e23
--- /dev/null
+++ b/govarbitrage/apps/mobile/lib/api.ts
@@ -0,0 +1,145 @@
+/**
+ * GovArbitrage typed API client.
+ *
+ * Auth is OPTIONAL. The default server's read API is public, so a fresh install
+ * sends NO Authorization header and gets the same data any browser or curl gets
+ * — identical behavior across every client. A header is only added when the user
+ * takes an explicit, visible action:
+ *   • Sign in with Apple  → Authorization: Bearer <app session JWT>
+ *   • Enter a username + password in Settings (to point the app at their own
+ *     self-hosted server) → Authorization: Basic base64(user:pass)
+ * With neither, requests are anonymous.
+ */
+import { loadSettings, loadAppleAccount } from "./settings";
+import { authHeadersFor } from "./auth-headers";
+import type {
+  ListingDetail,
+  ListingsQueryParams,
+  ListingsResponse,
+} from "./types";
+import { normalizeListingsResponse, normalizeListingDetail } from "./validate";
+import { ApiError } from "./api-error";
+
+// ── Internal helpers ──────────────────────────────────────────────────────────
+
+async function getAuthHeaders(): Promise<HeadersInit> {
+  const account = await loadAppleAccount();
+  const { username, password } = await loadSettings();
+  return authHeadersFor({ appleToken: account?.token, username, password });
+}
+
+interface FetchOptions {
+  signal?: AbortSignal;
+}
+
+const REQUEST_TIMEOUT_MS = 15_000;
+
+async function apiFetch<T>(path: string, opts: FetchOptions = {}): Promise<T> {
+  const { baseUrl } = await loadSettings();
+  const authHeaders = await getAuthHeaders();
+
+  const url = `${baseUrl.replace(/\/$/, "")}${path}`;
+
+  // Bound every request so a stalled connection can't leave the UI spinning
+  // forever. A timeout throws a real ApiError (surfaced to the user); a genuine
+  // caller-abort keeps its AbortError (callers treat that as silent navigation).
+  const controller = new AbortController();
+  let timedOut = false;
+  const timer = setTimeout(() => {
+    timedOut = true;
+    controller.abort();
+  }, REQUEST_TIMEOUT_MS);
+  if (opts.signal) {
+    if (opts.signal.aborted) controller.abort();
+    else opts.signal.addEventListener("abort", () => controller.abort(), { once: true });
+  }
+
+  try {
+    const res = await fetch(url, {
+      headers: {
+        Accept: "application/json",
+        ...authHeaders,
+      },
+      signal: controller.signal,
+    });
+
+    if (!res.ok) {
+      const body = await res.text().catch(() => "");
+      const msg = body || res.statusText || String(res.status);
+      throw new ApiError(res.status, msg);
+    }
+
+    return (await res.json()) as T;
+  } catch (err) {
+    if (timedOut) {
+      throw new ApiError(0, "Request timed out. Check your connection or the server URL in Settings.");
+    }
+    throw err;
+  } finally {
+    clearTimeout(timer);
+  }
+}
+
+// ── Error type ────────────────────────────────────────────────────────────────
+// Re-exported from ./api-error so existing `import { ApiError } from "./api"`
+// callers keep working while validate.ts imports it without a circular dep.
+export { ApiError } from "./api-error";
+
+// ── Listings ──────────────────────────────────────────────────────────────────
+
+export async function fetchListings(
+  params: ListingsQueryParams = {},
+  opts: FetchOptions = {}
+): Promise<ListingsResponse> {
+  const qs = new URLSearchParams();
+  if (params.search) qs.set("search", params.search);
+  if (params.source) qs.set("source", params.source);
+  if (params.category) qs.set("category", params.category);
+  if (params.condition) qs.set("condition", params.condition);
+  if (params.risk) qs.set("risk", params.risk);
+  if (params.closingWithinHours != null)
+    qs.set("closingWithinHours", String(params.closingWithinHours));
+  if (params.sort) qs.set("sort", params.sort);
+  if (params.dir) qs.set("dir", params.dir);
+  if (params.page != null) qs.set("page", String(params.page));
+  if (params.pageSize != null) qs.set("pageSize", String(params.pageSize));
+  if (params.profile) qs.set("profile", params.profile);
+
+  const query = qs.toString();
+  const raw = await apiFetch<unknown>(
+    `/api/listings${query ? `?${query}` : ""}`,
+    opts
+  );
+  return normalizeListingsResponse(raw);
+}
+
+export async function fetchListing(
+  id: string,
+  opts: FetchOptions = {}
+): Promise<ListingDetail> {
+  const raw = await apiFetch<unknown>(`/api/listings/${encodeURIComponent(id)}`, opts);
+  return normalizeListingDetail(raw);
+}
+
+// ── Connection test ───────────────────────────────────────────────────────────
+
+export interface ConnectionTestResult {
+  ok: boolean;
+  latencyMs: number;
+  tier?: string;
+  error?: string;
+}
+
+export async function testConnection(): Promise<ConnectionTestResult> {
+  const t0 = Date.now();
+  try {
+    const data = await fetchListings({ pageSize: 1 });
+    return { ok: true, latencyMs: Date.now() - t0, tier: data.tier };
+  } catch (err) {
+    return {
+      ok: false,
+      latencyMs: Date.now() - t0,
+      error: err instanceof Error ? err.message : String(err),
+    };
+  }
+}
diff --git a/govarbitrage/apps/mobile/lib/auth-headers.ts b/govarbitrage/apps/mobile/lib/auth-headers.ts
new file mode 100644
index 0000000..a4d31b0
--- /dev/null
+++ b/govarbitrage/apps/mobile/lib/auth-headers.ts
@@ -0,0 +1,26 @@
+/**
+ * Pure auth-header policy — zero native dependencies so it is unit-testable.
+ *
+ * The GovArbitrage app never sniffs the client type and never carries a secret
+ * machine-token path. A request carries an Authorization header ONLY when the
+ * user has taken an explicit, visible action, with this precedence:
+ *   1. Signed in with Apple  → Bearer <app session JWT>
+ *   2. Self-hosting creds set → Basic base64(user:pass)
+ *   3. otherwise              → no header (anonymous public read)
+ */
+
+export function buildBasicAuthHeader(username: string, password: string): string {
+  return `Basic ${btoa(`${username}:${password}`)}`;
+}
+
+export function authHeadersFor(input: {
+  appleToken?: string | null;
+  username?: string;
+  password?: string;
+}): Record<string, string> {
+  if (input.appleToken) return { Authorization: `Bearer ${input.appleToken}` };
+  if (input.password) {
+    return { Authorization: buildBasicAuthHeader(input.username ?? "", input.password) };
+  }
+  return {};
+}
diff --git a/govarbitrage/apps/mobile/lib/auth.ts b/govarbitrage/apps/mobile/lib/auth.ts
new file mode 100644
index 0000000..cc16f77
--- /dev/null
+++ b/govarbitrage/apps/mobile/lib/auth.ts
@@ -0,0 +1,83 @@
+/**
+ * Sign in with Apple (native iOS). Uses expo-apple-authentication to obtain an
+ * Apple identity token, exchanges it at the backend's /api/auth/apple for our
+ * own app session JWT, and stores that in expo-secure-store. Optional: the rest
+ * of the app works signed-out; this just creates/links a lightweight account.
+ */
+import * as AppleAuthentication from "expo-apple-authentication";
+import {
+  loadSettings,
+  saveAppleAccount,
+  clearAppleAccount,
+  type AppleAccount,
+} from "./settings";
+
+/** True only on iOS devices/simulators that support Sign in with Apple. */
+export async function isAppleSignInAvailable(): Promise<boolean> {
+  try {
+    return await AppleAuthentication.isAvailableAsync();
+  } catch {
+    return false;
+  }
+}
+
+export async function signInWithApple(): Promise<AppleAccount> {
+  const credential = await AppleAuthentication.signInAsync({
+    requestedScopes: [
+      AppleAuthentication.AppleAuthenticationScope.FULL_NAME,
+      AppleAuthentication.AppleAuthenticationScope.EMAIL,
+    ],
+  });
+
+  const { identityToken, fullName, email } = credential;
+  if (!identityToken) {
+    throw new Error("Apple did not return an identity token.");
+  }
+
+  // Apple sends name/email only on the FIRST authorization — pass them through
+  // so the backend can populate the account on create.
+  const name =
+    fullName && (fullName.givenName || fullName.familyName)
+      ? [fullName.givenName, fullName.familyName].filter(Boolean).join(" ")
+      : null;
+
+  const { baseUrl } = await loadSettings();
+  const res = await fetch(`${baseUrl.replace(/\/$/, "")}/api/auth/apple`, {
+    method: "POST",
+    headers: { "Content-Type": "application/json", Accept: "application/json" },
+    body: JSON.stringify({
+      identityToken,
+      fullName: name || undefined,
+      email: email || undefined,
+    }),
+  });
+
+  if (!res.ok) {
+    let msg = `Sign-in failed (${res.status}).`;
+    try {
+      const body = await res.json();
+      if (body?.error) msg = body.error;
+    } catch {
+      // keep the status-code message
+    }
+    throw new Error(msg);
+  }
+
+  const data = (await res.json()) as {
+    token: string;
+    user?: { name?: string | null; email?: string | null };
+  };
+  if (!data?.token) throw new Error("Server did not return a session token.");
+
+  const account: AppleAccount = {
+    token: data.token,
+    name: data.user?.name ?? name,
+    email: data.user?.email ?? email ?? null,
+  };
+  await saveAppleAccount(account);
+  return account;
+}
+
+export async function signOut(): Promise<void> {
+  await clearAppleAccount();
+}
diff --git a/govarbitrage/apps/mobile/lib/format.ts b/govarbitrage/apps/mobile/lib/format.ts
new file mode 100644
index 0000000..84be147
--- /dev/null
+++ b/govarbitrage/apps/mobile/lib/format.ts
@@ -0,0 +1,125 @@
+/**
+ * Formatting utilities for financial data, scores, dates, and countdowns.
+ */
+
+// ── Currency ──────────────────────────────────────────────────────────────────
+
+export function fmtUSD(value: number | null | undefined, decimals = 0): string {
+  if (value == null || !Number.isFinite(value)) return "—";
+  return new Intl.NumberFormat("en-US", {
+    style: "currency",
+    currency: "USD",
+    minimumFractionDigits: decimals,
+    maximumFractionDigits: decimals,
+  }).format(value);
+}
+
+// Percentages above this ratio are treated as implausible/garbage and rendered
+// as "—" instead of an absurd number (e.g. a backend annualizedReturn of
+// 23002764 would otherwise display as 2,300,276,400.0%). 1000 ratio = 100,000%,
+// generous enough to keep legitimate aggressive returns (e.g. roi 6.83 = 683%).
+const MAX_PCT_RATIO = 1000;
+
+export function fmtPct(
+  value: number | null | undefined,
+  opts?: { maxRatioAbs?: number; decimals?: number }
+): string {
+  if (value == null || !Number.isFinite(value)) return "—";
+  // value is a decimal ratio (0.42 = 42%)
+  const maxAbs = opts?.maxRatioAbs ?? MAX_PCT_RATIO;
+  if (Math.abs(value) > maxAbs) return "—";
+  const decimals = opts?.decimals ?? 1;
+  return `${(value * 100).toFixed(decimals)}%`;
+}
+
+// ── Scores ────────────────────────────────────────────────────────────────────
+
+export function fmtScore(score: number | null | undefined): string {
+  if (score == null || !Number.isFinite(score)) return "—";
+  return Math.round(score).toString();
+}
+
+// ── Time / countdown ──────────────────────────────────────────────────────────
+
+/**
+ * Returns a human-readable countdown string like "2d 4h" or "45m" or "CLOSED".
+ * closingAt is an ISO8601 string.
+ */
+export function closingCountdown(closingAt: string | null | undefined): string {
+  if (!closingAt) return "—";
+  const ms = new Date(closingAt).getTime() - Date.now();
+  if (ms <= 0) return "CLOSED";
+  const totalMin = Math.floor(ms / 60_000);
+  const days = Math.floor(totalMin / 1440);
+  const hours = Math.floor((totalMin % 1440) / 60);
+  const mins = totalMin % 60;
+  if (days > 0) return `${days}d ${hours}h`;
+  if (hours > 0) return `${hours}h ${mins}m`;
+  return `${mins}m`;
+}
+
+export function isClosingSoon(closingAt: string | null | undefined): boolean {
+  if (!closingAt) return false;
+  const ms = new Date(closingAt).getTime() - Date.now();
+  return ms > 0 && ms < 24 * 3_600_000; // within 24h
+}
+
+/**
+ * Formats a date+time for admin cards in local timezone.
+ * Steve's hard rule: admin cards must show created date AND time.
+ */
+export function fmtDateTime(iso: string | null | undefined): string {
+  if (!iso) return "—";
+  return new Date(iso).toLocaleString(undefined, {
+    year: "numeric",
+    month: "short",
+    day: "numeric",
+    hour: "numeric",
+    minute: "2-digit",
+  });
+}
+
+// ── Condition / risk labels ───────────────────────────────────────────────────
+
+export function conditionLabel(c: string | null): string {
+  const map: Record<string, string> = {
+    NEW: "New",
+    LIKE_NEW: "Like New",
+    USED_GOOD: "Used — Good",
+    USED_FAIR: "Used — Fair",
+    FOR_PARTS: "Parts Only",
+    UNKNOWN: "Unknown",
+  };
+  return c ? (map[c] ?? c) : "Unknown";
+}
+
+export function sourceLabel(s: string): string {
+  const map: Record<string, string> = {
+    GOVDEALS: "GovDeals",
+    GSA_AUCTIONS: "GSA Auctions",
+    PUBLIC_SURPLUS: "Public Surplus",
+    COUNTY: "County",
+    STATE_SURPLUS: "State Surplus",
+    UNIVERSITY_SURPLUS: "University Surplus",
+    MUNICIBID: "Municibid",
+    BID4ASSETS: "Bid4Assets",
+    GOINDUSTRY: "GoIndustry",
+    NETWORK_INTL: "Network Intl",
+    GRAYS_AU: "Grays",
+    GOVPLANET: "GovPlanet",
+    CSV: "CSV Import",
+    EXTENSION: "Extension",
+    OTHER: "Other",
+  };
+  return map[s] ?? s;
+}
+
+export function dropShipLabel(d: string): string {
+  const map: Record<string, string> = {
+    EASY: "Drop Ship: Easy",
+    MODERATE: "Drop Ship: OK",
+    DIFFICULT: "Drop Ship: Hard",
+    INFEASIBLE: "No Drop Ship",
+  };
+  return map[d] ?? d;
+}
diff --git a/govarbitrage/apps/mobile/lib/pnl.ts b/govarbitrage/apps/mobile/lib/pnl.ts
new file mode 100644
index 0000000..df90a67
--- /dev/null
+++ b/govarbitrage/apps/mobile/lib/pnl.ts
@@ -0,0 +1,13 @@
+/**
+ * Shared profit/loss color helper — used by any screen that tints a financial
+ * value green/red by its sign. Respects the same finite guard as the formatters
+ * in lib/format.ts: a missing or non-finite value is NEUTRAL, never a misleading
+ * red (a null value would otherwise imply a loss) or green. Color each field by
+ * its OWN value — do not drive one metric's color from another's sign.
+ */
+import { Colors } from "../constants/theme";
+
+export function pnlColor(value: number | null | undefined): string {
+  if (value == null || !Number.isFinite(value)) return Colors.neutral;
+  return value >= 0 ? Colors.profit : Colors.loss;
+}
diff --git a/govarbitrage/apps/mobile/lib/settings.ts b/govarbitrage/apps/mobile/lib/settings.ts
new file mode 100644
index 0000000..7f91d47
--- /dev/null
+++ b/govarbitrage/apps/mobile/lib/settings.ts
@@ -0,0 +1,97 @@
+/**
+ * Settings persistence — base URL and Basic Auth credentials stored in
+ * expo-secure-store so they never touch AsyncStorage (unencrypted).
+ *
+ * Keys:
+ *   GOVARB_BASE_URL   — e.g. "https://auctions.agentabrams.com"
+ *   GOVARB_USERNAME   — Basic auth username
+ *   GOVARB_PASSWORD   — Basic auth password
+ */
+import * as SecureStore from "expo-secure-store";
+export { buildBasicAuthHeader } from "./auth-headers";
+
+const KEYS = {
+  BASE_URL: "GOVARB_BASE_URL",
+  USERNAME: "GOVARB_USERNAME",
+  PASSWORD: "GOVARB_PASSWORD",
+  // Sign in with Apple — the app session JWT issued by /api/auth/apple, plus the
+  // account identity to show in Settings. Optional: absent = signed out.
+  SESSION_JWT: "GOVARB_SESSION_JWT",
+  ACCOUNT_NAME: "GOVARB_ACCOUNT_NAME",
+  ACCOUNT_EMAIL: "GOVARB_ACCOUNT_EMAIL",
+} as const;
+
+const DEFAULTS = {
+  BASE_URL: "https://auctions.agentabrams.com",
+  // Empty by default — the default server's read API is public, so a fresh
+  // install sends no Authorization header. Credentials are only for users
+  // pointing the app at their own self-hosted server.
+  USERNAME: "",
+  PASSWORD: "",
+} as const;
+
+export interface AppSettings {
+  baseUrl: string;
+  username: string;
+  password: string;
+}
+
+export async function loadSettings(): Promise<AppSettings> {
+  const [baseUrl, username, password] = await Promise.all([
+    SecureStore.getItemAsync(KEYS.BASE_URL),
+    SecureStore.getItemAsync(KEYS.USERNAME),
+    SecureStore.getItemAsync(KEYS.PASSWORD),
+  ]);
+  return {
+    baseUrl: baseUrl ?? DEFAULTS.BASE_URL,
+    username: username ?? DEFAULTS.USERNAME,
+    password: password ?? DEFAULTS.PASSWORD,
+  };
+}
+
+export async function saveSettings(settings: AppSettings): Promise<void> {
+  await Promise.all([
+    SecureStore.setItemAsync(KEYS.BASE_URL, settings.baseUrl.trim()),
+    SecureStore.setItemAsync(KEYS.USERNAME, settings.username.trim()),
+    SecureStore.setItemAsync(KEYS.PASSWORD, settings.password),
+  ]);
+}
+
+// ── Sign in with Apple session ──────────────────────────────────────────────
+
+export interface AppleAccount {
+  token: string;
+  name: string | null;
+  email: string | null;
+}
+
+/** The signed-in Apple account, or null when signed out. */
+export async function loadAppleAccount(): Promise<AppleAccount | null> {
+  const [token, name, email] = await Promise.all([
+    SecureStore.getItemAsync(KEYS.SESSION_JWT),
+    SecureStore.getItemAsync(KEYS.ACCOUNT_NAME),
+    SecureStore.getItemAsync(KEYS.ACCOUNT_EMAIL),
+  ]);
+  if (!token) return null;
+  return { token, name: name ?? null, email: email ?? null };
+}
+
+export async function saveAppleAccount(account: AppleAccount): Promise<void> {
+  await Promise.all([
+    SecureStore.setItemAsync(KEYS.SESSION_JWT, account.token),
+    account.name
+      ? SecureStore.setItemAsync(KEYS.ACCOUNT_NAME, account.name)
+      : SecureStore.deleteItemAsync(KEYS.ACCOUNT_NAME),
+    account.email
+      ? SecureStore.setItemAsync(KEYS.ACCOUNT_EMAIL, account.email)
+      : SecureStore.deleteItemAsync(KEYS.ACCOUNT_EMAIL),
+  ]);
+}
+
+export async function clearAppleAccount(): Promise<void> {
+  await Promise.all([
+    SecureStore.deleteItemAsync(KEYS.SESSION_JWT),
+    SecureStore.deleteItemAsync(KEYS.ACCOUNT_NAME),
+    SecureStore.deleteItemAsync(KEYS.ACCOUNT_EMAIL),
+  ]);
+}
diff --git a/govarbitrage/apps/mobile/lib/types.ts b/govarbitrage/apps/mobile/lib/types.ts
new file mode 100644
index 0000000..7d6c0d6
--- /dev/null
+++ b/govarbitrage/apps/mobile/lib/types.ts
@@ -0,0 +1,240 @@
+// TypeScript types derived directly from the GovArbitrage API response shapes.
+// Source: src/lib/listings.ts (ListingRow), src/prisma/schema.prisma,
+//         src/app/api/listings/route.ts, src/lib/listing-detail.ts
+
+// ── Enum mirrors ─────────────────────────────────────────────────────────────
+
+export type AuctionSource =
+  | "GOVDEALS"
+  | "PUBLIC_SURPLUS"
+  | "GSA_AUCTIONS"
+  | "COUNTY"
+  | "STATE_SURPLUS"
+  | "UNIVERSITY_SURPLUS"
+  | "MUNICIBID"
+  | "BID4ASSETS"
+  | "GOINDUSTRY"
+  | "NETWORK_INTL"
+  | "GRAYS_AU"
+  | "GOVPLANET"
+  | "CSV"
+  | "EXTENSION"
+  | "OTHER";
+
+export type Condition = "NEW" | "LIKE_NEW" | "USED_GOOD" | "USED_FAIR" | "FOR_PARTS" | "UNKNOWN";
+export type RiskLevel = "LOW" | "MEDIUM" | "HIGH";
+export type DropShipFeasibility = "EASY" | "MODERATE" | "DIFFICULT" | "INFEASIBLE";
+export type ResearchStatus = "PENDING" | "QUEUED" | "IN_PROGRESS" | "COMPLETE" | "FAILED";
+export type ListingStatus = "ACTIVE" | "ENDED" | "REMOVED";
+export type Tier = "FREE" | "STANDARD" | "PREMIUM";
+export type ScoreProfile =
+  | "OVERALL_OPPORTUNITY"
+  | "BEST_ARBITRAGE"
+  | "QUICK_FLIP"
+  | "COLLECTOR"
+  | "LOCAL_PICKUP"
+  | "EASY_FREIGHT"
+  | "PARTS_ONLY"
+  | "HIGH_CONFIDENCE"
+  | "HIGH_PROFIT";
+
+// ── Flat listing row (from GET /api/listings) ────────────────────────────────
+
+export interface ListingRow {
+  id: string;
+  source: AuctionSource;
+  sourceAuctionId: string;
+  sourceUrl: string | null;
+  title: string;
+  category: string | null;
+  manufacturer: string | null;
+  model: string | null;
+  condition: Condition;
+  quantity: number;
+  currentBid: number;
+  currentCost: number; // bid + premium + tax
+  /** Null for FREE tier — money-math gate */
+  recommendedMaxBid: number | null;
+  retailLow: number | null;
+  retailAverage: number | null;
+  retailHigh: number | null;
+  usedLow: number | null;
+  usedAverage: number | null;
+  usedHigh: number | null;
+  wholesale: number | null;
+  liquidation: number | null;
+  sellNow: number | null;
+  value7Day: number | null;
+  value30Day: number | null;
+  value90Day: number | null;
+  expectedSale: number | null;
+  shipping: number;
+  freight: number;
+  repairs: number;
+  marketplaceFees: number;
+  netProfit: number | null;
+  roi: number | null; // decimal ratio e.g. 0.42 = 42%
+  risk: RiskLevel;
+  confidence: number | null; // 0..100
+  opportunityScore: number | null;
+  arbitrageScore: number | null;
+  demandScore: number | null;
+  velocityScore: number | null;
+  logisticsScore: number | null;
+  conditionScore: number | null;
+  competitionScore: number | null;
+  buyerScore: number | null;
+  dropShip: DropShipFeasibility;
+  closingAt: string | null; // ISO8601
+  researchStatus: ResearchStatus;
+  imageUrl: string | null;
+  // createdAt is present on the full Listing model, returned on detail
+  createdAt?: string;
+}
+
+// ── Paginated list response ───────────────────────────────────────────────────
+
+export interface ListingsResponse {
+  rows: ListingRow[];
+  total: number;
+  page: number;
+  pageSize: number;
+  tier: Tier;
+  gated: boolean;
+}
+
+// ── Detail response (GET /api/listings/:id) ───────────────────────────────────
+
+export interface Research {
+  id: string;
+  newRetail: number | null;
+  newReplacement: number | null;
+  avgRetail: number | null;
+  usedSoldPrice: number | null;
+  usedAskingPrice: number | null;
+  usedLow: number | null;
+  usedHigh: number | null;
+  wholesaleValue: number | null;
+  liquidationValue: number | null;
+  sellTodayValue: number | null;
+  value7Day: number | null;
+  value30Day: number | null;
+  value90Day: number | null;
+  expectedSalePrice: number | null;
+  probabilityOfSale: number | null; // 0..1
+  daysUntilSold: number | null;
+  confidenceScore: number | null; // 0..100
+  summary: string | null;
+  createdAt: string;
+}
+
+export interface CostBreakdown {
+  id: string;
+  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; // decimal ratio
+  annualizedReturn: number;
+  recommendedMaxBid: number;
+}
+
+export interface Score {
+  id: string;
+  profile: ScoreProfile;
+  value: number; // 0..100
+  arbitrage: number;
+  demand: number;
+  velocity: number;
+  logistics: number;
+  condition: number;
+  competition: number;
+  buyer: number;
+  risk: RiskLevel;
+  dropShip: DropShipFeasibility;
+  explanation: string;
+}
+
+export interface Comparable {
+  id: string;
+  kind: "SOLD" | "ACTIVE" | "RETAIL";
+  title: string;
+  price: number;
+  url: string | null;
+  source: string | null;
+  soldAt: string | null;
+}
+
+export interface ListingDetail {
+  id: string;
+  source: AuctionSource;
+  sourceAuctionId: string;
+  sourceUrl: string | null;
+  title: string;
+  description: string | null;
+  category: string | null;
+  manufacturer: string | null;
+  model: string | null;
+  serialNumber: string | null;
+  condition: Condition;
+  quantity: number;
+  accessories: string | null;
+  missingParts: string | null;
+  weightLbs: number | null;
+  dimensions: string | null;
+  locationCity: string | null;
+  locationState: string | null;
+  locationZip: string | null;
+  currentBid: number;
+  bidCount: number;
+  closingAt: string | null;
+  imageUrls: string[];
+  researchStatus: ResearchStatus;
+  listingStatus: ListingStatus;
+  createdAt: string;
+  updatedAt: string;
+  research: Research | null;
+  costBreakdown: CostBreakdown | null;
+  scores: Score[];
+  comparables: Comparable[];
+  tier: Tier;
+  gated: boolean;
+}
+
+// ── Query params ──────────────────────────────────────────────────────────────
+
+export type SortField =
+  | "opportunityScore"
+  | "roi"
+  | "netProfit"
+  | "closingAt"
+  | "currentBid";
+
+export interface ListingsQueryParams {
+  search?: string;
+  source?: AuctionSource;
+  category?: string;
+  condition?: Condition;
+  risk?: RiskLevel;
+  closingWithinHours?: number;
+  sort?: SortField;
+  dir?: "asc" | "desc";
+  page?: number;
+  pageSize?: number;
+  profile?: ScoreProfile;
+}
diff --git a/govarbitrage/apps/mobile/lib/validate.ts b/govarbitrage/apps/mobile/lib/validate.ts
new file mode 100644
index 0000000..eb8f0c9
--- /dev/null
+++ b/govarbitrage/apps/mobile/lib/validate.ts
@@ -0,0 +1,95 @@
+/**
+ * Runtime payload validation for the API boundary (TK-10279, Cycle 6).
+ *
+ * Replaces the blind `as T` cast in api.ts: the server is trusted for TYPES at
+ * compile time only, so a malformed/garbage payload used to flow straight into
+ * the UI and crash it (a non-array `scores` → `.map` throw; a non-string
+ * `title` → `.slice` throw; NaN/Infinity numbers → absurd renders). This is the
+ * ROOT the render-layer guards (fmt helpers, pnlColor, scoreColor) were
+ * treating symptomatically.
+ *
+ * Scope (tight, per DTD): own what the render guards CANNOT —
+ *   - structural faults (required arrays/objects/strings) that cause crashes,
+ *   - non-finite / non-number values → null (so the existing guards show "—").
+ * It does NOT clamp implausible-but-finite numbers (e.g. annualizedReturn
+ * 23002764) — that stays fmtPct's job, by design.
+ */
+import type { ListingDetail, ListingsResponse, Tier } from "./types";
+import { ApiError } from "./api-error";
+
+// Recursively replace non-finite numbers (NaN/Infinity) with null, everywhere.
+// Leaves finite numbers, strings, arrays, and object structure intact.
+function sanitizeNonFinite(v: unknown): unknown {
+  if (typeof v === "number") return Number.isFinite(v) ? v : null;
+  if (Array.isArray(v)) return v.map(sanitizeNonFinite);
+  if (v && typeof v === "object") {
+    const out: Record<string, unknown> = {};
+    for (const k of Object.keys(v as Record<string, unknown>)) {
+      out[k] = sanitizeNonFinite((v as Record<string, unknown>)[k]);
+    }
+    return out;
+  }
+  return v;
+}
+
+const asObj = (v: unknown): Record<string, unknown> =>
+  v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, unknown>) : {};
+const asString = (v: unknown, fallback = ""): string => (typeof v === "string" ? v : fallback);
+const asArr = (v: unknown): unknown[] => (Array.isArray(v) ? v : []);
+
+/** GET /api/listings — guarantees rows is an array of shape-safe rows. */
+export function normalizeListingsResponse(raw: unknown): ListingsResponse {
+  if (!raw || typeof raw !== "object" || !Array.isArray((raw as { rows?: unknown }).rows)) {
+    throw new ApiError(0, "Malformed response from server (expected a listings payload).");
+  }
+  const o = sanitizeNonFinite(raw) as Record<string, unknown>;
+  const rows = asArr(o.rows).map((r) => {
+    const row = asObj(r);
+    // guarantee the string fields that hit ad-hoc string ops downstream
+    row.id = asString(row.id);
+    row.title = asString(row.title);
+    return row;
+  });
+  return {
+    ...o,
+    rows,
+    total: typeof o.total === "number" ? o.total : rows.length,
+    page: typeof o.page === "number" ? o.page : 1,
+    pageSize: typeof o.pageSize === "number" ? o.pageSize : rows.length,
+    tier: asString(o.tier, "FREE") as Tier,
+    gated: !!o.gated,
+  } as unknown as ListingsResponse;
+}
+
+/** GET /api/listings/:id — guarantees id/title strings + the mapped arrays. */
+export function normalizeListingDetail(raw: unknown): ListingDetail {
+  if (!raw || typeof raw !== "object" || typeof (raw as { id?: unknown }).id !== "string") {
+    throw new ApiError(0, "Malformed response from server (expected a listing detail).");
+  }
+  const o = sanitizeNonFinite(raw) as Record<string, unknown>;
+  return {
+    ...o,
+    id: asString(o.id),
+    title: asString(o.title),
+    // arrays the detail screen .map()s over — never let them be non-arrays
+    imageUrls: asArr(o.imageUrls).filter((u): u is string => typeof u === "string"),
+    // guard ITEM shape too: a non-string sc.profile crashes sc.profile.replace()
+    // in the detail screen — arrays-are-arrays isn't enough.
+    scores: asArr(o.scores).map((s) => {
+      const so = asObj(s);
+      so.profile = asString(so.profile);
+      so.explanation = asString(so.explanation);
+      return so;
+    }),
+    comparables: asArr(o.comparables).map((c) => {
+      const co = asObj(c);
+      co.title = asString(co.title);
+      co.kind = asString(co.kind);
+      co.source = co.source == null ? null : asString(co.source);
+      return co;
+    }),
+    // nested objects the screen reads with `cb.x` / `r.x` — object or null, never a scalar
+    research: o.research && typeof o.research === "object" ? o.research : null,
+    costBreakdown: o.costBreakdown && typeof o.costBreakdown === "object" ? o.costBreakdown : null,
+  } as unknown as ListingDetail;
+}
diff --git a/govarbitrage/apps/mobile/scripts/lint-financial-format.mjs b/govarbitrage/apps/mobile/scripts/lint-financial-format.mjs
new file mode 100644
index 0000000..dd200e7
--- /dev/null
+++ b/govarbitrage/apps/mobile/scripts/lint-financial-format.mjs
@@ -0,0 +1,128 @@
+#!/usr/bin/env node
+/**
+ * lint-financial-format — regression guard-rail (TK-10279, yoloforever Cycle 4).
+ *
+ * Financial + score values (roi, netProfit, annualizedReturn, probabilityOfSale,
+ * currentBid, *Score sub-scores, …) MUST reach the UI only through the guarded
+ * helpers — fmtUSD / fmtPct / fmtScore (lib/format.ts) and pnlColor / scoreColor
+ * (lib/pnl.ts, ScoreBadge) — which return "—" / neutral for null|non-finite and
+ * clamp implausible ratios. A backend garbage value (e.g. annualizedReturn
+ * 23002764 → "2,300,276,400.0%"), null, or NaN must never render as an absurd
+ * number, literal "NaN", or a misleading green/red.
+ *
+ * This script fails (exit 1) if a UI file under app/** or components/** does an
+ * AD-HOC render/color of such a value that bypasses those helpers. Zero-dep,
+ * read-only.
+ *
+ * Escape hatch: append  // lint-financial-format-ok: <reason>  to a reviewed
+ * exception line (e.g. a boolean success color, not a raw number).
+ *
+ * KNOWN LIMITATIONS (line-regex scanner, no AST — do not mistake a pass for proof):
+ *   - Aliasing / cross-line data flow is invisible: `const r = row.roi;` then
+ *     `<Text>{r}</Text>` on the next line is NOT caught (the alias isn't a field
+ *     name). Reviewers must still catch indirection.
+ *   - Only the Colors.profit/Colors.loss token pair is checked for sign-color;
+ *     a new color family or a styles.profitText indirection driven by a raw sign
+ *     is not caught.
+ *   - Field matching is name-based (suffix Score/Bid/Profit/Return/Price + an
+ *     explicit list); a financial field that fits none of those is not tracked.
+ *
+ * Run: npm run lint:fin   (or: node scripts/lint-financial-format.mjs [rootDir])
+ */
+import { readFileSync, readdirSync, statSync } from "node:fs";
+import { join, relative } from "node:path";
+
+const APP_ROOT = process.argv[2] || join(import.meta.dirname, "..");
+const SCAN_DIRS = ["app", "components"];
+const EXT = /\.(tsx|ts)$/;
+
+// Name-based field detection: common financial/score suffixes (catches
+// arbitrageScore, recommendedMaxBid, expectedNetProfit, expectedSalePrice, …)
+// plus explicit fields that fit no suffix.
+const FIELD_SRC =
+  "\\b(?:\\w*(?:Score|Bid|Profit|Return|Price)|roi|annualizedReturn|probabilityOfSale|daysUntilSold|liquidationValue|sellTodayValue|marketplaceFees|totalInvestment|expectedReturns)\\b";
+const FIELD_RE = new RegExp(FIELD_SRC);
+const GUARD_CALL = /\b(?:fmtUSD|fmtPct|fmtScore|pnlColor|scoreColor)\s*\([^)]*\)/g;
+const FINITE = /Number\.isFinite\s*\(/;
+const OK_MARK = /lint-financial-format-ok/;
+
+// Strip guarded-helper call spans + Number.isFinite spans so we only inspect the
+// UNGUARDED remainder of a line for a raw field.
+function residual(line) {
+  return line.replace(GUARD_CALL, " ").replace(/Number\.isFinite\s*\([^)]*\)/g, " ");
+}
+
+const RULES = [
+  { id: "adhoc-round",
+    test: (l) => new RegExp(`Math\\.round\\s*\\([^)]*${FIELD_SRC}`).test(l),
+    msg: "Math.round() on a financial/score field — use fmtScore()/fmtPct()" },
+  { id: "adhoc-format",
+    test: (l) => (/(\.toFixed|\.toLocaleString)\s*\(/.test(l) && FIELD_RE.test(l))
+                 || (/Intl\.NumberFormat/.test(l) && FIELD_RE.test(l)),
+    msg: ".toFixed()/.toLocaleString()/Intl.NumberFormat on a field — use fmtUSD()/fmtPct()" },
+  { id: "adhoc-pct100",
+    test: (l) => /\*\s*100\b/.test(l) && (FIELD_RE.test(l) || /%/.test(l)),
+    msg: "ad-hoc *100 percent conversion — use fmtPct()" },
+  { id: "raw-sign-color",
+    test: (l) => /\?\s*Colors\.(profit|loss)\s*:\s*Colors\.(profit|loss)/.test(l)
+                 && (/(>=|<=|<|>)\s*0/.test(l) || FIELD_RE.test(l)),
+    msg: "profit/loss color from a raw number — use pnlColor(value)" },
+  // A raw field reaching output as a BARE expression — `{row.currentBid}` or
+  // `${row.currentBid}` — with no guarded helper wrapping it and no
+  // Number.isFinite guard on the line. Only flags a bare field access (no
+  // call/operator inside the braces), so conditional guards like
+  // `{row.roi != null && (…)}` are not flagged. Catches the "delete the wrapper"
+  // regression, the whole reason this guard exists.
+  { id: "raw-field-render",
+    test: (l) => {
+      if (FINITE.test(l)) return false;
+      if (/^\s*import\b/.test(l)) return false;
+      const r = residual(l);
+      // {obj.field} or `${obj.field}` where the FIELD is the last member segment
+      // (a real data access), the object is not a style/theme namespace, and the
+      // braces hold only that access (no call/operator). Catches the
+      // "delete the fmt wrapper" regression; ignores style refs + component names.
+      return /\$?\{\s*(?!(?:styles|Colors|Typography|Spacing|Radius|StyleSheet)\b)[\w.]*?\.(?:\w*(?:Score|Bid|Profit|Return|Price)|roi|annualizedReturn|probabilityOfSale|daysUntilSold|liquidationValue|sellTodayValue|marketplaceFees|totalInvestment|expectedReturns)\b\s*\}/.test(r);
+    },
+    msg: "raw {field} reaching output — wrap in fmtUSD()/fmtPct()/fmtScore() (or guard with Number.isFinite() for a plain count)" },
+];
+
+function walk(dir, out = []) {
+  for (const name of readdirSync(dir)) {
+    if (name === "node_modules" || name.startsWith(".")) continue;
+    const p = join(dir, name);
+    const st = statSync(p);
+    if (st.isDirectory()) walk(p, out);
+    else if (EXT.test(name)) out.push(p);
+  }
+  return out;
+}
+
+const violations = [];
+for (const sub of SCAN_DIRS) {
+  const base = join(APP_ROOT, sub);
+  let files;
+  try { files = walk(base); } catch { continue; }
+  for (const file of files) {
+    const lines = readFileSync(file, "utf8").split("\n");
+    lines.forEach((line, i) => {
+      if (OK_MARK.test(line)) return;
+      for (const rule of RULES) {
+        if (rule.test(line)) {
+          violations.push({ file: relative(APP_ROOT, file), line: i + 1, rule: rule.id, msg: rule.msg, snippet: line.trim().slice(0, 120) });
+        }
+      }
+    });
+  }
+}
+
+if (violations.length === 0) {
+  console.log("✓ lint-financial-format: no ad-hoc financial/score rendering found.");
+  process.exit(0);
+}
+console.error(`✗ lint-financial-format: ${violations.length} violation(s) — route through the guarded helpers (or add // lint-financial-format-ok: <reason>):\n`);
+for (const v of violations) {
+  console.error(`  ${v.file}:${v.line}  [${v.rule}] ${v.msg}`);
+  console.error(`      ${v.snippet}`);
+}
+process.exit(1);
diff --git a/govarbitrage/apps/mobile/tests/auth-headers.test.mjs b/govarbitrage/apps/mobile/tests/auth-headers.test.mjs
new file mode 100644
index 0000000..aebb95b
--- /dev/null
+++ b/govarbitrage/apps/mobile/tests/auth-headers.test.mjs
@@ -0,0 +1,49 @@
+/**
+ * Regression tests for lib/auth-headers.ts — the app's auth-header policy after
+ * the Guideline 5.6 fix (TK-10279). Zero-dependency: Node's built-in test runner
+ * with TS type-stripping (auth-headers.ts is a pure module, no native imports).
+ *
+ * Run: npm run test:unit
+ *
+ * What these lock in:
+ *   • a fresh install (no Apple sign-in, no self-hosting creds) sends NO
+ *     Authorization header — byte-identical to what a browser/curl sends, which
+ *     is the whole point of the 5.6 fix (behavior can't vary by client).
+ *   • the removed "token:" x-import-token secret path stays removed — a password
+ *     that happens to start with "token:" is just a normal Basic password now.
+ */
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import { authHeadersFor, buildBasicAuthHeader } from "../lib/auth-headers.ts";
+
+test("fresh install → NO Authorization header (anonymous, identical to a browser)", () => {
+  assert.deepEqual(authHeadersFor({}), {});
+  assert.deepEqual(authHeadersFor({ appleToken: null, username: "", password: "" }), {});
+  // A username with no password still sends nothing — nothing to authenticate.
+  assert.deepEqual(authHeadersFor({ username: "admin", password: "" }), {});
+});
+
+test("Apple sign-in → Bearer, and it wins over any self-hosting creds", () => {
+  assert.deepEqual(authHeadersFor({ appleToken: "jwt123" }), {
+    Authorization: "Bearer jwt123",
+  });
+  assert.deepEqual(
+    authHeadersFor({ appleToken: "jwt123", username: "u", password: "p" }),
+    { Authorization: "Bearer jwt123" }
+  );
+});
+
+test("self-hosting username+password → Basic", () => {
+  assert.deepEqual(authHeadersFor({ username: "u", password: "p" }), {
+    Authorization: buildBasicAuthHeader("u", "p"),
+  });
+  assert.equal(buildBasicAuthHeader("u", "p"), `Basic ${btoa("u:p")}`);
+});
+
+test("the 'token:' secret machine-token path is gone", () => {
+  // Pre-fix, a password prefixed 'token:' became an x-import-token backdoor
+  // header. Now it is treated as an ordinary Basic password — no special path.
+  const headers = authHeadersFor({ username: "u", password: "token:SECRET" });
+  assert.ok(!("x-import-token" in headers), "no x-import-token header is ever produced");
+  assert.deepEqual(headers, { Authorization: buildBasicAuthHeader("u", "token:SECRET") });
+});
diff --git a/govarbitrage/apps/mobile/tests/format.test.mjs b/govarbitrage/apps/mobile/tests/format.test.mjs
new file mode 100644
index 0000000..8d2259e
--- /dev/null
+++ b/govarbitrage/apps/mobile/tests/format.test.mjs
@@ -0,0 +1,99 @@
+/**
+ * Regression tests for lib/format.ts — the guarded financial/score formatters
+ * hardened across TK-10279 Cycles 1-3. Zero-dependency: runs on Node's built-in
+ * test runner with TS type-stripping, no jest/babel needed (format.ts is a pure
+ * module with no imports).
+ *
+ * Run: npm run test:unit   (node --test --experimental-strip-types tests/*.test.mjs)
+ */
+import { test } from "node:test";
+import assert from "node:assert/strict";
+import {
+  fmtUSD, fmtPct, fmtScore,
+  closingCountdown, isClosingSoon, fmtDateTime,
+  conditionLabel, sourceLabel, dropShipLabel,
+} from "../lib/format.ts";
+
+const inMs = (ms) => new Date(Date.now() + ms).toISOString();
+const HOUR = 3_600_000;
+
+test("fmtPct clamps implausible + guards null/non-finite (Cycle 1 fix)", () => {
+  assert.equal(fmtPct(23002764), "—", "the 2.3-billion-% backend value renders as a dash");
+  assert.equal(fmtPct(6.83), "683.0%", "legit aggressive return survives");
+  assert.equal(fmtPct(0.42), "42.0%");
+  assert.equal(fmtPct(-0.15), "-15.0%");
+  assert.equal(fmtPct(null), "—");
+  assert.equal(fmtPct(undefined), "—");
+  assert.equal(fmtPct(NaN), "—");
+  assert.equal(fmtPct(Infinity), "—");
+  assert.equal(fmtPct(1000), "100000.0%", "ceiling is exclusive: exactly 1000 ratio still renders");
+  assert.equal(fmtPct(1000.01), "—", "just OVER the ceiling (>1000 ratio) → dash");
+  assert.equal(fmtPct(999), "99900.0%", "under the ceiling renders");
+});
+
+test("fmtPct decimals option (Cycle 2 probabilityOfSale fix)", () => {
+  assert.equal(fmtPct(0.85, { decimals: 0 }), "85%");
+  assert.equal(fmtPct(0.333, { decimals: 0 }), "33%");
+  assert.equal(fmtPct(1, { decimals: 0 }), "100%");
+});
+
+test("fmtUSD guards non-finite (Cycle 1 fix)", () => {
+  assert.equal(fmtUSD(1234), "$1,234");
+  assert.equal(fmtUSD(1234.5, 2), "$1,234.50");
+  assert.equal(fmtUSD(null), "—");
+  assert.equal(fmtUSD(undefined), "—");
+  assert.equal(fmtUSD(NaN), "—");
+  assert.equal(fmtUSD(Infinity), "—");
+  assert.equal(fmtUSD(0), "$0");
+  assert.equal(fmtUSD(-500), "-$500");
+});
+
+test("fmtScore rounds + guards non-finite (Cycle 3 fix)", () => {
+  assert.equal(fmtScore(87.6), "88");
+  assert.equal(fmtScore(40), "40");
+  assert.equal(fmtScore(null), "—");
+  assert.equal(fmtScore(NaN), "—");
+  assert.equal(fmtScore(Infinity), "—");
+});
+
+test("fmtUSD decimals + negative edges", () => {
+  assert.equal(fmtUSD(1234.5, 2), "$1,234.50");
+  assert.equal(fmtUSD(1234.5), "$1,235", "default 0 decimals rounds");
+  assert.equal(fmtUSD(-1234.5, 2), "-$1,234.50");
+});
+
+test("closingCountdown — auction-urgency logic (Cody Cycle-7 gap)", () => {
+  assert.equal(closingCountdown(null), "—");
+  assert.equal(closingCountdown(undefined), "—");
+  assert.equal(closingCountdown(inMs(-HOUR)), "CLOSED", "past close");
+  assert.equal(closingCountdown(inMs(0)), "CLOSED", "exactly now (ms<=0)");
+  assert.match(closingCountdown(inMs(50 * HOUR)), /^\d+d \d+h$/, "multi-day → Nd Nh");
+  assert.match(closingCountdown(inMs(3 * HOUR + 5 * 60_000)), /^\d+h \d+m$/, "hours → Nh Nm");
+  assert.match(closingCountdown(inMs(30 * 60_000)), /^\d+m$/, "under an hour → Nm");
+});
+
+test("isClosingSoon — 24h boundary (Cody Cycle-7 gap)", () => {
+  assert.equal(isClosingSoon(null), false);
+  assert.equal(isClosingSoon(inMs(2 * HOUR)), true, "within 24h");
+  assert.equal(isClosingSoon(inMs(48 * HOUR)), false, "beyond 24h");
+  assert.equal(isClosingSoon(inMs(-HOUR)), false, "already closed is not 'soon'");
+});
+
+test("fmtDateTime — admin timestamp (Steve's hard rule)", () => {
+  assert.equal(fmtDateTime(null), "—");
+  assert.equal(fmtDateTime(undefined), "—");
+  const s = fmtDateTime("2026-05-20T15:17:00Z");
+  assert.equal(typeof s, "string");
+  assert.notEqual(s, "—");
+  assert.match(s, /2026/, "renders the year (locale-format, so assert the year not exact string)");
+});
+
+test("label lookups fall back to the raw code for unknown values", () => {
+  assert.equal(conditionLabel("NEW"), "New");
+  assert.equal(conditionLabel(null), "Unknown");
+  assert.equal(conditionLabel("MYSTERY"), "MYSTERY", "unknown code passes through");
+  assert.equal(sourceLabel("GOVDEALS"), "GovDeals");
+  assert.equal(sourceLabel("WEIRD_SRC"), "WEIRD_SRC");
+  assert.equal(dropShipLabel("EASY"), "Drop Ship: Easy");
+  assert.equal(dropShipLabel("???"), "???");
+});
diff --git a/govarbitrage/eslint.config.mjs b/govarbitrage/eslint.config.mjs
new file mode 100644
index 0000000..0549540
--- /dev/null
+++ b/govarbitrage/eslint.config.mjs
@@ -0,0 +1,11 @@
+import next from "eslint-config-next";
+
+/** Flat ESLint config for Next.js 16 (native flat export). */
+const eslintConfig = [
+  {
+    ignores: ["node_modules/**", ".next/**", "extension/**", "prisma/seed.ts"],
+  },
+  ...next,
+];
+
+export default eslintConfig;
diff --git a/govarbitrage/extension/background.js b/govarbitrage/extension/background.js
new file mode 100644
index 0000000..378e426
--- /dev/null
+++ b/govarbitrage/extension/background.js
@@ -0,0 +1,28 @@
+/**
+ * GovArbitrage Capture — MV3 service worker.
+ *
+ * Intentionally minimal. The extension does its real work from the popup
+ * (chrome.scripting + fetch), so this worker only:
+ *   - logs install/update for debugging
+ *   - optionally relays messages (kept for future content-script → app hooks)
+ *
+ * Service workers in MV3 are ephemeral; do not hold long-lived state here.
+ */
+
+chrome.runtime.onInstalled.addListener((details) => {
+  console.log('[GovArbitrage Capture] installed/updated:', details.reason);
+});
+
+// Optional message relay. Not required for the current popup-driven flow, but
+// lets a future content script ping the worker without breaking anything.
+chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => {
+  try {
+    if (message && message.type === 'PING') {
+      sendResponse({ type: 'PONG', ts: Date.now() });
+      return true; // keep the channel open for the async response
+    }
+  } catch (e) {
+    console.warn('[GovArbitrage Capture] message handler error:', e);
+  }
+  return false;
+});
diff --git a/govarbitrage/extension/content.js b/govarbitrage/extension/content.js
new file mode 100644
index 0000000..c71fd96
--- /dev/null
+++ b/govarbitrage/extension/content.js
@@ -0,0 +1,422 @@
+/**
+ * GovArbitrage Capture — page extractor.
+ *
+ * This file exposes a single function, extractGovArbitrageListing(), that is
+ * injected into the active tab via chrome.scripting.executeScript and returns
+ * a structured listing object. It must be entirely self-contained (no imports,
+ * no closure over popup state) because it runs in the page's isolated world.
+ *
+ * It is written to NEVER throw — every DOM access is wrapped in try/catch or
+ * guarded with optional chaining, so a partial page still yields a partial (but
+ * valid) object.
+ *
+ * Emitted shape (matches the app's Prisma Listing model / EXTENSION source):
+ * {
+ *   source:          "GOVDEALS" | "PUBLIC_SURPLUS" | "GSA_AUCTIONS" | "OTHER",
+ *   sourceAuctionId: string,
+ *   sourceUrl:       string,
+ *   title:           string,
+ *   description:     string,
+ *   category:        string,
+ *   currentBid:      number,          // dollars, e.g. 125.5
+ *   bidCount:        number,
+ *   closingAt:       string | null,   // ISO 8601
+ *   locationCity:    string,
+ *   locationState:   string,
+ *   locationZip:     string,
+ *   imageUrls:       string[],
+ *   auctionTerms:    string,
+ *   capturedAt:      string           // ISO 8601, when the extension scraped it
+ * }
+ */
+function extractGovArbitrageListing() {
+  'use strict';
+
+  // ----------------------------------------------------------------- helpers
+  const safe = (fn, fallback) => {
+    try {
+      const v = fn();
+      return v === undefined || v === null ? fallback : v;
+    } catch (_e) {
+      return fallback;
+    }
+  };
+
+  const text = (el) => {
+    try {
+      return (el && (el.textContent || el.innerText) || '').replace(/\s+/g, ' ').trim();
+    } catch (_e) {
+      return '';
+    }
+  };
+
+  const q = (sel, root) => safe(() => (root || document).querySelector(sel), null);
+  const qa = (sel, root) => safe(() => Array.from((root || document).querySelectorAll(sel)), []);
+
+  const meta = (prop) =>
+    safe(
+      () =>
+        (
+          q(`meta[property="${prop}"]`) ||
+          q(`meta[name="${prop}"]`)
+        )?.getAttribute('content') || '',
+      ''
+    );
+
+  // Parse a currency-ish string ("$1,250.00", "USD 1250") into a number.
+  const parseMoney = (str) => {
+    if (str === null || str === undefined) return 0;
+    try {
+      const m = String(str).replace(/[^0-9.,]/g, '');
+      if (!m) return 0;
+      // Strip thousands separators, keep the last dot as decimal.
+      const cleaned = m.replace(/,(?=\d{3}(\D|$))/g, '').replace(/,/g, '');
+      const n = parseFloat(cleaned);
+      return Number.isFinite(n) ? n : 0;
+    } catch (_e) {
+      return 0;
+    }
+  };
+
+  const parseInt10 = (str) => {
+    try {
+      const n = parseInt(String(str).replace(/[^0-9]/g, ''), 10);
+      return Number.isFinite(n) ? n : 0;
+    } catch (_e) {
+      return 0;
+    }
+  };
+
+  const toISO = (str) => {
+    if (!str) return null;
+    try {
+      const d = new Date(str);
+      if (!isNaN(d.getTime())) return d.toISOString();
+    } catch (_e) {
+      /* fall through */
+    }
+    return null;
+  };
+
+  const absUrl = (u) => {
+    if (!u) return '';
+    try {
+      return new URL(u, location.href).href;
+    } catch (_e) {
+      return u;
+    }
+  };
+
+  const params = safe(() => new URLSearchParams(location.search), new URLSearchParams());
+  const host = safe(() => location.hostname.toLowerCase(), '');
+
+  // ------------------------------------------------------------------ source
+  let source = 'OTHER';
+  if (host.includes('govdeals.com')) source = 'GOVDEALS';
+  else if (host.includes('publicsurplus.com')) source = 'PUBLIC_SURPLUS';
+  else if (host.includes('gsaauctions.gov')) source = 'GSA_AUCTIONS';
+
+  // ---------------------------------------------------- source auction / lot id
+  const idFromQuery = () => {
+    const keys = [
+      'index',
+      'itemid',
+      'itemId',
+      'assetId',
+      'assetid',
+      'auctionId',
+      'auctionid',
+      'id',
+      'lotId',
+      'lotid',
+      'sku',
+      'a',
+    ];
+    for (const k of keys) {
+      const v = params.get(k);
+      if (v) return v;
+    }
+    return '';
+  };
+
+  const idFromPath = () => {
+    const path = safe(() => location.pathname, '') || '';
+    // common patterns: /asset/12345, /item/12345, /auction/98765, /.../12345
+    const patterns = [
+      /\/(?:asset|item|items|auction|auctions|lot|lots|listing|listings)\/([A-Za-z0-9_-]+)/i,
+      /\/(\d{4,})(?:[/?#]|$)/,
+    ];
+    for (const re of patterns) {
+      const m = path.match(re);
+      if (m && m[1]) return m[1];
+    }
+    return '';
+  };
+
+  let sourceAuctionId = idFromQuery() || idFromPath();
+  if (!sourceAuctionId) {
+    // Last resort: look for a visible "Item #" / "Auction #" label on the page.
+    const bodyText = safe(() => document.body.innerText, '') || '';
+    const m =
+      bodyText.match(/(?:item|auction|lot|asset)\s*(?:#|no\.?|number)?\s*:?\s*([A-Za-z0-9-]{3,})/i) || null;
+    if (m && m[1]) sourceAuctionId = m[1];
+  }
+  sourceAuctionId = String(sourceAuctionId || '').trim();
+
+  // ------------------------------------------------------------------- title
+  let title =
+    meta('og:title') ||
+    text(q('h1')) ||
+    safe(() => document.title, '') ||
+    '';
+  // Trim trailing " | GovDeals" style site suffixes.
+  title = title.replace(/\s*[|\-–—]\s*(GovDeals|Public Surplus|GSA Auctions).*$/i, '').trim();
+
+  // ------------------------------------------------------------- description
+  let description =
+    meta('og:description') ||
+    meta('description') ||
+    '';
+  if (!description) {
+    // Try common description containers.
+    const descEl =
+      q('#description') ||
+      q('.description') ||
+      q('[class*="description" i]') ||
+      q('[id*="description" i]') ||
+      q('[class*="itemDetail" i]');
+    description = text(descEl);
+  }
+
+  // --------------------------------------------------------------- category
+  let category =
+    meta('og:category') ||
+    meta('article:section') ||
+    '';
+  if (!category) {
+    // Breadcrumb trail is the most reliable category signal.
+    const crumbs = qa('[class*="breadcrumb" i] a, nav[aria-label*="breadcrumb" i] a, .breadcrumbs a');
+    const parts = crumbs.map(text).filter(Boolean);
+    if (parts.length) {
+      // Drop a leading "Home" crumb, take the deepest meaningful one.
+      const filtered = parts.filter((p) => !/^home$/i.test(p));
+      category = (filtered[filtered.length - 1] || '').trim();
+    }
+  }
+
+  // -------------------------------------------------------------- current bid
+  const findBid = () => {
+    // 1. Site-specific selectors first.
+    const bidSelectors = [
+      '#currentBidAmount',
+      '.current-bid',
+      '.currentBid',
+      '[class*="currentBid" i]',
+      '[class*="current-bid" i]',
+      '[id*="currentBid" i]',
+      '[data-testid*="bid" i]',
+    ];
+    for (const sel of bidSelectors) {
+      const el = q(sel);
+      if (el) {
+        const v = parseMoney(text(el));
+        if (v > 0) return v;
+      }
+    }
+    // 2. Label-adjacent scan: find a "Current Bid" / "High Bid" label, read
+    //    the nearest dollar amount after it.
+    const labelRe = /(current\s*bid|high\s*bid|winning\s*bid|current\s*price|bid\s*amount)/i;
+    const candidates = qa('td, th, span, div, p, li, dt, dd, label, strong, b');
+    for (let i = 0; i < candidates.length; i++) {
+      const t = text(candidates[i]);
+      if (!labelRe.test(t)) continue;
+      // dollar value may be inside the same node...
+      const inline = t.match(/\$[\s]*([0-9][0-9.,]*)/);
+      if (inline) {
+        const v = parseMoney(inline[0]);
+        if (v > 0) return v;
+      }
+      // ...or in a sibling / parent's next cell.
+      const sib = candidates[i].nextElementSibling;
+      if (sib) {
+        const v = parseMoney(text(sib));
+        if (v > 0) return v;
+      }
+    }
+    // 3. Global fallback: first plausible dollar figure on the page.
+    const body = safe(() => document.body.innerText, '') || '';
+    const m = body.match(/\$[\s]*([0-9][0-9.,]*)/);
+    return m ? parseMoney(m[0]) : 0;
+  };
+  const currentBid = safe(findBid, 0);
+
+  // ---------------------------------------------------------------- bid count
+  const findBidCount = () => {
+    const sels = ['[class*="bidCount" i]', '[class*="bid-count" i]', '[id*="bidCount" i]', '.bids'];
+    for (const sel of sels) {
+      const el = q(sel);
+      if (el) {
+        const v = parseInt10(text(el));
+        if (v > 0) return v;
+      }
+    }
+    const body = safe(() => document.body.innerText, '') || '';
+    const m = body.match(/(\d+)\s*bids?\b/i) || body.match(/bids?\s*[:#]?\s*(\d+)/i);
+    return m ? parseInt10(m[1]) : 0;
+  };
+  const bidCount = safe(findBidCount, 0);
+
+  // ---------------------------------------------------------------- closing at
+  const findClosing = () => {
+    // Prefer machine-readable <time datetime="...">.
+    const timeEls = qa('time[datetime]');
+    for (const el of timeEls) {
+      const iso = toISO(el.getAttribute('datetime'));
+      if (iso) return iso;
+    }
+    // Labelled selectors.
+    const sels = [
+      '[class*="closingDate" i]',
+      '[class*="closing-date" i]',
+      '[class*="endDate" i]',
+      '[class*="end-date" i]',
+      '[class*="timeLeft" i]',
+      '[id*="closing" i]',
+      '[id*="endDate" i]',
+    ];
+    for (const sel of sels) {
+      const el = q(sel);
+      const iso = toISO(el?.getAttribute?.('datetime') || text(el));
+      if (iso) return iso;
+    }
+    // Label-adjacent text scan.
+    const labelRe = /(closing|close|ends?|end\s*date|end\s*time|auction\s*ends?)/i;
+    const nodes = qa('td, th, span, div, p, li, dt, dd, label, strong, b');
+    for (let i = 0; i < nodes.length; i++) {
+      const t = text(nodes[i]);
+      if (!labelRe.test(t)) continue;
+      const iso = toISO(t.replace(labelRe, '').replace(/[:#-]/g, ' ').trim());
+      if (iso) return iso;
+      const sib = nodes[i].nextElementSibling;
+      if (sib) {
+        const iso2 = toISO(text(sib));
+        if (iso2) return iso2;
+      }
+    }
+    return null;
+  };
+  const closingAt = safe(findClosing, null);
+
+  // --------------------------------------------------------------- location
+  const findLocation = () => {
+    let city = '';
+    let state = '';
+    let zip = '';
+
+    // Look for an explicit location label / container.
+    const locEl =
+      q('[class*="location" i]') ||
+      q('[id*="location" i]') ||
+      q('[class*="itemLocation" i]');
+    let locText = text(locEl);
+
+    if (!locText) {
+      const labelRe = /(location|located\s*in|city\/state|item\s*location)/i;
+      const nodes = qa('td, th, span, div, p, li, dt, dd, label, strong, b');
+      for (let i = 0; i < nodes.length; i++) {
+        const t = text(nodes[i]);
+        if (labelRe.test(t)) {
+          const sib = nodes[i].nextElementSibling;
+          locText = (sib ? text(sib) : t.replace(labelRe, '').trim()) || '';
+          if (locText) break;
+        }
+      }
+    }
+
+    // Zip.
+    const zipM = locText.match(/\b(\d{5})(?:-\d{4})?\b/);
+    if (zipM) zip = zipM[1];
+
+    // "City, ST 12345" or "City, ST".
+    const csM = locText.match(/([A-Za-z .'-]+),\s*([A-Z]{2})\b/);
+    if (csM) {
+      city = csM[1].trim();
+      state = csM[2].trim();
+    }
+
+    return { locationCity: city, locationState: state, locationZip: zip };
+  };
+  const loc = safe(findLocation, { locationCity: '', locationState: '', locationZip: '' });
+
+  // ---------------------------------------------------------------- images
+  const findImages = () => {
+    const urls = new Set();
+
+    const og = meta('og:image');
+    if (og) urls.add(absUrl(og));
+
+    // Gallery / prominent images. Prefer larger images and skip obvious icons.
+    const imgs = qa('img');
+    for (const img of imgs) {
+      const raw =
+        img.getAttribute('data-src') ||
+        img.getAttribute('data-lazy') ||
+        img.currentSrc ||
+        img.src ||
+        '';
+      if (!raw) continue;
+      const u = absUrl(raw);
+      if (!/^https?:/i.test(u)) continue;
+      // Skip sprites / icons / tracking pixels / tiny thumbs by name or size.
+      if (/(sprite|icon|logo|pixel|blank|spacer|placeholder)/i.test(u)) continue;
+      const w = img.naturalWidth || img.width || 0;
+      const h = img.naturalHeight || img.height || 0;
+      if ((w && w < 80) || (h && h < 80)) continue;
+      urls.add(u);
+    }
+
+    return Array.from(urls).slice(0, 24);
+  };
+  const imageUrls = safe(findImages, []);
+
+  // ------------------------------------------------------------ auction terms
+  const findTerms = () => {
+    const sels = [
+      '[class*="terms" i]',
+      '[id*="terms" i]',
+      '[class*="paymentTerms" i]',
+      '[class*="removalTerms" i]',
+      '[class*="specialInstructions" i]',
+    ];
+    const chunks = [];
+    for (const sel of sels) {
+      for (const el of qa(sel)) {
+        const t = text(el);
+        if (t && t.length > 10) chunks.push(t);
+      }
+    }
+    // De-dup and cap length.
+    const joined = Array.from(new Set(chunks)).join('\n\n');
+    return joined.slice(0, 5000);
+  };
+  const auctionTerms = safe(findTerms, '');
+
+  // ------------------------------------------------------------------ result
+  return {
+    source,
+    sourceAuctionId,
+    sourceUrl: safe(() => location.href, ''),
+    title: (title || '').slice(0, 500),
+    description: (description || '').slice(0, 20000),
+    category: (category || '').slice(0, 200),
+    currentBid,
+    bidCount,
+    closingAt,
+    locationCity: loc.locationCity || '',
+    locationState: loc.locationState || '',
+    locationZip: loc.locationZip || '',
+    imageUrls,
+    auctionTerms,
+    capturedAt: new Date().toISOString(),
+  };
+}
diff --git a/govarbitrage/extension/popup.js b/govarbitrage/extension/popup.js
new file mode 100644
index 0000000..1354a5f
--- /dev/null
+++ b/govarbitrage/extension/popup.js
@@ -0,0 +1,198 @@
+/**
+ * GovArbitrage Capture — popup controller.
+ *
+ * Responsibilities:
+ *   - load/save the API base URL to chrome.storage.local
+ *   - on "Capture", inject content.js's extractor into the active tab and show
+ *     the returned object
+ *   - on "Send", POST the captured object to `${apiBase}/api/import/extension`
+ *
+ * All async paths are guarded so a failure surfaces as readable status text
+ * rather than an unhandled rejection.
+ */
+
+const DEFAULT_API_BASE = 'http://localhost:3000';
+const STORAGE_KEY = 'govarbitrage_api_base';
+const IMPORT_PATH = '/api/import/extension';
+
+const els = {
+  apiBase: document.getElementById('apiBase'),
+  captureBtn: document.getElementById('captureBtn'),
+  sendBtn: document.getElementById('sendBtn'),
+  preview: document.getElementById('preview'),
+  summary: document.getElementById('summary'),
+  status: document.getElementById('status'),
+};
+
+/** Last successfully captured listing object (or null). */
+let captured = null;
+
+// ------------------------------------------------------------------ status
+function setStatus(msg, kind = 'info') {
+  els.status.textContent = msg || '';
+  els.status.className = kind;
+}
+
+// ------------------------------------------------------------- storage load
+function loadApiBase() {
+  try {
+    chrome.storage.local.get([STORAGE_KEY], (res) => {
+      const val = (res && res[STORAGE_KEY]) || DEFAULT_API_BASE;
+      els.apiBase.value = val;
+    });
+  } catch (_e) {
+    els.apiBase.value = DEFAULT_API_BASE;
+  }
+}
+
+function saveApiBase() {
+  const val = normalizeBase(els.apiBase.value);
+  try {
+    chrome.storage.local.set({ [STORAGE_KEY]: val });
+  } catch (_e) {
+    /* non-fatal */
+  }
+}
+
+function normalizeBase(v) {
+  let base = (v || '').trim() || DEFAULT_API_BASE;
+  // strip trailing slashes
+  base = base.replace(/\/+$/, '');
+  return base;
+}
+
+// ------------------------------------------------------------- active tab
+function getActiveTab() {
+  return new Promise((resolve, reject) => {
+    try {
+      chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => {
+        if (chrome.runtime.lastError) return reject(new Error(chrome.runtime.lastError.message));
+        const tab = tabs && tabs[0];
+        if (!tab) return reject(new Error('No active tab found.'));
+        resolve(tab);
+      });
+    } catch (e) {
+      reject(e);
+    }
+  });
+}
+
+// ------------------------------------------------------------------ capture
+async function onCapture() {
+  setStatus('Capturing…', 'info');
+  els.sendBtn.disabled = true;
+  captured = null;
+
+  let tab;
+  try {
+    tab = await getActiveTab();
+  } catch (e) {
+    setStatus('Could not read the active tab: ' + e.message, 'err');
+    return;
+  }
+
+  if (!tab.id || !/^https?:/i.test(tab.url || '')) {
+    setStatus('This page cannot be captured (only http/https pages).', 'err');
+    return;
+  }
+
+  try {
+    const results = await chrome.scripting.executeScript({
+      target: { tabId: tab.id },
+      // The extractor is defined in content.js; injecting the file makes
+      // extractGovArbitrageListing available in the page, then we call it.
+      files: ['content.js'],
+    });
+
+    // File injection returns undefined results, so run the call in a second
+    // pass now that the function is defined in the page.
+    const callResults = await chrome.scripting.executeScript({
+      target: { tabId: tab.id },
+      func: () =>
+        typeof extractGovArbitrageListing === 'function'
+          ? extractGovArbitrageListing()
+          : null,
+    });
+
+    const data = callResults && callResults[0] && callResults[0].result;
+    if (!data) {
+      setStatus('Extraction returned no data on this page.', 'err');
+      els.preview.textContent = '';
+      els.summary.textContent = '';
+      return;
+    }
+
+    captured = data;
+    renderPreview(data);
+    els.sendBtn.disabled = false;
+    setStatus('Captured. Review, then send.', 'ok');
+  } catch (e) {
+    setStatus('Capture failed: ' + (e && e.message ? e.message : String(e)), 'err');
+    els.preview.textContent = '';
+    els.summary.textContent = '';
+  }
+}
+
+function renderPreview(data) {
+  try {
+    els.preview.textContent = JSON.stringify(data, null, 2);
+  } catch (_e) {
+    els.preview.textContent = String(data);
+  }
+  const imgCount = Array.isArray(data.imageUrls) ? data.imageUrls.length : 0;
+  els.summary.textContent = `${data.source} · #${data.sourceAuctionId || '—'} · ${imgCount} img`;
+}
+
+// --------------------------------------------------------------------- send
+async function onSend() {
+  if (!captured) {
+    setStatus('Nothing captured yet.', 'err');
+    return;
+  }
+  const base = normalizeBase(els.apiBase.value);
+  saveApiBase();
+  const endpoint = base + IMPORT_PATH;
+
+  setStatus('Sending to ' + endpoint + ' …', 'info');
+  els.sendBtn.disabled = true;
+
+  try {
+    const resp = await fetch(endpoint, {
+      method: 'POST',
+      headers: { 'Content-Type': 'application/json' },
+      body: JSON.stringify(captured),
+    });
+
+    let payload = null;
+    const raw = await resp.text();
+    try {
+      payload = raw ? JSON.parse(raw) : null;
+    } catch (_e) {
+      payload = { raw };
+    }
+
+    if (resp.ok) {
+      const id = payload && (payload.id || (payload.listing && payload.listing.id));
+      setStatus('Sent ✓' + (id ? ' — listing ' + id : ' (HTTP ' + resp.status + ')'), 'ok');
+    } else {
+      const detail =
+        (payload && (payload.error || payload.message)) || 'HTTP ' + resp.status;
+      setStatus('Server rejected it: ' + detail, 'err');
+      els.sendBtn.disabled = false;
+    }
+  } catch (e) {
+    setStatus(
+      'Network error — is the app running at ' + base + '? (' + (e && e.message ? e.message : e) + ')',
+      'err'
+    );
+    els.sendBtn.disabled = false;
+  }
+}
+
+// -------------------------------------------------------------------- wiring
+els.captureBtn.addEventListener('click', onCapture);
+els.sendBtn.addEventListener('click', onSend);
+els.apiBase.addEventListener('change', saveApiBase);
+els.apiBase.addEventListener('blur', saveApiBase);
+
+loadApiBase();
diff --git a/govarbitrage/next.config.ts b/govarbitrage/next.config.ts
new file mode 100644
index 0000000..00e5e73
--- /dev/null
+++ b/govarbitrage/next.config.ts
@@ -0,0 +1,18 @@
+import type { NextConfig } from "next";
+import { fileURLToPath } from "node:url";
+
+const nextConfig: NextConfig = {
+  // Pin the workspace root (a stray parent lockfile confuses Turbopack's inference).
+  turbopack: { root: fileURLToPath(new URL(".", import.meta.url)) },
+  // Prisma 7 client is generated into node_modules; keep it external to the
+  // server bundle so the query engine binary resolves at runtime.
+  serverExternalPackages: ["@prisma/client", "bullmq", "ioredis"],
+  images: {
+    remotePatterns: [
+      { protocol: "https", hostname: "**" },
+      { protocol: "http", hostname: "**" },
+    ],
+  },
+};
+
+export default nextConfig;
diff --git a/govarbitrage/playwright.config.ts b/govarbitrage/playwright.config.ts
new file mode 100644
index 0000000..7976673
--- /dev/null
+++ b/govarbitrage/playwright.config.ts
@@ -0,0 +1,20 @@
+import { defineConfig, devices } from "@playwright/test";
+
+const PORT = process.env.E2E_PORT || "3010";
+const baseURL = `http://localhost:${PORT}`;
+
+export default defineConfig({
+  testDir: "./tests/e2e",
+  timeout: 30_000,
+  fullyParallel: true,
+  reporter: "list",
+  use: { baseURL, trace: "on-first-retry" },
+  projects: [{ name: "chromium", use: { ...devices["Desktop Chrome"] } }],
+  // Build + start the production server for E2E. Reuses an already-running one.
+  webServer: {
+    command: `npm run build && PORT=${PORT} npm run start`,
+    url: baseURL,
+    reuseExistingServer: !process.env.CI,
+    timeout: 180_000,
+  },
+});
diff --git a/govarbitrage/postcss.config.mjs b/govarbitrage/postcss.config.mjs
new file mode 100644
index 0000000..79bcf13
--- /dev/null
+++ b/govarbitrage/postcss.config.mjs
@@ -0,0 +1,8 @@
+/** @type {import('postcss-load-config').Config} */
+const config = {
+  plugins: {
+    "@tailwindcss/postcss": {},
+  },
+};
+
+export default config;
diff --git a/govarbitrage/prisma/seed.ts b/govarbitrage/prisma/seed.ts
new file mode 100644
index 0000000..c36bc00
--- /dev/null
+++ b/govarbitrage/prisma/seed.ts
@@ -0,0 +1,820 @@
+import { PrismaClient, type AuctionSource, type Condition } from "@prisma/client";
+import { runResearch } from "../src/pipeline/research";
+import { hashPassword } from "../src/lib/password";
+
+const prisma = new PrismaClient();
+
+const day = 86_400_000;
+const soon = (days: number) => new Date(Date.now() + days * day);
+const ago = (days: number) => new Date(Date.now() - days * day);
+const img = (seed: string) => [
+  `https://picsum.photos/seed/${seed}a/600/400`,
+  `https://picsum.photos/seed/${seed}b/600/400`,
+];
+
+interface Seed {
+  source: AuctionSource;
+  sourceAuctionId: string;
+  sourceUrl: string;
+  title: string;
+  description: string;
+  category: string;
+  manufacturer?: string;
+  model?: string;
+  condition: Condition;
+  quantity: number;
+  weightLbs: number;
+  dimensions?: string;
+  city: string;
+  state: string;
+  zip: string;
+  currentBid: number;
+  bidCount: number;
+  closesInDays: number;
+  terms: string;
+  seed: string;
+  anchor: { newRetail: number; demandScore: number };
+  comparables: {
+    kind: "SOLD" | "ACTIVE" | "RETAIL";
+    title: string;
+    price: number;
+    url?: string;
+    source?: string;
+    soldAt?: Date;
+  }[];
+}
+
+const LISTINGS: Seed[] = [
+  {
+    source: "GOVDEALS",
+    sourceAuctionId: "GD-448120",
+    sourceUrl: "https://www.govdeals.com/index.cfm?fa=Main.Item&itemid=448120",
+    title: "A-dec 511 Dental Patient Chair with Delivery System",
+    description:
+      "Surplus dental operatory chair, A-dec 511, powered recline, foot control, delivery unit. Removed from county health clinic. Functional, cosmetic wear.",
+    category: "Medical / Dental Equipment",
+    manufacturer: "A-dec",
+    model: "511",
+    condition: "USED_GOOD",
+    quantity: 1,
+    weightLbs: 320,
+    dimensions: '60 x 30 x 44 in',
+    city: "Sacramento",
+    state: "CA",
+    zip: "95814",
+    currentBid: 500,
+    bidCount: 3,
+    closesInDays: 2,
+    terms: "Pickup only. Payment within 5 business days. Buyer responsible for removal.",
+    seed: "adec511",
+    anchor: { newRetail: 20000, demandScore: 74 },
+    comparables: [
+      { kind: "RETAIL", title: "A-dec 511 new operatory package", price: 21500, source: "adec.com", url: "https://www.a-dec.com" },
+      { kind: "SOLD", title: "A-dec 511 chair + delivery (used)", price: 6800, source: "ebay", soldAt: ago(21) },
+      { kind: "SOLD", title: "A-dec 500 series used operatory", price: 5900, source: "dentalplanet", soldAt: ago(40) },
+      { kind: "ACTIVE", title: "A-dec 511 refurb listed", price: 9200, source: "ebay" },
+    ],
+  },
+  {
+    source: "PUBLIC_SURPLUS",
+    sourceAuctionId: "PS-99120",
+    sourceUrl: "https://www.publicsurplus.com/sms/auction/view?auc=99120",
+    title: "Lot of 12 Apple MacBook Pro 14 M-series Laptops",
+    description:
+      "12x MacBook Pro 14-inch, university IT refresh. Wiped, functional, minor scuffs. Chargers included for 9 of 12.",
+    category: "Computers / Laptops",
+    manufacturer: "Apple",
+    model: "MacBook Pro 14",
+    condition: "USED_GOOD",
+    quantity: 12,
+    weightLbs: 45,
+    dimensions: "pallet",
+    city: "Madison",
+    state: "WI",
+    zip: "53706",
+    currentBid: 3200,
+    bidCount: 11,
+    closesInDays: 1,
+    terms: "Shipping available at buyer expense. Payment via PayPal.",
+    seed: "mbp14",
+    anchor: { newRetail: 1999, demandScore: 88 },
+    comparables: [
+      { kind: "SOLD", title: "MacBook Pro 14 M-series used", price: 1150, source: "ebay", soldAt: ago(9) },
+      { kind: "ACTIVE", title: "MacBook Pro 14 refurb", price: 1399, source: "ebay" },
+      { kind: "RETAIL", title: "MacBook Pro 14 new", price: 1999, source: "apple.com" },
+    ],
+  },
+  {
+    source: "GSA_AUCTIONS",
+    sourceAuctionId: "GSA-7781",
+    sourceUrl: "https://gsaauctions.gov/auctions/7781",
+    title: "Herman Miller Aeron Chairs (Qty 25)",
+    description: "Federal office closure. 25 Aeron size B chairs, mixed condition, most fully functional.",
+    category: "Office Furniture",
+    manufacturer: "Herman Miller",
+    model: "Aeron B",
+    condition: "USED_GOOD",
+    quantity: 25,
+    weightLbs: 750,
+    city: "Washington",
+    state: "DC",
+    zip: "20405",
+    currentBid: 1500,
+    bidCount: 7,
+    closesInDays: 4,
+    terms: "Pickup only. Government surplus, no buyer premium (GSA).",
+    seed: "aeron",
+    anchor: { newRetail: 1395, demandScore: 68 },
+    comparables: [
+      { kind: "SOLD", title: "Aeron B used", price: 480, source: "ebay", soldAt: ago(12) },
+      { kind: "ACTIVE", title: "Aeron B refurb", price: 695, source: "facebook" },
+      { kind: "RETAIL", title: "Aeron new", price: 1395, source: "hermanmiller.com" },
+    ],
+  },
+  {
+    source: "STATE_SURPLUS",
+    sourceAuctionId: "TX-33112",
+    sourceUrl: "https://www.tfc.texas.gov/surplus/33112",
+    title: "Toyota 8FGCU25 Forklift 5000lb LP",
+    description: "State facilities forklift, 5000 lb capacity, LP, ~6800 hours. Runs and lifts.",
+    category: "Industrial / Material Handling",
+    manufacturer: "Toyota",
+    model: "8FGCU25",
+    condition: "USED_FAIR",
+    quantity: 1,
+    weightLbs: 8200,
+    city: "Austin",
+    state: "TX",
+    zip: "78701",
+    currentBid: 4200,
+    bidCount: 9,
+    closesInDays: 3,
+    terms: "Pickup only. Heavy equipment, buyer arranges transport.",
+    seed: "forklift",
+    anchor: { newRetail: 32000, demandScore: 66 },
+    comparables: [
+      { kind: "SOLD", title: "Toyota 8FGCU25 used", price: 14500, source: "machinerytrader", soldAt: ago(30) },
+      { kind: "ACTIVE", title: "Toyota 5k LP forklift", price: 16900, source: "ebay" },
+    ],
+  },
+  {
+    source: "UNIVERSITY_SURPLUS",
+    sourceAuctionId: "UW-5521",
+    sourceUrl: "https://surplus.uw.edu/5521",
+    title: "Nikon Eclipse Ci Laboratory Microscope",
+    description: "University lab surplus. Nikon Eclipse Ci upright microscope, objectives included. Functional.",
+    category: "Laboratory Equipment",
+    manufacturer: "Nikon",
+    model: "Eclipse Ci",
+    condition: "LIKE_NEW",
+    quantity: 1,
+    weightLbs: 35,
+    city: "Seattle",
+    state: "WA",
+    zip: "98195",
+    currentBid: 900,
+    bidCount: 4,
+    closesInDays: 5,
+    terms: "Shipping available. Pickup preferred.",
+    seed: "nikonci",
+    anchor: { newRetail: 9500, demandScore: 68 },
+    comparables: [
+      { kind: "SOLD", title: "Nikon Eclipse Ci used", price: 4200, source: "ebay", soldAt: ago(25) },
+      { kind: "ACTIVE", title: "Nikon Eclipse Ci-L", price: 5600, source: "labx" },
+      { kind: "RETAIL", title: "Nikon Eclipse Ci new config", price: 9500, source: "nikon" },
+    ],
+  },
+  {
+    source: "COUNTY",
+    sourceAuctionId: "CTY-2201",
+    sourceUrl: "https://county.example.gov/auctions/2201",
+    title: "Cisco Catalyst 9300 48-port Switches (Lot of 6)",
+    description: "County IT surplus. 6x Cisco Catalyst 9300 48-port. Wiped to ROMMON. Powered on before removal.",
+    category: "Networking",
+    manufacturer: "Cisco",
+    model: "Catalyst 9300-48",
+    condition: "USED_GOOD",
+    quantity: 6,
+    weightLbs: 60,
+    city: "San Diego",
+    state: "CA",
+    zip: "92101",
+    currentBid: 700,
+    bidCount: 5,
+    closesInDays: 2,
+    terms: "Shipping available at buyer expense.",
+    seed: "cat9300",
+    anchor: { newRetail: 4800, demandScore: 64 },
+    comparables: [
+      { kind: "SOLD", title: "Catalyst 9300-48 used", price: 850, source: "ebay", soldAt: ago(7) },
+      { kind: "ACTIVE", title: "C9300-48P refurb", price: 1200, source: "ebay" },
+    ],
+  },
+  {
+    source: "GOVDEALS",
+    sourceAuctionId: "GD-451900",
+    sourceUrl: "https://www.govdeals.com/index.cfm?fa=Main.Item&itemid=451900",
+    title: "DeWalt 20V MAX Tool Lot (Drills, Impact, Saws)",
+    description: "Municipal maintenance shop surplus. Mixed DeWalt 20V tools, some with batteries. Used, working.",
+    category: "Power Tools",
+    manufacturer: "DeWalt",
+    model: "20V MAX",
+    condition: "USED_GOOD",
+    quantity: 14,
+    weightLbs: 55,
+    city: "Columbus",
+    state: "OH",
+    zip: "43215",
+    currentBid: 220,
+    bidCount: 6,
+    closesInDays: 1,
+    terms: "Pickup or shipping. Payment within 5 days.",
+    seed: "dewalt",
+    anchor: { newRetail: 2400, demandScore: 58 },
+    comparables: [
+      { kind: "SOLD", title: "DeWalt 20V tool lot used", price: 900, source: "ebay", soldAt: ago(14) },
+      { kind: "ACTIVE", title: "DeWalt 20V combo kit", price: 1200, source: "ebay" },
+    ],
+  },
+  {
+    source: "PUBLIC_SURPLUS",
+    sourceAuctionId: "PS-99870",
+    sourceUrl: "https://www.publicsurplus.com/sms/auction/view?auc=99870",
+    title: "Epson Pro L1500 Laser Projector",
+    description: "Auditorium projector, Epson Pro L1500UH, ~2100 lamp hours (laser). Functional, includes lens.",
+    category: "AV / Projectors",
+    manufacturer: "Epson",
+    model: "Pro L1500UH",
+    condition: "USED_GOOD",
+    quantity: 1,
+    weightLbs: 55,
+    city: "Phoenix",
+    state: "AZ",
+    zip: "85003",
+    currentBid: 650,
+    bidCount: 3,
+    closesInDays: 6,
+    terms: "Shipping available.",
+    seed: "epsonl1500",
+    anchor: { newRetail: 11000, demandScore: 60 },
+    comparables: [
+      { kind: "SOLD", title: "Epson Pro L1500UH used", price: 3800, source: "ebay", soldAt: ago(35) },
+      { kind: "ACTIVE", title: "Epson L1505 laser", price: 5200, source: "ebay" },
+    ],
+  },
+  {
+    source: "GSA_AUCTIONS",
+    sourceAuctionId: "GSA-8890",
+    sourceUrl: "https://gsaauctions.gov/auctions/8890",
+    title: "DJI Matrice 300 RTK Drone with Payloads",
+    description: "Federal agency surplus UAV, DJI Matrice 300 RTK, 2 batteries, H20T payload. Flight-tested.",
+    category: "Drones / UAV",
+    manufacturer: "DJI",
+    model: "Matrice 300 RTK",
+    condition: "LIKE_NEW",
+    quantity: 1,
+    weightLbs: 40,
+    city: "Denver",
+    state: "CO",
+    zip: "80202",
+    currentBid: 3800,
+    bidCount: 8,
+    closesInDays: 3,
+    terms: "Pickup or shipping. No buyer premium (GSA).",
+    seed: "matrice300",
+    anchor: { newRetail: 21000, demandScore: 72 },
+    comparables: [
+      { kind: "SOLD", title: "DJI M300 RTK + H20T used", price: 9800, source: "ebay", soldAt: ago(18) },
+      { kind: "ACTIVE", title: "DJI Matrice 300 RTK", price: 12500, source: "ebay" },
+    ],
+  },
+  {
+    source: "STATE_SURPLUS",
+    sourceAuctionId: "CA-DGS-6612",
+    sourceUrl: "https://www.dgs.ca.gov/surplus/6612",
+    title: "Dell PowerEdge R750 Servers (Lot of 4)",
+    description: "State data center refresh. 4x Dell PowerEdge R750, dual Xeon, drives pulled. Powered before removal.",
+    category: "Servers",
+    manufacturer: "Dell",
+    model: "PowerEdge R750",
+    condition: "USED_GOOD",
+    quantity: 4,
+    weightLbs: 130,
+    city: "Sacramento",
+    state: "CA",
+    zip: "95814",
+    currentBid: 1100,
+    bidCount: 6,
+    closesInDays: 4,
+    terms: "Shipping available at buyer expense.",
+    seed: "r750",
+    anchor: { newRetail: 9000, demandScore: 62 },
+    comparables: [
+      { kind: "SOLD", title: "Dell R750 barebones used", price: 2200, source: "ebay", soldAt: ago(11) },
+      { kind: "ACTIVE", title: "PowerEdge R750 config", price: 3100, source: "ebay" },
+    ],
+  },
+  {
+    source: "COUNTY",
+    sourceAuctionId: "CTY-3040",
+    sourceUrl: "https://county.example.gov/auctions/3040",
+    title: "Steelcase Leap V2 Chairs (Lot of 30)",
+    description: "County office surplus, 30x Steelcase Leap V2. Used, functional, mixed upholstery wear.",
+    category: "Office Furniture",
+    manufacturer: "Steelcase",
+    model: "Leap V2",
+    condition: "USED_GOOD",
+    quantity: 30,
+    weightLbs: 900,
+    city: "Portland",
+    state: "OR",
+    zip: "97204",
+    currentBid: 900,
+    bidCount: 4,
+    closesInDays: 5,
+    terms: "Pickup only.",
+    seed: "leapv2",
+    anchor: { newRetail: 1100, demandScore: 60 },
+    comparables: [
+      { kind: "SOLD", title: "Steelcase Leap V2 used", price: 320, source: "ebay", soldAt: ago(8) },
+      { kind: "ACTIVE", title: "Leap V2 refurb", price: 520, source: "facebook" },
+    ],
+  },
+  {
+    source: "UNIVERSITY_SURPLUS",
+    sourceAuctionId: "MSU-7710",
+    sourceUrl: "https://surplus.msu.edu/7710",
+    title: "Thermo Scientific Sorvall Legend X1R Centrifuge",
+    description: "University lab surplus centrifuge, refrigerated, rotor included. Functional.",
+    category: "Laboratory Equipment",
+    manufacturer: "Thermo Scientific",
+    model: "Sorvall Legend X1R",
+    condition: "USED_GOOD",
+    quantity: 1,
+    weightLbs: 190,
+    city: "East Lansing",
+    state: "MI",
+    zip: "48824",
+    currentBid: 800,
+    bidCount: 2,
+    closesInDays: 7,
+    terms: "Pickup only, freight arrangeable.",
+    seed: "sorvall",
+    anchor: { newRetail: 14000, demandScore: 65 },
+    comparables: [
+      { kind: "SOLD", title: "Sorvall Legend X1R used", price: 3600, source: "labx", soldAt: ago(28) },
+      { kind: "ACTIVE", title: "Legend X1R refrigerated", price: 5400, source: "ebay" },
+    ],
+  },
+  {
+    source: "GOVDEALS",
+    sourceAuctionId: "GD-460210",
+    sourceUrl: "https://www.govdeals.com/index.cfm?fa=Main.Item&itemid=460210",
+    title: "2015 Ford F-250 Super Duty Utility Truck",
+    description: "Municipal fleet retirement. F-250 XL, 118k miles, utility bed. Runs, drives, service records.",
+    category: "Vehicles",
+    manufacturer: "Ford",
+    model: "F-250 Super Duty",
+    condition: "USED_FAIR",
+    quantity: 1,
+    weightLbs: 6800,
+    city: "Tucson",
+    state: "AZ",
+    zip: "85701",
+    currentBid: 8200,
+    bidCount: 14,
+    closesInDays: 2,
+    terms: "Pickup only. Title transfer. As-is.",
+    seed: "f250",
+    anchor: { newRetail: 42000, demandScore: 50 },
+    comparables: [
+      { kind: "SOLD", title: "2015 F-250 utility 118k", price: 16500, source: "kbb", soldAt: ago(20) },
+      { kind: "ACTIVE", title: "2015 F-250 XL", price: 18900, source: "autotrader" },
+    ],
+  },
+  {
+    source: "PUBLIC_SURPLUS",
+    sourceAuctionId: "PS-100240",
+    sourceUrl: "https://www.publicsurplus.com/sms/auction/view?auc=100240",
+    title: "Lot of 40 Dell Latitude 7440 Laptops",
+    description: "School district refresh, 40x Dell Latitude 7440, i5, wiped. Working, cosmetic wear.",
+    category: "Computers / Laptops",
+    manufacturer: "Dell",
+    model: "Latitude 7440",
+    condition: "USED_GOOD",
+    quantity: 40,
+    weightLbs: 120,
+    city: "Orlando",
+    state: "FL",
+    zip: "32801",
+    currentBid: 2600,
+    bidCount: 10,
+    closesInDays: 1,
+    terms: "Pickup or shipping.",
+    seed: "lat7440",
+    anchor: { newRetail: 1400, demandScore: 70 },
+    comparables: [
+      { kind: "SOLD", title: "Dell Latitude 7440 used", price: 430, source: "ebay", soldAt: ago(6) },
+      { kind: "ACTIVE", title: "Latitude 7440 i5", price: 560, source: "ebay" },
+    ],
+  },
+  {
+    source: "GSA_AUCTIONS",
+    sourceAuctionId: "GSA-9021",
+    sourceUrl: "https://gsaauctions.gov/auctions/9021",
+    title: "Midmark 625 Barrier-Free Exam Table",
+    description: "Federal clinic surplus exam table, Midmark 625, powered. Functional.",
+    category: "Medical / Dental Equipment",
+    manufacturer: "Midmark",
+    model: "625",
+    condition: "USED_GOOD",
+    quantity: 1,
+    weightLbs: 350,
+    city: "Kansas City",
+    state: "MO",
+    zip: "64106",
+    currentBid: 450,
+    bidCount: 2,
+    closesInDays: 6,
+    terms: "Pickup only.",
+    seed: "midmark625",
+    anchor: { newRetail: 9800, demandScore: 66 },
+    comparables: [
+      { kind: "SOLD", title: "Midmark 625 used", price: 2900, source: "ebay", soldAt: ago(22) },
+      { kind: "ACTIVE", title: "Midmark 625 barrier-free", price: 4200, source: "dotmed" },
+    ],
+  },
+  {
+    source: "STATE_SURPLUS",
+    sourceAuctionId: "TX-34550",
+    sourceUrl: "https://www.tfc.texas.gov/surplus/34550",
+    title: "Miller Big Blue 400 Pro Welder/Generator",
+    description: "State DOT surplus, Miller Big Blue 400 Pro diesel welder/generator. Runs.",
+    category: "Industrial / Welding",
+    manufacturer: "Miller",
+    model: "Big Blue 400 Pro",
+    condition: "USED_FAIR",
+    quantity: 1,
+    weightLbs: 1100,
+    city: "Houston",
+    state: "TX",
+    zip: "77002",
+    currentBid: 2400,
+    bidCount: 5,
+    closesInDays: 3,
+    terms: "Pickup only.",
+    seed: "bigblue",
+    anchor: { newRetail: 13500, demandScore: 62 },
+    comparables: [
+      { kind: "SOLD", title: "Miller Big Blue 400 used", price: 6200, source: "machinerytrader", soldAt: ago(33) },
+      { kind: "ACTIVE", title: "Big Blue 400 Pro", price: 7800, source: "ebay" },
+    ],
+  },
+  {
+    source: "COUNTY",
+    sourceAuctionId: "CTY-3311",
+    sourceUrl: "https://county.example.gov/auctions/3311",
+    title: "Zebra ZT610 Industrial Label Printers (Lot of 8)",
+    description: "County warehouse surplus, 8x Zebra ZT610 thermal printers. Working, some worn platens.",
+    category: "Industrial / Printing",
+    manufacturer: "Zebra",
+    model: "ZT610",
+    condition: "USED_GOOD",
+    quantity: 8,
+    weightLbs: 190,
+    city: "Charlotte",
+    state: "NC",
+    zip: "28202",
+    currentBid: 600,
+    bidCount: 3,
+    closesInDays: 4,
+    terms: "Shipping available.",
+    seed: "zt610",
+    anchor: { newRetail: 3200, demandScore: 58 },
+    comparables: [
+      { kind: "SOLD", title: "Zebra ZT610 used", price: 850, source: "ebay", soldAt: ago(16) },
+      { kind: "ACTIVE", title: "ZT610 203dpi", price: 1300, source: "ebay" },
+    ],
+  },
+  {
+    source: "UNIVERSITY_SURPLUS",
+    sourceAuctionId: "UCD-8802",
+    sourceUrl: "https://surplus.ucdavis.edu/8802",
+    title: "Agilent 1260 Infinity II HPLC System",
+    description: "University lab surplus HPLC, Agilent 1260 Infinity II, modules included. Powered before removal, untested flow.",
+    category: "Laboratory Equipment",
+    manufacturer: "Agilent",
+    model: "1260 Infinity II",
+    condition: "UNKNOWN",
+    quantity: 1,
+    weightLbs: 160,
+    city: "Davis",
+    state: "CA",
+    zip: "95616",
+    currentBid: 1500,
+    bidCount: 4,
+    closesInDays: 8,
+    terms: "Pickup only, freight arrangeable. Sold as-is, untested.",
+    seed: "hplc1260",
+    anchor: { newRetail: 45000, demandScore: 67 },
+    comparables: [
+      { kind: "SOLD", title: "Agilent 1260 Infinity II used", price: 9500, source: "labx", soldAt: ago(45) },
+      { kind: "ACTIVE", title: "1260 Infinity II stack", price: 14500, source: "ebay" },
+    ],
+  },
+  {
+    source: "GOVDEALS",
+    sourceAuctionId: "GD-462800",
+    sourceUrl: "https://www.govdeals.com/index.cfm?fa=Main.Item&itemid=462800",
+    title: "Genie GS-1930 Scissor Lift",
+    description: "City facilities surplus, Genie GS-1930 electric scissor lift, 19ft. Charges and lifts.",
+    category: "Industrial / Aerial",
+    manufacturer: "Genie",
+    model: "GS-1930",
+    condition: "USED_FAIR",
+    quantity: 1,
+    weightLbs: 2800,
+    city: "Reno",
+    state: "NV",
+    zip: "89501",
+    currentBid: 1900,
+    bidCount: 7,
+    closesInDays: 2,
+    terms: "Pickup only.",
+    seed: "gs1930",
+    anchor: { newRetail: 16000, demandScore: 63 },
+    comparables: [
+      { kind: "SOLD", title: "Genie GS-1930 used", price: 6500, source: "machinerytrader", soldAt: ago(27) },
+      { kind: "ACTIVE", title: "GS-1930 scissor lift", price: 8200, source: "ebay" },
+    ],
+  },
+  {
+    source: "PUBLIC_SURPLUS",
+    sourceAuctionId: "PS-101100",
+    sourceUrl: "https://www.publicsurplus.com/sms/auction/view?auc=101100",
+    title: "Lot of 20 iPad 9th Gen Tablets",
+    description: "School district surplus, 20x iPad 9th gen, wiped. Working, some screen scratches.",
+    category: "Tablets",
+    manufacturer: "Apple",
+    model: "iPad 9th Gen",
+    condition: "USED_GOOD",
+    quantity: 20,
+    weightLbs: 25,
+    city: "San Antonio",
+    state: "TX",
+    zip: "78205",
+    currentBid: 900,
+    bidCount: 9,
+    closesInDays: 1,
+    terms: "Shipping available.",
+    seed: "ipad9",
+    anchor: { newRetail: 329, demandScore: 80 },
+    comparables: [
+      { kind: "SOLD", title: "iPad 9th gen used", price: 165, source: "ebay", soldAt: ago(5) },
+      { kind: "ACTIVE", title: "iPad 9th gen wifi", price: 210, source: "ebay" },
+    ],
+  },
+  {
+    source: "GSA_AUCTIONS",
+    sourceAuctionId: "GSA-9330",
+    sourceUrl: "https://gsaauctions.gov/auctions/9330",
+    title: "FLIR T540 Thermal Imaging Camera",
+    description: "Federal surplus thermal camera, FLIR T540, 464x348, case + lenses. Calibrated in-service.",
+    category: "Test Equipment",
+    manufacturer: "FLIR",
+    model: "T540",
+    condition: "LIKE_NEW",
+    quantity: 1,
+    weightLbs: 12,
+    city: "Atlanta",
+    state: "GA",
+    zip: "30303",
+    currentBid: 2100,
+    bidCount: 6,
+    closesInDays: 3,
+    terms: "Shipping available. No buyer premium (GSA).",
+    seed: "flirt540",
+    anchor: { newRetail: 13000, demandScore: 69 },
+    comparables: [
+      { kind: "SOLD", title: "FLIR T540 used", price: 5200, source: "ebay", soldAt: ago(19) },
+      { kind: "ACTIVE", title: "FLIR T540 42deg", price: 6900, source: "ebay" },
+    ],
+  },
+  {
+    source: "COUNTY",
+    sourceAuctionId: "CTY-3520",
+    sourceUrl: "https://county.example.gov/auctions/3520",
+    title: "Toro Groundsmaster 4000-D Mower",
+    description: "County parks surplus, Toro Groundsmaster 4000-D diesel rotary mower, ~3400 hrs. Runs, mows.",
+    category: "Grounds / Mowers",
+    manufacturer: "Toro",
+    model: "Groundsmaster 4000-D",
+    condition: "USED_FAIR",
+    quantity: 1,
+    weightLbs: 3200,
+    city: "Fresno",
+    state: "CA",
+    zip: "93721",
+    currentBid: 3600,
+    bidCount: 5,
+    closesInDays: 5,
+    terms: "Pickup only.",
+    seed: "toro4000",
+    anchor: { newRetail: 68000, demandScore: 55 },
+    comparables: [
+      { kind: "SOLD", title: "Toro GM 4000-D used", price: 12500, source: "machinerytrader", soldAt: ago(38) },
+      { kind: "ACTIVE", title: "Groundsmaster 4000-D", price: 16900, source: "ebay" },
+    ],
+  },
+  {
+    source: "STATE_SURPLUS",
+    sourceAuctionId: "CA-DGS-6720",
+    sourceUrl: "https://www.dgs.ca.gov/surplus/6720",
+    title: "Lot of 50 Poly VVX 450 Desk Phones",
+    description: "State office surplus, 50x Poly VVX 450 IP phones with handsets. Working.",
+    category: "Telecom",
+    manufacturer: "Poly",
+    model: "VVX 450",
+    condition: "USED_GOOD",
+    quantity: 50,
+    weightLbs: 90,
+    city: "Sacramento",
+    state: "CA",
+    zip: "95814",
+    currentBid: 300,
+    bidCount: 2,
+    closesInDays: 6,
+    terms: "Shipping available.",
+    seed: "vvx450",
+    anchor: { newRetail: 190, demandScore: 52 },
+    comparables: [
+      { kind: "SOLD", title: "Poly VVX 450 used", price: 55, source: "ebay", soldAt: ago(13) },
+      { kind: "ACTIVE", title: "VVX 450 IP phone", price: 85, source: "ebay" },
+    ],
+  },
+  {
+    source: "UNIVERSITY_SURPLUS",
+    sourceAuctionId: "UW-5680",
+    sourceUrl: "https://surplus.uw.edu/5680",
+    title: "Canon EOS R5 + RF 24-70 f/2.8 Kit",
+    description: "University media dept surplus, Canon EOS R5 body + RF 24-70mm f/2.8. Low shutter, functional.",
+    category: "Cameras",
+    manufacturer: "Canon",
+    model: "EOS R5",
+    condition: "LIKE_NEW",
+    quantity: 1,
+    weightLbs: 8,
+    city: "Seattle",
+    state: "WA",
+    zip: "98195",
+    currentBid: 1800,
+    bidCount: 12,
+    closesInDays: 1,
+    terms: "Shipping available.",
+    seed: "eosr5",
+    anchor: { newRetail: 5700, demandScore: 76 },
+    comparables: [
+      { kind: "SOLD", title: "Canon R5 + 24-70 used", price: 3200, source: "ebay", soldAt: ago(4) },
+      { kind: "ACTIVE", title: "EOS R5 kit", price: 3800, source: "mpb" },
+    ],
+  },
+  {
+    source: "GOVDEALS",
+    sourceAuctionId: "GD-465550",
+    sourceUrl: "https://www.govdeals.com/index.cfm?fa=Main.Item&itemid=465550",
+    title: "Pallet of Assorted Networking Gear (Parts/Repair)",
+    description: "Mixed pallet: switches, APs, cabling, mostly untested. Sold for parts/repair.",
+    category: "Networking",
+    manufacturer: "Mixed",
+    model: "Assorted",
+    condition: "FOR_PARTS",
+    quantity: 1,
+    weightLbs: 210,
+    city: "Baltimore",
+    state: "MD",
+    zip: "21202",
+    currentBid: 120,
+    bidCount: 3,
+    closesInDays: 2,
+    terms: "Pickup only. Sold as-is for parts.",
+    seed: "partspallet",
+    anchor: { newRetail: 6000, demandScore: 48 },
+    comparables: [
+      { kind: "SOLD", title: "Networking parts pallet", price: 700, source: "ebay", soldAt: ago(15) },
+      { kind: "ACTIVE", title: "Mixed networking lot", price: 1100, source: "ebay" },
+    ],
+  },
+];
+
+async function main() {
+  console.log("Seeding GovArbitrage…");
+
+  // Reset (idempotent seed).
+  await prisma.listingEvent.deleteMany();
+  await prisma.score.deleteMany();
+  await prisma.comparable.deleteMany();
+  await prisma.research.deleteMany();
+  await prisma.costBreakdown.deleteMany();
+  await prisma.buyerLead.deleteMany();
+  await prisma.buyerInterestPage.deleteMany();
+  await prisma.auctionOutcome.deleteMany();
+  await prisma.note.deleteMany();
+  await prisma.listing.deleteMany();
+
+  // Primary admin. NEVER hardcode a real password in the repo — the actual
+  // credential is set out-of-band via `scripts/set-admin.ts` with an env-
+  // provided ADMIN_PASSWORD. Here we only ensure the admin ROW exists:
+  //  - if ADMIN_PASSWORD is provided at seed time, use it;
+  //  - otherwise, on CREATE use a throwaway random hash (login is unusable until
+  //    set-admin runs) and on UPDATE leave the existing password untouched so a
+  //    redeploy never clobbers a real credential set via set-admin.
+  const { randomBytes } = await import("node:crypto");
+  const adminEmail = process.env.ADMIN_EMAIL || "admin@agentabrams.com";
+  const provided = process.env.ADMIN_PASSWORD;
+  const createHash = hashPassword(provided || randomBytes(24).toString("hex"));
+  await prisma.user.upsert({
+    where: { email: adminEmail },
+    update: provided ? { passwordHash: hashPassword(provided), role: "ADMIN" } : { role: "ADMIN" },
+    create: { email: adminEmail, name: "Admin", role: "ADMIN", passwordHash: createHash },
+  });
+  console.log(
+    `Admin row ensured: ${adminEmail}` +
+      (provided ? " (password set from ADMIN_PASSWORD)" : " (set the password via scripts/set-admin.ts)"),
+  );
+
+  let done = 0;
+  for (const s of LISTINGS) {
+    const listing = await prisma.listing.create({
+      data: {
+        source: s.source,
+        sourceAuctionId: s.sourceAuctionId,
+        sourceUrl: s.sourceUrl,
+        title: s.title,
+        description: s.description,
+        category: s.category,
+        manufacturer: s.manufacturer,
+        model: s.model,
+        condition: s.condition,
+        quantity: s.quantity,
+        weightLbs: s.weightLbs,
+        dimensions: s.dimensions,
+        locationCity: s.city,
+        locationState: s.state,
+        locationZip: s.zip,
+        currentBid: s.currentBid,
+        bidCount: s.bidCount,
+        closingAt: soon(s.closesInDays),
+        imageUrls: img(s.seed),
+        auctionTerms: s.terms,
+        researchStatus: "PENDING",
+      },
+    });
+
+    await prisma.listingEvent.create({
+      data: { listingId: listing.id, type: "IMPORTED", message: `Imported from ${s.source}` },
+    });
+
+    await runResearch(listing.id, { anchor: s.anchor, comparables: s.comparables });
+    done++;
+    process.stdout.write(`  [${done}/${LISTINGS.length}] ${s.title.slice(0, 48)}\n`);
+  }
+
+  // A sample buyer-interest page + lead on the standout dental chair.
+  const dental = await prisma.listing.findFirst({ where: { sourceAuctionId: "GD-448120" } });
+  if (dental) {
+    await prisma.buyerInterestPage.create({
+      data: {
+        listingId: dental.id,
+        slug: "adec-511-dental-chair",
+        headline: "A-dec 511 Dental Chair — Contingent Interest",
+        published: true,
+        estDelivered: 9500,
+      },
+    });
+    await prisma.buyerLead.create({
+      data: {
+        listingId: dental.id,
+        name: "Dr. Chen",
+        email: "chen@example-dental.com",
+        offer: 8500,
+        contingent: true,
+        notes: "Interested if it wins; needs delivery to 94103.",
+      },
+    });
+    await prisma.auctionOutcome.create({
+      data: { listingId: dental.id, status: "WATCHING", maxBidSet: 3200 },
+    });
+  }
+
+  console.log(`Seeded ${done} listings with full research, costs, and scores.`);
+}
+
+main()
+  .then(async () => {
+    await prisma.$disconnect();
+  })
+  .catch(async (e) => {
+    console.error(e);
+    await prisma.$disconnect();
+    process.exit(1);
+  });
diff --git a/govarbitrage/scripts/hot-deal-alert.ts b/govarbitrage/scripts/hot-deal-alert.ts
new file mode 100644
index 0000000..4d059c6
--- /dev/null
+++ b/govarbitrage/scripts/hot-deal-alert.ts
@@ -0,0 +1,160 @@
+// 🔥 HOT DEAL alerter. Finds NEW listings that pass the hot-deal gate (buy-side
+// headroom + proven sell-side demand), emails them via George, and stamps
+// hotAlertedAt so each deal alerts exactly once. Meant to run right after every
+// import refresh. $0 (local DB + internal George send to Steve).
+//
+// Env (from .env): DATABASE_URL, GEORGE_URL, GEORGE_BASIC_AUTH,
+//   HOT_TO (default steve@designerwallcoverings.com), DIGEST_FROM_ACCOUNT,
+//   APP_URL, HOT_MAX (max deals per email, default 12),
+//   HOT_DRY_RUN=1 (print instead of send), plus the HOT_MIN_* thresholds.
+//
+// Run: npx tsx scripts/hot-deal-alert.ts
+
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { dirname, join } from "node:path";
+
+function loadEnv() {
+  const root = join(dirname(fileURLToPath(import.meta.url)), "..");
+  try {
+    const raw = readFileSync(join(root, ".env"), "utf8");
+    for (const line of raw.split("\n")) {
+      const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/i);
+      if (!m) continue;
+      const key = m[1];
+      let val = m[2].trim();
+      if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
+        val = val.slice(1, -1);
+      }
+      if (process.env[key] === undefined) process.env[key] = val;
+    }
+  } catch {
+    /* rely on ambient env */
+  }
+}
+loadEnv();
+
+const { prisma } = await import("../src/lib/db");
+const { findHotDeals } = await import("../src/lib/hot-deals");
+const { formatMoney, formatPercent, countdown } = await import("../src/lib/utils");
+
+const HOT_TO = process.env.HOT_TO || process.env.DIGEST_TO || "steve@designerwallcoverings.com";
+const FROM_ACCOUNT = process.env.DIGEST_FROM_ACCOUNT || "steve-office";
+const APP_URL = process.env.APP_URL || "http://localhost:3737";
+const HOT_MAX = Number(process.env.HOT_MAX || 12);
+
+function escapeHtml(s: string): string {
+  return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]!));
+}
+
+type Deal = Awaited<ReturnType<typeof findHotDeals>>[number];
+
+function renderDeal(d: Deal, i: number): string {
+  const l = d.listing;
+  const closes = l.closingAt ? countdown(l.closingAt) : "Make Offer (no deadline)";
+  const loc = [l.locationCity, l.locationState].filter(Boolean).join(", ") || "—";
+  // Honesty Gate: confidence badge + conservative figures (never the raw hype).
+  const confColor = d.confidence === "HIGH" ? "#166534" : d.confidence === "MEDIUM" ? "#b45309" : "#64748b";
+  const confBg = d.confidence === "HIGH" ? "#dcfce7" : d.confidence === "MEDIUM" ? "#fef3c7" : "#f1f5f9";
+  const confBadge = `<span style="background:${confBg};color:${confColor};padding:1px 6px;border-radius:4px;font-weight:700;font-size:11px">${d.confidence} confidence</span>`;
+  const roiText = `${formatPercent(d.roiConservative)}${d.roiCapped ? "+" : ""}`;
+  const sellProof =
+    d.buyerLeadCount > 0
+      ? `<span style="background:#dcfce7;color:#166534;padding:1px 6px;border-radius:4px;font-weight:700">${d.buyerLeadCount} buyer lead${d.buyerLeadCount > 1 ? "s" : ""}${d.buyerLeadTop ? ` · top offer ${formatMoney(d.buyerLeadTop)}` : ""}</span>`
+      : `<span style="color:#166534">${d.comparableCount} comps · ${formatPercent(d.probSale)} sell prob</span>`;
+  return `<div style="border:1px solid #fecaca;border-left:4px solid #ef4444;border-radius:8px;margin:0 0 14px;padding:12px">
+    <div style="font-size:15px;font-weight:700">🔥 #${i + 1} · <a href="${APP_URL}/listings/${l.id}" style="color:#b91c1c;text-decoration:none">${escapeHtml(l.title)}</a></div>
+    <div style="color:#64748b;font-size:12px;margin:2px 0 8px">
+      ${l.source.replace(/_/g, " ")} · #${escapeHtml(l.sourceAuctionId)} · ${loc} · closes ${closes}
+      ${l.sourceUrl ? ` · <a href="${escapeHtml(l.sourceUrl)}" style="color:#2563eb">source ↗</a>` : ""}
+    </div>
+    <table style="width:100%;border-collapse:collapse;font-size:13px">
+      <tr>
+        <td style="padding:3px 8px 3px 0;color:#166534;font-weight:700;width:50%">SELL side — can you flip it?</td>
+        <td style="padding:3px 8px 3px 0;color:#b91c1c;font-weight:700">BUY side — is there room?</td>
+      </tr>
+      <tr valign="top">
+        <td style="padding-right:8px">
+          <div>Est. resale (conservative): <b>${formatMoney(d.expectedSaleLow)}</b> ${confBadge}</div>
+          <div>Demand score: <b>${Math.round(d.demandScore)}/100</b></div>
+          <div>Sell proof: ${sellProof}</div>
+        </td>
+        <td>
+          <div>Current bid: <b>${formatMoney(d.currentBid)}</b></div>
+          <div>Recommended max bid: <b>${formatMoney(d.recMax)}</b> <span style="color:#64748b">(${formatPercent(d.headroom)} headroom)</span></div>
+          <div>Est. ROI: <b>${roiText}</b> <span style="color:#64748b">(low-side)</span></div>
+        </td>
+      </tr>
+    </table>
+    <div style="font-size:11px;color:#94a3b8;margin-top:6px">⚠︎ ${escapeHtml(d.disclaimer)}</div>
+    <div style="font-size:11px;color:#64748b;margin-top:3px">${escapeHtml(d.score.explanation)}</div>
+  </div>`;
+}
+
+function buildHtml(deals: Deal[]): string {
+  const when = new Date().toLocaleString("en-US", { dateStyle: "medium", timeStyle: "short" });
+  return `<div style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#0f172a;max-width:820px">
+    <h2 style="margin:0 0 4px;color:#b91c1c">🔥 ${deals.length} HOT DEAL${deals.length > 1 ? "S" : ""} — curated, verified-demand, early</h2>
+    <div style="color:#64748b;font-size:13px;margin-bottom:14px">${when} · each cleared the demand + bid-headroom gate; figures are conservative heuristic estimates (confidence-labeled) — always verify comps before bidding · act before close</div>
+    ${deals.map(renderDeal).join("")}
+    <p style="margin-top:8px"><a href="${APP_URL}" style="color:#2563eb">Open the dashboard →</a></p>
+    <p style="color:#94a3b8;font-size:11px">HOT gate: score≥${process.env.HOT_MIN_SCORE || 75}, arbitrage≥${process.env.HOT_MIN_ARBITRAGE || 70}, demand≥${process.env.HOT_MIN_DEMAND || 50}, net≥$${process.env.HOT_MIN_NET || 150}, ROI≥${Math.round(Number(process.env.HOT_MIN_ROI || 0.5) * 100)}%, bid headroom≥${Math.round(Number(process.env.HOT_MIN_HEADROOM_PCT || 0.15) * 100)}%. Verify comps before bidding.</p>
+  </div>`;
+}
+
+async function sendViaGeorge(subject: string, html: string) {
+  const base = process.env.GEORGE_URL;
+  const auth = process.env.GEORGE_BASIC_AUTH;
+  if (!base || !auth) throw new Error("GEORGE_URL / GEORGE_BASIC_AUTH not set");
+  const res = await fetch(`${base}/api/send`, {
+    method: "POST",
+    headers: { "Content-Type": "application/json", Authorization: `Basic ${auth}` },
+    body: JSON.stringify({ account: FROM_ACCOUNT, to: HOT_TO, subject, body: html }),
+  });
+  const text = await res.text();
+  if (!res.ok) throw new Error(`George send failed ${res.status}: ${text.slice(0, 300)}`);
+  return text;
+}
+
+async function main() {
+  const deals = await findHotDeals({ onlyUnalerted: true, limit: HOT_MAX });
+  if (deals.length === 0) {
+    console.log(`[${new Date().toISOString()}] No new hot deals. No alert sent.`);
+    await prisma.$disconnect();
+    return;
+  }
+  const subject = `🔥 ${deals.length} HOT DEAL${deals.length > 1 ? "S" : ""} on GovArbitrage`;
+  const html = buildHtml(deals);
+
+  if (process.env.HOT_DRY_RUN === "1") {
+    const { writeFileSync, mkdirSync } = await import("node:fs");
+    mkdirSync("logs", { recursive: true });
+    writeFileSync("logs/hot-deal-preview.html", html);
+    console.log(`[DRY RUN] to=${HOT_TO} subject="${subject}" (${deals.length} deals)`);
+    deals.forEach((d, i) =>
+      console.log(
+        `  ${i + 1}. ${d.listing.title.slice(0, 44)} — bid ${Math.round(d.currentBid)}→max ${Math.round(d.recMax)}, net ${Math.round(d.netProfit)}, ROI ${Math.round(d.roi * 100)}%`,
+      ),
+    );
+    console.log("Preview: logs/hot-deal-preview.html (NOT marking alerted in dry-run)");
+    await prisma.$disconnect();
+    return;
+  }
+
+  const result = await sendViaGeorge(subject, html);
+  // Stamp each alerted deal so it never re-alerts.
+  const ids = deals.map((d) => d.listing.id);
+  await prisma.listing.updateMany({
+    where: { id: { in: ids } },
+    data: { hotAlertedAt: new Date() },
+  });
+  console.log(`Sent "${subject}" to ${HOT_TO}; marked ${ids.length} alerted. ${result.slice(0, 120)}`);
+  await prisma.$disconnect();
+}
+
+main()
+  .then(() => process.exit(0))
+  .catch((e) => {
+    console.error(`[hot-deal-alert] ${new Date().toISOString()} ERROR:`, e.message);
+    process.exit(1);
+  });
diff --git a/govarbitrage/scripts/import-apify-govdeals.ts b/govarbitrage/scripts/import-apify-govdeals.ts
new file mode 100644
index 0000000..47ff3b0
--- /dev/null
+++ b/govarbitrage/scripts/import-apify-govdeals.ts
@@ -0,0 +1,30 @@
+// Import GovDeals listings scraped by the Apify actor into GovArbitrage.
+// Reads an already-finished dataset (free) — does NOT trigger a new paid run.
+//
+// Usage: APIFY_TOKEN=... npx tsx scripts/import-apify-govdeals.ts [datasetId]
+//   - datasetId arg or APIFY_GOVDEALS_DATASET env → read that dataset
+//   - otherwise → read the actor's most recent SUCCEEDED run
+
+import { fetchApifyDataset, fetchApifyLastRun } from "../src/importers/apify-govdeals";
+import { ingestMany } from "../src/importers/ingest";
+import { prisma } from "../src/lib/db";
+
+async function main() {
+  const datasetId = process.argv[2] || process.env.APIFY_GOVDEALS_DATASET;
+  const rows = datasetId ? await fetchApifyDataset(datasetId) : await fetchApifyLastRun();
+  console.log(`Fetched ${rows.length} GovDeals listings via Apify (${datasetId ? `dataset ${datasetId}` : "last run"}). Ingesting…`);
+
+  const results = await ingestMany(rows, { useAI: false });
+  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);
+  console.log(`Done: ${created} created, ${updated} updated, ${failed.length} failed.`);
+  failed.slice(0, 5).forEach((f) => console.log(`  ✗ ${f.title}: ${f.error}`));
+  await prisma.$disconnect();
+}
+
+main().catch(async (e) => {
+  console.error("[import-apify-govdeals] ERROR:", e.message);
+  await prisma.$disconnect();
+  process.exit(1);
+});
diff --git a/govarbitrage/scripts/import-govdeals-free.ts b/govarbitrage/scripts/import-govdeals-free.ts
new file mode 100644
index 0000000..2424ff9
--- /dev/null
+++ b/govarbitrage/scripts/import-govdeals-free.ts
@@ -0,0 +1,37 @@
+// FREE daily GovDeals refresh — hits GovDeals' own backend API directly ($0,
+// no Apify, no browser). Replaces the paid Apify path.
+//
+// Usage: npx tsx scripts/import-govdeals-free.ts [limit]   (default 120)
+
+import { fetchGovdealsFree } from "../src/importers/govdeals-free";
+import { ingestMany } from "../src/importers/ingest";
+import { prisma } from "../src/lib/db";
+
+// Liquidity Services marketplaces, all on the same free API. GovDeals gets the
+// most; the industrial siblings a smaller slice.
+const MARKETS: { biz: string; limit: number }[] = [
+  { biz: "GD", limit: Number(process.argv[2] || 120) },
+  { biz: "GI", limit: 60 },
+  { biz: "NI", limit: 60 },
+];
+
+async function main() {
+  let created = 0, updated = 0, failed = 0, fetched = 0;
+  for (const { biz, limit } of MARKETS) {
+    console.log(`[${new Date().toISOString()}] Fetching ${limit} newest ${biz} listings (FREE maestro API)…`);
+    const rows = await fetchGovdealsFree({ limit, businessId: biz });
+    fetched += rows.length;
+    const results = await ingestMany(rows, { useAI: false });
+    created += results.filter((r) => r.ok && r.created).length;
+    updated += results.filter((r) => r.ok && !r.created).length;
+    failed += results.filter((r) => !r.ok).length;
+  }
+  console.log(`Done: fetched ${fetched}, ${created} created, ${updated} updated, ${failed} failed. Cost: $0 (free API).`);
+  await prisma.$disconnect();
+}
+
+main().catch(async (e) => {
+  console.error("[import-govdeals-free] ERROR:", e.message);
+  await prisma.$disconnect();
+  process.exit(1);
+});
diff --git a/govarbitrage/scripts/import-govplanet-free.ts b/govarbitrage/scripts/import-govplanet-free.ts
new file mode 100644
index 0000000..8836a33
--- /dev/null
+++ b/govarbitrage/scripts/import-govplanet-free.ts
@@ -0,0 +1,29 @@
+// FREE GovPlanet import via embedded quickviews JSON in search HTML. $0, no browser.
+// Usage: npx tsx scripts/import-govplanet-free.ts [limit]
+
+import { fetchGovPlanetFree } from "../src/importers/govplanet-free";
+import { ingestMany } from "../src/importers/ingest";
+import { prisma } from "../src/lib/db";
+
+async function main() {
+  const limit = Number(process.argv[2] || 100);
+  console.log(
+    `[${new Date().toISOString()}] Fetching ${limit} GovPlanet items via quickviews JSON (FREE, USD)…`,
+  );
+  const rows = await fetchGovPlanetFree({ limit });
+  console.log(`Fetched ${rows.length}. Ingesting…`);
+  const results = await ingestMany(rows, { useAI: false });
+  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).length;
+  console.log(
+    `Done: ${created} created, ${updated} updated, ${failed} failed. Cost: $0.`,
+  );
+  await prisma.$disconnect();
+}
+
+main().catch(async (e) => {
+  console.error("[import-govplanet-free] ERROR:", e.message);
+  await prisma.$disconnect();
+  process.exit(1);
+});
diff --git a/govarbitrage/scripts/import-grays.ts b/govarbitrage/scripts/import-grays.ts
new file mode 100644
index 0000000..7096570
--- /dev/null
+++ b/govarbitrage/scripts/import-grays.ts
@@ -0,0 +1,25 @@
+// FREE GraysOnline (AU) import via Algolia. $0.
+// Usage: npx tsx scripts/import-grays.ts [limit]
+
+import { fetchGraysFree } from "../src/importers/grays-free";
+import { ingestMany } from "../src/importers/ingest";
+import { prisma } from "../src/lib/db";
+
+async function main() {
+  const limit = Number(process.argv[2] || 100);
+  console.log(`[${new Date().toISOString()}] Fetching ${limit} GraysOnline (AU) lots via Algolia (FREE)…`);
+  const rows = await fetchGraysFree({ limit });
+  console.log(`Fetched ${rows.length}. Ingesting…`);
+  const results = await ingestMany(rows, { useAI: false });
+  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).length;
+  console.log(`Done: ${created} created, ${updated} updated, ${failed} failed. Cost: $0.`);
+  await prisma.$disconnect();
+}
+
+main().catch(async (e) => {
+  console.error("[import-grays] ERROR:", e.message);
+  await prisma.$disconnect();
+  process.exit(1);
+});
diff --git a/govarbitrage/scripts/import-gsa.ts b/govarbitrage/scripts/import-gsa.ts
new file mode 100644
index 0000000..9148c2f
--- /dev/null
+++ b/govarbitrage/scripts/import-gsa.ts
@@ -0,0 +1,31 @@
+// Import live GSA Auctions API listings into GovArbitrage (real federal-surplus
+// data — no scraping). Runs the deterministic research/cost/scoring pipeline
+// (heuristic/local; the worker can AI-enrich later).
+//
+// Usage: npx tsx scripts/import-gsa.ts [limit]   (default 40)
+
+import { fetchGsaAuctions } from "../src/importers/gsa";
+import { ingestMany } from "../src/importers/ingest";
+import { prisma } from "../src/lib/db";
+
+async function main() {
+  const limit = Number(process.argv[2] || 40);
+  console.log(`Fetching live GSA auctions (limit ${limit})…`);
+  const rows = await fetchGsaAuctions({ limit, onlyOpen: false });
+  console.log(`Fetched ${rows.length} listings. Ingesting + scoring…`);
+
+  const results = await ingestMany(rows, { useAI: false });
+  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);
+
+  console.log(`Done: ${created} created, ${updated} updated, ${failed.length} failed.`);
+  if (failed.length) failed.slice(0, 5).forEach((f) => console.log(`  ✗ ${f.title}: ${f.error}`));
+  await prisma.$disconnect();
+}
+
+main().catch(async (e) => {
+  console.error("[import-gsa] ERROR:", e.message);
+  await prisma.$disconnect();
+  process.exit(1);
+});
diff --git a/govarbitrage/scripts/import-municibid-free.ts b/govarbitrage/scripts/import-municibid-free.ts
new file mode 100644
index 0000000..6e542bb
--- /dev/null
+++ b/govarbitrage/scripts/import-municibid-free.ts
@@ -0,0 +1,29 @@
+// FREE Municibid import via server-rendered /browse HTML parsing. $0, no browser.
+// Usage: npx tsx scripts/import-municibid-free.ts [limit]
+
+import { fetchMunicibidFree } from "../src/importers/municibid-free";
+import { ingestMany } from "../src/importers/ingest";
+import { prisma } from "../src/lib/db";
+
+async function main() {
+  const limit = Number(process.argv[2] || 100);
+  console.log(
+    `[${new Date().toISOString()}] Fetching ${limit} Municibid listings via /browse HTML (FREE)…`,
+  );
+  const rows = await fetchMunicibidFree({ limit });
+  console.log(`Fetched ${rows.length}. Ingesting…`);
+  const results = await ingestMany(rows, { useAI: false });
+  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).length;
+  console.log(
+    `Done: ${created} created, ${updated} updated, ${failed} failed. Cost: $0.`,
+  );
+  await prisma.$disconnect();
+}
+
+main().catch(async (e) => {
+  console.error("[import-municibid-free] ERROR:", e.message);
+  await prisma.$disconnect();
+  process.exit(1);
+});
diff --git a/govarbitrage/scripts/import-publicsurplus-free.ts b/govarbitrage/scripts/import-publicsurplus-free.ts
new file mode 100644
index 0000000..a9719ba
--- /dev/null
+++ b/govarbitrage/scripts/import-publicsurplus-free.ts
@@ -0,0 +1,29 @@
+// FREE Public Surplus import via server-rendered category HTML. $0, no browser.
+// Usage: npx tsx scripts/import-publicsurplus-free.ts [limit]
+
+import { fetchPublicSurplusFree } from "../src/importers/publicsurplus-free";
+import { ingestMany } from "../src/importers/ingest";
+import { prisma } from "../src/lib/db";
+
+async function main() {
+  const limit = Number(process.argv[2] || 100);
+  console.log(
+    `[${new Date().toISOString()}] Fetching ${limit} Public Surplus listings via /sms/browse/cataucs HTML (FREE)…`,
+  );
+  const rows = await fetchPublicSurplusFree({ limit });
+  console.log(`Fetched ${rows.length}. Ingesting…`);
+  const results = await ingestMany(rows, { useAI: false });
+  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).length;
+  console.log(
+    `Done: ${created} created, ${updated} updated, ${failed} failed. Cost: $0.`,
+  );
+  await prisma.$disconnect();
+}
+
+main().catch(async (e) => {
+  console.error("[import-publicsurplus-free] ERROR:", e.message);
+  await prisma.$disconnect();
+  process.exit(1);
+});
diff --git a/govarbitrage/scripts/liveness-sweep.ts b/govarbitrage/scripts/liveness-sweep.ts
new file mode 100644
index 0000000..1c9a933
--- /dev/null
+++ b/govarbitrage/scripts/liveness-sweep.ts
@@ -0,0 +1,141 @@
+// Fast dead-listing removal. Keeps the live grid honest so Steve never chases
+// an item that's already gone. Two tiers:
+//
+//   Tier 1 (instant, $0, no network): any ACTIVE listing whose closingAt is in
+//     the past → ENDED. This clears the vast majority the moment they close.
+//     Run this often (e.g. every 10 min) — it's a single indexed UPDATE.
+//
+//   Tier 2 (network verify, rate-limited): re-fetch a batch of ACTIVE listings
+//     that have NO closingAt (make-offer items, e.g. GovPlanet) or are stalest
+//     by livenessCheckedAt. Definitive "gone" signals (HTTP 404/410, or explicit
+//     "no longer available / has ended / not found" text) → REMOVED. Anything
+//     else is treated as still-live (never remove on a transient 5xx/timeout).
+//
+// Run: npx tsx scripts/liveness-sweep.ts [tier2Batch]   (default 40)
+
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { dirname, join } from "node:path";
+
+function loadEnv() {
+  const root = join(dirname(fileURLToPath(import.meta.url)), "..");
+  try {
+    const raw = readFileSync(join(root, ".env"), "utf8");
+    for (const line of raw.split("\n")) {
+      const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/i);
+      if (!m) continue;
+      const key = m[1];
+      let val = m[2].trim();
+      if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
+        val = val.slice(1, -1);
+      }
+      if (process.env[key] === undefined) process.env[key] = val;
+    }
+  } catch {
+    /* ambient env */
+  }
+}
+loadEnv();
+
+const { prisma } = await import("../src/lib/db");
+
+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";
+
+// Text markers that DEFINITIVELY mean the lot is gone. Kept conservative to
+// avoid false removals.
+const GONE_MARKERS = [
+  "no longer available",
+  "this auction has ended",
+  "auction has ended",
+  "listing has ended",
+  "item not found",
+  "page not found",
+  "the resource cannot be found",
+  "no longer active",
+  "has been removed",
+  "sale has closed",
+];
+
+async function tier1ExpireByClock(): Promise<number> {
+  const res = await prisma.listing.updateMany({
+    where: { listingStatus: "ACTIVE", closingAt: { lt: new Date() } },
+    data: { listingStatus: "ENDED", endedAt: new Date() },
+  });
+  return res.count;
+}
+
+interface VerifyResult { gone: boolean; live: boolean }
+
+async function verifyOne(url: string): Promise<VerifyResult> {
+  try {
+    const res = await fetch(url, {
+      headers: { "User-Agent": UA, Accept: "text/html,*/*" },
+      redirect: "follow",
+    });
+    if (res.status === 404 || res.status === 410) return { gone: true, live: false };
+    if (!res.ok) return { gone: false, live: false }; // transient — don't touch
+    const body = (await res.text()).toLowerCase();
+    if (GONE_MARKERS.some((m) => body.includes(m))) return { gone: true, live: false };
+    return { gone: false, live: true };
+  } catch {
+    return { gone: false, live: false }; // network error — don't remove
+  }
+}
+
+async function tier2VerifyBatch(batch: number): Promise<{ checked: number; removed: number }> {
+  // Prioritize items with no clock (can't expire via Tier 1) then stalest checks.
+  const candidates = await prisma.listing.findMany({
+    where: { listingStatus: "ACTIVE", sourceUrl: { not: null } },
+    orderBy: [{ closingAt: { sort: "asc", nulls: "first" } }, { livenessCheckedAt: { sort: "asc", nulls: "first" } }],
+    take: batch,
+    select: { id: true, sourceUrl: true, title: true },
+  });
+
+  let removed = 0;
+  for (const l of candidates) {
+    if (!l.sourceUrl) continue;
+    const { gone, live } = await verifyOne(l.sourceUrl);
+    const now = new Date();
+    if (gone) {
+      await prisma.listing.update({
+        where: { id: l.id },
+        data: { listingStatus: "REMOVED", endedAt: now, livenessCheckedAt: now },
+      });
+      await prisma.listingEvent
+        .create({ data: { listingId: l.id, type: "STATUS_CHANGE", message: "Removed: no longer available on source" } })
+        .catch(() => {});
+      removed++;
+    } else if (live) {
+      await prisma.listing.update({
+        where: { id: l.id },
+        data: { livenessCheckedAt: now, lastSeenAt: now },
+      });
+    } else {
+      // transient — just record we looked, don't change status
+      await prisma.listing.update({ where: { id: l.id }, data: { livenessCheckedAt: now } });
+    }
+    await new Promise((r) => setTimeout(r, 200)); // be polite
+  }
+  return { checked: candidates.length, removed };
+}
+
+async function main() {
+  const batch = Number(process.argv[2] || process.env.LIVENESS_TIER2_BATCH || 40);
+  const t0 = Date.now();
+  const expired = await tier1ExpireByClock();
+  const { checked, removed } = await tier2VerifyBatch(batch);
+  const ms = Date.now() - t0;
+  console.log(
+    `[${new Date().toISOString()}] liveness: tier1 expired=${expired}, tier2 checked=${checked} removed=${removed} (${ms}ms). Cost: $0.`,
+  );
+  await prisma.$disconnect();
+}
+
+main()
+  .then(() => process.exit(0))
+  .catch((e) => {
+    console.error(`[liveness-sweep] ${new Date().toISOString()} ERROR:`, e.message);
+    process.exit(1);
+  });
diff --git a/govarbitrage/scripts/probe-govdeals.ts b/govarbitrage/scripts/probe-govdeals.ts
new file mode 100644
index 0000000..43cb30b
--- /dev/null
+++ b/govarbitrage/scripts/probe-govdeals.ts
@@ -0,0 +1,61 @@
+// One-off: discover live GovDeals item URLs by rendering the Angular SPA and
+// harvesting item-detail links. Prints candidates so we can point scrapeUrl at
+// a real, currently-open listing.
+import { chromium } from "playwright";
+
+const ENTRY = process.argv[2] || "https://www.govdeals.com/";
+const HEADED = process.env.HEADED === "1";
+
+async function main() {
+  const browser = await chromium.launch({
+    headless: !HEADED,
+    args: ["--disable-blink-features=AutomationControlled"],
+  });
+  const context = await browser.newContext({
+    userAgent:
+      "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36",
+    viewport: { width: 1400, height: 900 },
+    locale: "en-US",
+    timezoneId: "America/Los_Angeles",
+  });
+  // Mask the headless/automation tells before any page script runs.
+  await context.addInitScript(() => {
+    Object.defineProperty(navigator, "webdriver", { get: () => undefined });
+    // @ts-expect-error test fingerprint props
+    window.chrome = { runtime: {} };
+    Object.defineProperty(navigator, "plugins", { get: () => [1, 2, 3] });
+    Object.defineProperty(navigator, "languages", { get: () => ["en-US", "en"] });
+  });
+  const page = await context.newPage();
+  try {
+    console.log("goto", ENTRY);
+    await page.goto(ENTRY, { waitUntil: "networkidle", timeout: 45_000 }).catch((e) => console.log("nav warn:", e.message));
+    await page.waitForTimeout(4000);
+    console.log("title:", await page.title());
+    console.log("url:", page.url());
+
+    const links = await page.evaluate(() => {
+      const hrefs = Array.from(document.querySelectorAll("a"))
+        .map((a) => (a as HTMLAnchorElement).href)
+        .filter((h) => /asset|\/item|itemid|\/listing/i.test(h));
+      return Array.from(new Set(hrefs)).slice(0, 15);
+    });
+    console.log(`\nfound ${links.length} item-like links:`);
+    links.forEach((l) => console.log("  ", l));
+
+    // Fallback: sample any anchor hrefs so we can see the URL shape.
+    if (links.length === 0) {
+      const sample = await page.evaluate(() =>
+        Array.from(new Set(Array.from(document.querySelectorAll("a")).map((a) => (a as HTMLAnchorElement).href)))
+          .filter((h) => h.includes("govdeals.com"))
+          .slice(0, 20),
+      );
+      console.log("\nno item links; sample of on-site anchors:");
+      sample.forEach((s) => console.log("  ", s));
+    }
+  } finally {
+    await browser.close();
+  }
+}
+
+main();
diff --git a/govarbitrage/scripts/seed-digest-snapshots.ts b/govarbitrage/scripts/seed-digest-snapshots.ts
new file mode 100644
index 0000000..5525b05
--- /dev/null
+++ b/govarbitrage/scripts/seed-digest-snapshots.ts
@@ -0,0 +1,117 @@
+// Seed the public /deals archive:
+//   1. A REAL capture for today's slot via captureDigestSnapshot() — freezes
+//      whatever currently clears the hot-deal gate (may be 0 deals; the
+//      archive renders that honestly).
+//   2. A clearly-marked FABRICATED edition for 2026-07-12 with realistic
+//      deals so the teaser rendering (confidence labels, ROI bands, withheld
+//      numbers) is actually exercised before launch. Every fabricated deal's
+//      disclaimer says SEED DATA. Delete before go-live:
+//        DELETE FROM "DigestSnapshot" WHERE date = '2026-07-12';
+//
+// Run: npx tsx scripts/seed-digest-snapshots.ts
+
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { dirname, join } from "node:path";
+import type { Prisma } from "@prisma/client";
+
+function loadEnv() {
+  const root = join(dirname(fileURLToPath(import.meta.url)), "..");
+  try {
+    const raw = readFileSync(join(root, ".env"), "utf8");
+    for (const line of raw.split("\n")) {
+      const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/i);
+      if (!m) continue;
+      const key = m[1];
+      let val = m[2].trim();
+      if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
+        val = val.slice(1, -1);
+      }
+      if (process.env[key] === undefined) process.env[key] = val;
+    }
+  } catch {
+    /* no .env — rely on ambient env */
+  }
+}
+loadEnv();
+
+const { prisma } = await import("../src/lib/db");
+const { captureDigestSnapshot, currentSlot } = await import("../src/lib/digest-snapshot");
+type SnapshotDeal = import("../src/lib/digest-snapshot").SnapshotDeal;
+
+const SEED_DATE = "2026-07-12";
+const SEED_DISCLAIMER = "SEED DATA — fabricated example for pre-launch rendering tests, not a real listing.";
+
+const seedDeals: SnapshotDeal[] = [
+  {
+    rank: 1,
+    title: "2018 John Deere 320G Skid Steer (1,240 hrs) — County Fleet Surplus",
+    source: "GOVDEALS",
+    locationCity: "Bakersfield",
+    locationState: "CA",
+    currentBid: 6200,
+    recMax: 14800,
+    expectedSaleLow: 21500,
+    confidence: "HIGH",
+    roiConservative: 0.58,
+    roiCapped: false,
+    disclaimer: SEED_DISCLAIMER,
+    closingAt: "2026-07-14T19:00:00.000Z",
+    sourceUrl: null,
+  },
+  {
+    rank: 2,
+    title: "Pallet of 24 Dell Latitude 5520 Laptops (i5/16GB, wiped) — School District IT Refresh",
+    source: "PUBLICSURPLUS",
+    locationCity: "Mesa",
+    locationState: "AZ",
+    currentBid: 1150,
+    recMax: 3400,
+    expectedSaleLow: 5300,
+    confidence: "MEDIUM",
+    roiConservative: 0.49,
+    roiCapped: false,
+    disclaimer: SEED_DISCLAIMER,
+    closingAt: "2026-07-13T22:30:00.000Z",
+    sourceUrl: null,
+  },
+  {
+    rank: 3,
+    title: "2015 Ford F-250 XL 4x4 Utility Truck w/ Liftgate — Municipal Water Dept.",
+    source: "MUNICIBID",
+    locationCity: "Spokane",
+    locationState: "WA",
+    currentBid: 3875,
+    recMax: 9200,
+    expectedSaleLow: 12800,
+    confidence: "LOW",
+    roiConservative: 0.41,
+    roiCapped: true,
+    disclaimer: SEED_DISCLAIMER,
+    closingAt: null, // make-offer, no deadline
+    sourceUrl: null,
+  },
+];
+
+async function main() {
+  const real = await captureDigestSnapshot(currentSlot());
+  console.log(`Real capture: ${real.date} ${real.slot} — ${real.dealCount} deal(s)`);
+
+  const dealsJson = seedDeals as unknown as Prisma.InputJsonValue;
+  const fabricated = await prisma.digestSnapshot.upsert({
+    where: { date_slot: { date: SEED_DATE, slot: "AM" } },
+    create: { date: SEED_DATE, slot: "AM", dealsJson, dealCount: seedDeals.length, isSeed: true },
+    update: { dealsJson, dealCount: seedDeals.length, isSeed: true },
+  });
+  console.log(`Fabricated seed edition: ${fabricated.date} ${fabricated.slot} — ${fabricated.dealCount} deal(s) (SEED DATA)`);
+}
+
+main()
+  .then(async () => {
+    await prisma.$disconnect();
+  })
+  .catch(async (e) => {
+    console.error(e);
+    await prisma.$disconnect();
+    process.exit(1);
+  });
diff --git a/govarbitrage/scripts/send-digest.ts b/govarbitrage/scripts/send-digest.ts
new file mode 100644
index 0000000..9c6993c
--- /dev/null
+++ b/govarbitrage/scripts/send-digest.ts
@@ -0,0 +1,256 @@
+// Twice-daily "Top 10 Opportunities" email digest.
+// Ranks still-open auctions by Overall Opportunity score and emails the top 10
+// via George (the DW Gmail HTTP agent). Scheduled by launchd at 06:00 and 17:00.
+//
+// Env (from the project .env, loaded below):
+//   DATABASE_URL         - Postgres (required)
+//   GEORGE_URL           - George base URL (required to send)
+//   GEORGE_BASIC_AUTH    - base64 "user:pass" for George Basic Auth (required)
+//   DIGEST_TO            - recipient (default steve@designerwallcoverings.com)
+//   DIGEST_FROM_ACCOUNT  - George account (default steve-office)
+//   APP_URL              - base URL for listing links (default http://localhost:3737)
+//   DIGEST_DRY_RUN=1     - print the email instead of sending
+//
+// Run: npx tsx scripts/send-digest.ts
+
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { dirname, join } from "node:path";
+
+// --- minimal .env loader (don't rely on Prisma's dotenv side-effect) ----------
+function loadEnv() {
+  const root = join(dirname(fileURLToPath(import.meta.url)), "..");
+  try {
+    const raw = readFileSync(join(root, ".env"), "utf8");
+    for (const line of raw.split("\n")) {
+      const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/i);
+      if (!m) continue;
+      const key = m[1];
+      let val = m[2].trim();
+      if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
+        val = val.slice(1, -1);
+      }
+      if (process.env[key] === undefined) process.env[key] = val;
+    }
+  } catch {
+    /* no .env — rely on ambient env */
+  }
+}
+loadEnv();
+
+const { topRankedOpportunities } = await import("../src/lib/digest-top");
+const { prisma } = await import("../src/lib/db");
+const { formatMoney, formatPercent, countdown } = await import("../src/lib/utils");
+
+const DIGEST_TO = process.env.DIGEST_TO || "steve@designerwallcoverings.com";
+const FROM_ACCOUNT = process.env.DIGEST_FROM_ACCOUNT || "steve-office";
+const APP_URL = process.env.APP_URL || "http://localhost:3737";
+
+const n = (d: unknown): number => (d == null ? 0 : Number(d));
+
+type Detail = NonNullable<Awaited<ReturnType<typeof fetchDetails>>>[number];
+
+/** Rank still-open auctions by Overall Opportunity and return the top-10 ids in order.
+ *  Shared with the skeptic agent (src/lib/digest-top.ts) so the adversarial gate
+ *  audits exactly what this digest emails. */
+async function topTenIds(): Promise<string[]> {
+  const rows = await topRankedOpportunities({ limit: 10, openOnly: true });
+  return rows.map((r) => r.id);
+}
+
+/**
+ * CRITICAL skeptic findings from the MOST RECENT completed skeptic run, keyed
+ * by listingId. Used to annotate (not drop) flagged items in the email.
+ */
+async function skepticCriticalFlags(ids: string[]): Promise<Map<string, string>> {
+  const flags = new Map<string, string>();
+  const lastRun = await prisma.agentRun.findFirst({
+    where: { agent: "skeptic", status: "OK" },
+    orderBy: { startedAt: "desc" },
+  });
+  if (!lastRun) return flags;
+  const findings = await prisma.agentFinding.findMany({
+    where: { runId: lastRun.id, severity: "CRITICAL", listingId: { in: ids } },
+    orderBy: { createdAt: "asc" },
+  });
+  for (const f of findings) {
+    if (f.listingId && !flags.has(f.listingId)) flags.set(f.listingId, f.title);
+  }
+  return flags;
+}
+
+/** Fetch full research + cost + scores for the given ids, preserving order. */
+async function fetchDetails(ids: string[]) {
+  const listings = await prisma.listing.findMany({
+    where: { id: { in: ids } },
+    include: { research: true, costBreakdown: true, scores: true },
+  });
+  const byId = new Map(listings.map((l) => [l.id, l]));
+  return ids.map((id) => byId.get(id)).filter((x): x is NonNullable<typeof x> => !!x);
+}
+
+function kv(label: string, value: string, strong = false): string {
+  return `<tr>
+    <td style="padding:2px 6px 2px 0;color:#64748b">${label}</td>
+    <td style="padding:2px 0;text-align:right;font-variant-numeric:tabular-nums${strong ? ";font-weight:700" : ""}">${value}</td>
+  </tr>`;
+}
+
+function valuationTable(r: NonNullable<Detail["research"]>): string {
+  return `<table style="border-collapse:collapse;font-size:12px;width:100%">
+    ${kv("New Retail", formatMoney(n(r.newRetail)))}
+    ${kv("New Replacement", formatMoney(n(r.newReplacement)))}
+    ${kv("Avg Retail", formatMoney(n(r.avgRetail)))}
+    ${kv("Used Low / Avg / High", `${formatMoney(n(r.usedLow))} / ${formatMoney(n(r.usedSoldPrice))} / ${formatMoney(n(r.usedHigh))}`)}
+    ${kv("Used Asking", formatMoney(n(r.usedAskingPrice)))}
+    ${kv("Wholesale", formatMoney(n(r.wholesaleValue)))}
+    ${kv("Liquidation", formatMoney(n(r.liquidationValue)))}
+    ${kv("Sell Today", formatMoney(n(r.sellTodayValue)))}
+    ${kv("7 / 30 / 90-Day", `${formatMoney(n(r.value7Day))} / ${formatMoney(n(r.value30Day))} / ${formatMoney(n(r.value90Day))}`)}
+    ${kv("Expected Sale", formatMoney(n(r.expectedSalePrice)), true)}
+    ${kv("Prob. of Sale", formatPercent(n(r.probabilityOfSale)))}
+    ${kv("Days to Sell", `${r.daysUntilSold ?? "—"}`)}
+    ${kv("Confidence", `${Math.round(n(r.confidenceScore))}/100`)}
+  </table>`;
+}
+
+function costTable(c: NonNullable<Detail["costBreakdown"]>): string {
+  return `<table style="border-collapse:collapse;font-size:12px;width:100%">
+    ${kv("Winning Bid", formatMoney(n(c.winningBid)))}
+    ${kv("Buyer Premium", formatMoney(n(c.buyerPremium)))}
+    ${kv("Sales Tax", formatMoney(n(c.salesTax)))}
+    ${kv("Shipping / Freight", `${formatMoney(n(c.shipping))} / ${formatMoney(n(c.freight))}`)}
+    ${kv("Insurance / Packing", `${formatMoney(n(c.insurance))} / ${formatMoney(n(c.packing))}`)}
+    ${kv("Pickup / Testing", `${formatMoney(n(c.pickupLabor))} / ${formatMoney(n(c.testing))}`)}
+    ${kv("Repairs / Cert.", `${formatMoney(n(c.repairs))} / ${formatMoney(n(c.certification))}`)}
+    ${kv("Mkt / Payment Fees", `${formatMoney(n(c.marketplaceFees))} / ${formatMoney(n(c.paymentFees))}`)}
+    ${kv("Storage / Photo / List", `${formatMoney(n(c.storage))} / ${formatMoney(n(c.photography))} / ${formatMoney(n(c.listingLabor))}`)}
+    ${kv("Total Investment", formatMoney(n(c.totalInvestment)), true)}
+    ${kv("Expected Returns", formatMoney(n(c.expectedReturns)))}
+    ${kv("Expected Net Profit", formatMoney(n(c.expectedNetProfit)), true)}
+    ${kv("ROI / Annualized", `${formatPercent(n(c.roi))} / ${formatPercent(n(c.annualizedReturn))}`, true)}
+    ${kv("Recommended Max Bid", formatMoney(n(c.recommendedMaxBid)), true)}
+  </table>`;
+}
+
+function scoreTable(scores: Detail["scores"]): string {
+  const overall = scores.find((s) => s.profile === "OVERALL_OPPORTUNITY") ?? scores[0];
+  if (!overall) return "";
+  const chip = (label: string, v: number) =>
+    kv(label, `${Math.round(v)}`);
+  return `<table style="border-collapse:collapse;font-size:12px;width:100%">
+    ${kv("Opportunity", `${Math.round(overall.value)}`, true)}
+    ${chip("Arbitrage", overall.arbitrage)}
+    ${chip("Demand", overall.demand)}
+    ${chip("Velocity", overall.velocity)}
+    ${chip("Logistics", overall.logistics)}
+    ${chip("Condition", overall.condition)}
+    ${chip("Competition", overall.competition)}
+    ${chip("Buyer", overall.buyer)}
+    ${kv("Risk", overall.risk)}
+    ${kv("Drop Ship", overall.dropShip)}
+  </table>
+  <div style="font-size:11px;color:#64748b;margin-top:6px">${escapeHtml(overall.explanation)}</div>`;
+}
+
+function renderItem(l: Detail, rank: number, skepticFlag?: string): string {
+  const closes = countdown(l.closingAt);
+  const net = formatMoney(n(l.costBreakdown?.expectedNetProfit));
+  const roi = formatPercent(n(l.costBreakdown?.roi));
+  const flagBanner = skepticFlag
+    ? `<div style="background:#fef2f2;border:1px solid #fecaca;border-radius:6px;color:#b91c1c;font-size:12px;padding:6px 8px;margin:8px 0 2px">
+        ⚠ flagged by Skeptic: ${escapeHtml(skepticFlag)} — verify before bidding
+      </div>`
+    : "";
+  return `<div style="border:1px solid #e2e8f0;border-radius:8px;margin:0 0 16px;padding:12px">
+    <div style="font-size:15px;font-weight:700">#${rank} · <a href="${APP_URL}/listings/${l.id}" style="color:#2563eb;text-decoration:none">${escapeHtml(l.title)}</a></div>
+    ${flagBanner}
+    <div style="color:#64748b;font-size:12px;margin:2px 0 10px">
+      ${l.source.replace(/_/g, " ")} · #${escapeHtml(l.sourceAuctionId)} · ${escapeHtml(l.category || "—")} ·
+      Qty ${l.quantity} · ${l.condition.replace(/_/g, " ")} ·
+      ${[l.locationCity, l.locationState].filter(Boolean).join(", ") || "—"} ·
+      closes ${closes} · <b style="color:#16a34a">${net} net</b> · <b>${roi} ROI</b>
+    </div>
+    <table style="width:100%;border-collapse:collapse"><tr valign="top">
+      <td style="width:34%;padding-right:12px">
+        <div style="font-weight:600;color:#334155;font-size:12px;margin-bottom:2px">Valuation</div>
+        ${l.research ? valuationTable(l.research) : "<i>pending</i>"}
+      </td>
+      <td style="width:36%;padding-right:12px">
+        <div style="font-weight:600;color:#334155;font-size:12px;margin-bottom:2px">Cost Breakdown &amp; Profit</div>
+        ${l.costBreakdown ? costTable(l.costBreakdown) : "<i>pending</i>"}
+      </td>
+      <td style="width:30%">
+        <div style="font-weight:600;color:#334155;font-size:12px;margin-bottom:2px">Scores</div>
+        ${scoreTable(l.scores)}
+      </td>
+    </tr></table>
+  </div>`;
+}
+
+function buildHtml(items: Detail[], skepticFlags: Map<string, string> = new Map()): string {
+  const when = new Date().toLocaleString("en-US", { dateStyle: "medium", timeStyle: "short" });
+  if (items.length === 0) {
+    return `<p>No open auction opportunities right now (${when}).</p>`;
+  }
+  return `<div style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#0f172a;max-width:900px">
+    <h2 style="margin:0 0 4px">GovArbitrage — Top ${items.length} Opportunities</h2>
+    <div style="color:#64748b;font-size:13px;margin-bottom:14px">${when} · ranked by Overall Opportunity · still-open auctions · detailed valuation, costs &amp; scores per item</div>
+    ${items.map((l, i) => renderItem(l, i + 1, skepticFlags.get(l.id))).join("")}
+    <p style="margin-top:8px"><a href="${APP_URL}" style="color:#2563eb">Open the dashboard →</a></p>
+    <p style="color:#94a3b8;font-size:11px">Recommended max bids back-solve to a 40% target ROI. Figures are estimates — verify comps before bidding.</p>
+  </div>`;
+}
+
+function escapeHtml(s: string): string {
+  return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]!));
+}
+
+async function sendViaGeorge(subject: string, html: string) {
+  const base = process.env.GEORGE_URL;
+  const auth = process.env.GEORGE_BASIC_AUTH;
+  if (!base || !auth) throw new Error("GEORGE_URL / GEORGE_BASIC_AUTH not set");
+  const res = await fetch(`${base}/api/send`, {
+    method: "POST",
+    headers: { "Content-Type": "application/json", Authorization: `Basic ${auth}` },
+    body: JSON.stringify({ account: FROM_ACCOUNT, to: DIGEST_TO, subject, body: html }),
+  });
+  const text = await res.text();
+  if (!res.ok) throw new Error(`George send failed ${res.status}: ${text.slice(0, 300)}`);
+  return text;
+}
+
+async function main() {
+  const ids = await topTenIds();
+  const items = await fetchDetails(ids);
+  const skepticFlags = await skepticCriticalFlags(ids);
+  const hour = new Date().getHours();
+  const slot = hour < 12 ? "Morning" : "Evening";
+  const subject = `GovArbitrage — Top ${items.length} Opportunities (${slot})`;
+  const html = buildHtml(items, skepticFlags);
+
+  if (process.env.DIGEST_DRY_RUN === "1") {
+    const { writeFileSync } = await import("node:fs");
+    writeFileSync("logs/digest-preview.html", html);
+    console.log(`[DRY RUN] to=${DIGEST_TO} subject="${subject}" (${items.length} items, ${html.length} bytes)`);
+    console.log(`Preview written to logs/digest-preview.html`);
+    items.forEach((l, i) =>
+      console.log(
+        `  ${i + 1}. ${l.title.slice(0, 40)} — net ${Math.round(n(l.costBreakdown?.expectedNetProfit))}, ROI ${Math.round(n(l.costBreakdown?.roi) * 100)}%`,
+      ),
+    );
+    await prisma.$disconnect();
+    return;
+  }
+
+  const result = await sendViaGeorge(subject, html);
+  console.log(`Sent "${subject}" to ${DIGEST_TO}: ${result.slice(0, 160)}`);
+  await prisma.$disconnect();
+}
+
+main()
+  .then(() => process.exit(0))
+  .catch((e) => {
+    console.error(`[digest] ${new Date().toISOString()} ERROR:`, e.message);
+    process.exit(1);
+  });
diff --git a/govarbitrage/scripts/send-newsletter-digest.ts b/govarbitrage/scripts/send-newsletter-digest.ts
new file mode 100644
index 0000000..91f74c2
--- /dev/null
+++ b/govarbitrage/scripts/send-newsletter-digest.ts
@@ -0,0 +1,12 @@
+import "dotenv/config";
+import { sendDigestToSubscribers } from "@/lib/send-digest";
+
+// CLI entry for the newsletter digest fan-out. All gating lives in
+// sendNewsletterEmail(); this just runs the send and reports.
+(async () => {
+  const res = await sendDigestToSubscribers();
+  console.log(
+    `[newsletter-digest] mode=${res.mode} subscribers=${res.subscribers} sent=${res.sent} errors=${res.errors} deals=${res.dealCount}`
+  );
+  process.exit(res.errors > 0 ? 1 : 0);
+})();
diff --git a/govarbitrage/scripts/set-admin.ts b/govarbitrage/scripts/set-admin.ts
new file mode 100644
index 0000000..8b09170
--- /dev/null
+++ b/govarbitrage/scripts/set-admin.ts
@@ -0,0 +1,42 @@
+// Set/reset the admin login credential without running the full seed.
+// Usage:
+//   ADMIN_EMAIL=admin@agentabrams.com ADMIN_PASSWORD='...' npx tsx scripts/set-admin.ts
+//   (omit ADMIN_PASSWORD to GENERATE a strong random one and print it once)
+// Never hardcodes a real password.
+
+import { randomBytes } from "node:crypto";
+import { prisma } from "../src/lib/db";
+import { hashPassword } from "../src/lib/password";
+
+// Readable strong password: 3 base32-ish blocks, ~120 bits.
+function generatePassword(): string {
+  const alphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; // no ambiguous 0/O/1/I
+  const bytes = randomBytes(18);
+  const chars = Array.from(bytes, (b) => alphabet[b % alphabet.length]);
+  return `${chars.slice(0, 6).join("")}-${chars.slice(6, 12).join("")}-${chars.slice(12, 18).join("")}`;
+}
+
+async function main() {
+  const email = process.env.ADMIN_EMAIL || process.argv[2] || "admin@agentabrams.com";
+  const provided = process.env.ADMIN_PASSWORD || process.argv[3];
+  const generated = !provided;
+  const password = provided || generatePassword();
+  const hash = hashPassword(password);
+  const user = await prisma.user.upsert({
+    where: { email },
+    update: { passwordHash: hash, role: "ADMIN" },
+    create: { email, name: "Admin", role: "ADMIN", passwordHash: hash },
+  });
+  console.log(`Admin credential set: ${user.email} (role ${user.role}).`);
+  if (generated) {
+    // Printed ONCE so the operator can capture it; never stored to disk/git.
+    console.log(`GENERATED_PASSWORD=${password}`);
+  }
+  await prisma.$disconnect();
+}
+
+main().catch(async (e) => {
+  console.error("[set-admin] ERROR:", e.message);
+  await prisma.$disconnect();
+  process.exit(1);
+});
diff --git a/govarbitrage/src/agents/appraiser.ts b/govarbitrage/src/agents/appraiser.ts
new file mode 100644
index 0000000..3a0544e
--- /dev/null
+++ b/govarbitrage/src/agents/appraiser.ts
@@ -0,0 +1,85 @@
+import { prisma } from "@/lib/db";
+import { runAgent, type AgentRunResult } from "./framework";
+import { runResearch } from "@/pipeline/research";
+
+// The Appraiser re-researches poorly-identified listings WITH the local AI
+// (Ollama qwen3:14b) so valuations become item-specific instead of the
+// keyword-heuristic placeholder (the "$900 retail / 214% ROI for everything"
+// artifact). Sequential (concurrency 1) — one local LLM, ~30-60s per listing.
+
+const num = (d: unknown): number | null => (d == null ? null : Number(d));
+
+/**
+ * Pick up to `limit` ACTIVE, still-open listings ordered by soonest close
+ * where identification is weak (manufacturer/model missing, or the last
+ * research pass was heuristic-only / low confidence), and re-run research
+ * with useAI + writeIdentity.
+ */
+export async function runAppraiser(limit = 25): Promise<AgentRunResult> {
+  return runAgent("appraiser", async (ctx) => {
+    const now = new Date();
+    const candidates = await prisma.listing.findMany({
+      where: {
+        listingStatus: "ACTIVE",
+        closingAt: { gt: now },
+        OR: [
+          { manufacturer: null },
+          { model: null },
+          { identifiedBy: null },
+          { identifiedBy: "heuristic" },
+          { research: { is: { confidenceScore: { lt: 40 } } } },
+        ],
+      },
+      orderBy: { closingAt: "asc" },
+      take: limit,
+      include: { research: true },
+    });
+
+    let aiIdentified = 0;
+    let fallbacks = 0;
+
+    for (const l of candidates) {
+      const beforeRetail = num(l.research?.newRetail);
+      const beforeConfidence = num(l.research?.confidenceScore);
+
+      const result = await runResearch(l.id, { useAI: true, writeIdentity: true });
+      ctx.count();
+
+      const after = await prisma.research.findUnique({ where: { listingId: l.id } });
+      const afterRetail = num(after?.newRetail);
+      const afterConfidence = num(after?.confidenceScore);
+
+      const fmt = (v: number | null, pct = false) =>
+        v == null ? "—" : pct ? `${Math.round(v)}/100` : `$${Math.round(v)}`;
+      const delta =
+        `newRetail ${fmt(beforeRetail)} → ${fmt(afterRetail)}, ` +
+        `confidence ${fmt(beforeConfidence, true)} → ${fmt(afterConfidence, true)}`;
+
+      if (result.identifiedBy === "heuristic") {
+        fallbacks++;
+        await ctx.finding({
+          kind: "AI_FALLBACK",
+          severity: "WARN",
+          listingId: l.id,
+          listingTitle: l.title,
+          title: `Ollama returned nothing — heuristic fallback kept for "${l.title.slice(0, 80)}"`,
+          detail: `${delta}. Valuation remains keyword-heuristic; re-run when the local model is reachable.`,
+        });
+      } else {
+        aiIdentified++;
+        await ctx.finding({
+          kind: "REAPPRAISED",
+          severity: "INFO",
+          listingId: l.id,
+          listingTitle: l.title,
+          title: `Re-appraised via ${result.identifiedBy}: ${delta}`,
+          detail:
+            `${l.title}. Net $${Math.round(result.expectedNetProfit)}, ` +
+            `ROI ${Math.round(result.roi * 100)}%, overall score ${Math.round(result.overallScore)}.`,
+        });
+      }
+    }
+
+    return `${candidates.length} listings appraised — ${aiIdentified} AI-identified, ${fallbacks} heuristic fallbacks`;
+  });
+}
diff --git a/govarbitrage/src/agents/framework.ts b/govarbitrage/src/agents/framework.ts
new file mode 100644
index 0000000..cd6884f
--- /dev/null
+++ b/govarbitrage/src/agents/framework.ts
@@ -0,0 +1,91 @@
+import { prisma } from "@/lib/db";
+
+// Minimal, dependency-free agent runner. Every agent execution is recorded as
+// an AgentRun; observations stream in as AgentFindings via ctx.finding().
+// Errors are caught → status FAILED with the message in summary, so a crashed
+// agent still leaves an auditable row instead of vanishing.
+
+export type Severity = "INFO" | "WARN" | "CRITICAL";
+
+export interface FindingInput {
+  kind: string;
+  severity: Severity;
+  title: string;
+  detail?: string;
+  listingId?: string;
+  listingTitle?: string;
+}
+
+export interface AgentContext {
+  runId: string;
+  agent: string;
+  /** Persist one finding against this run. */
+  finding(f: FindingInput): Promise<void>;
+  /** Increment the run's itemsProcessed counter (default +1). */
+  count(n?: number): void;
+}
+
+export interface AgentRunResult {
+  runId: string;
+  agent: string;
+  status: "OK" | "FAILED";
+  itemsProcessed: number;
+  summary: string | null;
+  findings: number;
+}
+
+/**
+ * Execute `fn` inside a tracked AgentRun. The function may return a summary
+ * string; on throw the run is marked FAILED with the error message.
+ */
+export async function runAgent(
+  name: string,
+  fn: (ctx: AgentContext) => Promise<string | void>,
+): Promise<AgentRunResult> {
+  const run = await prisma.agentRun.create({
+    data: { agent: name, status: "RUNNING" },
+  });
+
+  let items = 0;
+  let findings = 0;
+
+  const ctx: AgentContext = {
+    runId: run.id,
+    agent: name,
+    async finding(f) {
+      findings++;
+      await prisma.agentFinding.create({
+        data: {
+          runId: run.id,
+          agent: name,
+          kind: f.kind,
+          severity: f.severity,
+          title: f.title,
+          detail: f.detail,
+          listingId: f.listingId,
+          listingTitle: f.listingTitle,
+        },
+      });
+    },
+    count(n = 1) {
+      items += n;
+    },
+  };
+
+  try {
+    const summary = (await fn(ctx)) ?? null;
+    await prisma.agentRun.update({
+      where: { id: run.id },
+      data: { status: "OK", finishedAt: new Date(), itemsProcessed: items, summary },
+    });
+    return { runId: run.id, agent: name, status: "OK", itemsProcessed: items, summary, findings };
+  } catch (e) {
+    const message = e instanceof Error ? e.message : String(e);
+    const summary = `FAILED: ${message}`.slice(0, 1000);
+    await prisma.agentRun.update({
+      where: { id: run.id },
+      data: { status: "FAILED", finishedAt: new Date(), itemsProcessed: items, summary },
+    });
+    return { runId: run.id, agent: name, status: "FAILED", itemsProcessed: items, summary, findings };
+  }
+}
diff --git a/govarbitrage/src/agents/run.ts b/govarbitrage/src/agents/run.ts
new file mode 100644
index 0000000..9ebca32
--- /dev/null
+++ b/govarbitrage/src/agents/run.ts
@@ -0,0 +1,80 @@
+// CLI runner for the agent layer.
+//   npm run agents -- appraiser [N]   re-appraise up to N listings via local AI (default 25)
+//   npm run agents -- skeptic [N]     adversarial audit of the top N ranked (default 30)
+//   npm run agents -- scout           hot deals closing within 48h
+//   npm run agents -- all             skeptic → scout → appraiser (small batch)
+
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { dirname, join } from "node:path";
+import type { AgentRunResult } from "./framework";
+
+// Minimal .env loader (same pattern as scripts/send-digest.ts — don't rely on
+// Prisma's dotenv side-effect when invoked via tsx).
+function loadEnv() {
+  const root = join(dirname(fileURLToPath(import.meta.url)), "..", "..");
+  try {
+    const raw = readFileSync(join(root, ".env"), "utf8");
+    for (const line of raw.split("\n")) {
+      const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/i);
+      if (!m) continue;
+      const key = m[1];
+      let val = m[2].trim();
+      if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
+        val = val.slice(1, -1);
+      }
+      if (process.env[key] === undefined) process.env[key] = val;
+    }
+  } catch {
+    /* no .env — rely on ambient env */
+  }
+}
+loadEnv();
+
+const { prisma } = await import("../lib/db");
+const { runAppraiser } = await import("./appraiser");
+const { runSkeptic } = await import("./skeptic");
+const { runScout } = await import("./scout");
+
+function report(r: AgentRunResult) {
+  console.log(
+    `[${r.agent}] ${r.status} — run ${r.runId} · ${r.itemsProcessed} items · ${r.findings} findings\n  ${r.summary ?? ""}`,
+  );
+}
+
+async function main() {
+  const [agent, nArg] = process.argv.slice(2);
+  const n = nArg != null ? Number(nArg) : undefined;
+  if (nArg != null && (!Number.isFinite(n) || n! <= 0)) {
+    throw new Error(`Invalid count "${nArg}" — expected a positive number.`);
+  }
+
+  switch ((agent || "").toLowerCase()) {
+    case "appraiser":
+      report(await runAppraiser(n ?? 25));
+      break;
+    case "skeptic":
+      report(await runSkeptic(n ?? 30));
+      break;
+    case "scout":
+      report(await runScout());
+      break;
+    case "all":
+      report(await runSkeptic(n ?? 30));
+      report(await runScout());
+      report(await runAppraiser(n ?? 25));
+      break;
+    default:
+      console.log("Usage: npm run agents -- <appraiser [N] | skeptic [N] | scout | all>");
+      process.exitCode = 2;
+  }
+}
+
+main()
+  .catch((e) => {
+    console.error("[agents] ERROR:", e instanceof Error ? e.message : e);
+    process.exitCode = 1;
+  })
+  .finally(async () => {
+    await prisma.$disconnect();
+  });
diff --git a/govarbitrage/src/agents/scout.ts b/govarbitrage/src/agents/scout.ts
new file mode 100644
index 0000000..5ddd45f
--- /dev/null
+++ b/govarbitrage/src/agents/scout.ts
@@ -0,0 +1,49 @@
+import { prisma } from "@/lib/db";
+import { runAgent, type AgentRunResult } from "./framework";
+
+// The Scout watches the clock: open listings closing within 48h that carry a
+// strong Overall Opportunity score (>= minScore). Each hit is an INFO finding;
+// anything closing within 6h escalates to CRITICAL.
+
+const HOUR = 3_600_000;
+
+export async function runScout(minScore = 75): Promise<AgentRunResult> {
+  return runAgent("scout", async (ctx) => {
+    const now = Date.now();
+    const listings = await prisma.listing.findMany({
+      where: {
+        listingStatus: "ACTIVE",
+        closingAt: { gt: new Date(now), lte: new Date(now + 48 * HOUR) },
+        scores: { some: { profile: "OVERALL_OPPORTUNITY", value: { gte: minScore } } },
+      },
+      include: {
+        scores: { where: { profile: "OVERALL_OPPORTUNITY" } },
+        costBreakdown: true,
+      },
+      orderBy: { closingAt: "asc" },
+    });
+
+    let critical = 0;
+    for (const l of listings) {
+      ctx.count();
+      const score = l.scores[0]?.value ?? 0;
+      const hoursLeft = (new Date(l.closingAt!).getTime() - now) / HOUR;
+      const isCritical = hoursLeft <= 6;
+      if (isCritical) critical++;
+      const net = l.costBreakdown ? Math.round(Number(l.costBreakdown.expectedNetProfit)) : null;
+      await ctx.finding({
+        kind: "HOT_CLOSING",
+        severity: isCritical ? "CRITICAL" : "INFO",
+        listingId: l.id,
+        listingTitle: l.title,
+        title: `Closing in ${hoursLeft < 1 ? "<1h" : Math.round(hoursLeft) + "h"} @ score ${Math.round(score)}: "${l.title.slice(0, 70)}"`,
+        detail:
+          `Overall ${Math.round(score)}/100, closes ${l.closingAt?.toISOString()}` +
+          (net != null ? `, est. net $${net}` : "") +
+          `. ${isCritical ? "Under 6 hours — act now or drop it." : "Within 48h window."}`,
+      });
+    }
+
+    return `${listings.length} hot deals closing within 48h (${critical} within 6h) at score >= ${minScore}`;
+  });
+}
diff --git a/govarbitrage/src/agents/skeptic-rules.test.ts b/govarbitrage/src/agents/skeptic-rules.test.ts
new file mode 100644
index 0000000..5777766
--- /dev/null
+++ b/govarbitrage/src/agents/skeptic-rules.test.ts
@@ -0,0 +1,82 @@
+import { describe, expect, it } from "vitest";
+import { findCloneGroups, isClosingStale, isLowConfidence, type ValuationTuple } from "./skeptic-rules";
+
+const t = (
+  listingId: string,
+  newRetail: number | null,
+  expectedNetProfit: number | null,
+  roi: number | null,
+): ValuationTuple => ({ listingId, title: listingId, newRetail, expectedNetProfit, roi });
+
+describe("findCloneGroups", () => {
+  it("flags >=3 listings sharing the exact (newRetail, net, roi) tuple", () => {
+    const items = [
+      t("a", 900, 189, 2.14),
+      t("b", 900, 189, 2.14),
+      t("c", 900, 189, 2.14),
+      t("d", 450, 80, 0.6),
+    ];
+    const groups = findCloneGroups(items);
+    expect(groups).toHaveLength(1);
+    expect(groups[0].members.map((m) => m.listingId)).toEqual(["a", "b", "c"]);
+    expect(groups[0].key).toBe("$900 retail / $189 net / 214% ROI");
+  });
+
+  it("does not flag pairs (below minGroup)", () => {
+    const items = [t("a", 900, 189, 2.14), t("b", 900, 189, 2.14), t("c", 450, 80, 0.6)];
+    expect(findCloneGroups(items)).toHaveLength(0);
+  });
+
+  it("treats a small dollar difference as distinct", () => {
+    const items = [t("a", 900, 189, 2.14), t("b", 901, 189, 2.14), t("c", 900, 189, 2.14)];
+    expect(findCloneGroups(items)).toHaveLength(0);
+  });
+
+  it("ignores listings with missing values", () => {
+    const items = [t("a", 900, 189, 2.14), t("b", 900, 189, 2.14), t("c", null, 189, 2.14)];
+    expect(findCloneGroups(items)).toHaveLength(0);
+  });
+
+  it("returns multiple groups, largest first", () => {
+    const items = [
+      t("a", 900, 189, 2.14),
+      t("b", 900, 189, 2.14),
+      t("c", 900, 189, 2.14),
+      t("d", 600, 120, 1.5),
+      t("e", 600, 120, 1.5),
+      t("f", 600, 120, 1.5),
+      t("g", 600, 120, 1.5),
+    ];
+    const groups = findCloneGroups(items);
+    expect(groups).toHaveLength(2);
+    expect(groups[0].members).toHaveLength(4);
+    expect(groups[1].members).toHaveLength(3);
+  });
+});
+
+describe("isLowConfidence", () => {
+  it("handles the 0..100 scale (Research.confidenceScore)", () => {
+    expect(isLowConfidence(35)).toBe(true);
+    expect(isLowConfidence(62)).toBe(false);
+  });
+  it("handles the 0..1 scale (identificationConfidence)", () => {
+    expect(isLowConfidence(0.3)).toBe(true);
+    expect(isLowConfidence(0.55)).toBe(false);
+  });
+  it("treats missing confidence as low", () => {
+    expect(isLowConfidence(null)).toBe(true);
+    expect(isLowConfidence(undefined)).toBe(true);
+  });
+});
+
+describe("isClosingStale", () => {
+  const now = new Date("2026-08-06T12:00:00Z");
+  it("flags ACTIVE listings whose closingAt is in the past", () => {
+    expect(isClosingStale(new Date("2026-08-05T12:00:00Z"), "ACTIVE", now)).toBe(true);
+  });
+  it("does not flag open or ended listings", () => {
+    expect(isClosingStale(new Date("2026-08-07T12:00:00Z"), "ACTIVE", now)).toBe(false);
+    expect(isClosingStale(new Date("2026-08-05T12:00:00Z"), "ENDED", now)).toBe(false);
+    expect(isClosingStale(null, "ACTIVE", now)).toBe(false);
+  });
+});
diff --git a/govarbitrage/src/agents/skeptic-rules.ts b/govarbitrage/src/agents/skeptic-rules.ts
new file mode 100644
index 0000000..2c893b6
--- /dev/null
+++ b/govarbitrage/src/agents/skeptic-rules.ts
@@ -0,0 +1,69 @@
+// Pure, testable rule logic for the skeptic agent. No DB, no I/O.
+
+export interface ValuationTuple {
+  listingId: string;
+  title: string;
+  /** Research.newRetail, USD */
+  newRetail: number | null;
+  /** CostBreakdown.expectedNetProfit, USD */
+  expectedNetProfit: number | null;
+  /** CostBreakdown.roi, ratio (0.42 = 42%) */
+  roi: number | null;
+}
+
+export interface CloneGroup {
+  /** Human-readable tuple key, e.g. "$900 retail / $189 net / 214% ROI" */
+  key: string;
+  members: ValuationTuple[];
+}
+
+/**
+ * Detect the placeholder-valuation artifact: groups of >= minGroup listings
+ * whose (newRetail, expectedNetProfit, roi) tuple is identical to the dollar
+ * (ROI compared to 0.1%). Distinct items genuinely never share all three.
+ */
+export function findCloneGroups(items: ValuationTuple[], minGroup = 3): CloneGroup[] {
+  const buckets = new Map<string, ValuationTuple[]>();
+  for (const it of items) {
+    if (it.newRetail == null || it.expectedNetProfit == null || it.roi == null) continue;
+    const key = [
+      Math.round(it.newRetail),
+      Math.round(it.expectedNetProfit),
+      Math.round(it.roi * 1000), // 0.1% resolution
+    ].join("|");
+    const arr = buckets.get(key);
+    if (arr) arr.push(it);
+    else buckets.set(key, [it]);
+  }
+
+  const groups: CloneGroup[] = [];
+  for (const members of buckets.values()) {
+    if (members.length < minGroup) continue;
+    const m = members[0];
+    groups.push({
+      key: `$${Math.round(m.newRetail!)} retail / $${Math.round(m.expectedNetProfit!)} net / ${Math.round(m.roi! * 100)}% ROI`,
+      members,
+    });
+  }
+  // Biggest artifact first.
+  groups.sort((a, b) => b.members.length - a.members.length);
+  return groups;
+}
+
+/** Identification/valuation confidence below the floor (both scales accepted). */
+export function isLowConfidence(confidence: number | null | undefined, floor = 0.4): boolean {
+  if (confidence == null) return true; // no confidence recorded at all
+  // Research.confidenceScore is 0..100; identificationConfidence is 0..1.
+  const ratio = confidence > 1 ? confidence / 100 : confidence;
+  return ratio < floor;
+}
+
+/** closesAt in the past but the listing still marked open/ACTIVE. */
+export function isClosingStale(
+  closingAt: Date | string | null | undefined,
+  listingStatus: string,
+  now: Date = new Date(),
+): boolean {
+  if (!closingAt) return false;
+  return listingStatus === "ACTIVE" && new Date(closingAt).getTime() < now.getTime();
+}
diff --git a/govarbitrage/src/agents/skeptic.ts b/govarbitrage/src/agents/skeptic.ts
new file mode 100644
index 0000000..aa21a57
--- /dev/null
+++ b/govarbitrage/src/agents/skeptic.ts
@@ -0,0 +1,93 @@
+import { prisma } from "@/lib/db";
+import { runAgent, type AgentRunResult } from "./framework";
+import { topRankedOpportunities } from "@/lib/digest-top";
+import { findCloneGroups, isClosingStale, isLowConfidence, type ValuationTuple } from "./skeptic-rules";
+
+// The Skeptic is the adversarial digest gate. It loads the top-ranked
+// opportunities exactly the way the digest does (shared helper in
+// src/lib/digest-top.ts) and flags what a buyer should NOT trust:
+//   CLONE_VALUATION (CRITICAL) — >=3 listings with dollar-identical
+//     (newRetail, expectedNetProfit, roi): the placeholder-heuristic artifact.
+//   LOW_CONFIDENCE (WARN) — identification/valuation confidence < 0.4.
+//   CLOSING_DATA_STALE (WARN) — closingAt in the past but still marked open.
+
+const num = (d: unknown): number | null => (d == null ? null : Number(d));
+
+export async function runSkeptic(topN = 30): Promise<AgentRunResult> {
+  return runAgent("skeptic", async (ctx) => {
+    // openOnly:false so stale-but-still-ranked rows are audited too.
+    const rows = await topRankedOpportunities({ limit: topN, openOnly: false });
+    const ids = rows.map((r) => r.id);
+    const listings = await prisma.listing.findMany({
+      where: { id: { in: ids } },
+      include: { research: true, costBreakdown: true },
+    });
+    const byId = new Map(listings.map((l) => [l.id, l]));
+
+    const flagged = new Set<string>();
+    ctx.count(rows.length);
+
+    // ── CLONE_VALUATION ──
+    const tuples: ValuationTuple[] = [];
+    for (const r of rows) {
+      const l = byId.get(r.id);
+      if (!l) continue;
+      tuples.push({
+        listingId: l.id,
+        title: l.title,
+        newRetail: num(l.research?.newRetail),
+        expectedNetProfit: num(l.costBreakdown?.expectedNetProfit),
+        roi: num(l.costBreakdown?.roi),
+      });
+    }
+    for (const group of findCloneGroups(tuples)) {
+      for (const m of group.members) {
+        flagged.add(m.listingId);
+        await ctx.finding({
+          kind: "CLONE_VALUATION",
+          severity: "CRITICAL",
+          listingId: m.listingId,
+          listingTitle: m.title,
+          title: `Clone valuation: ${group.members.length} top listings share ${group.key}`,
+          detail:
+            `Identical-to-the-dollar valuation tuple across ${group.members.length} listings — ` +
+            `placeholder heuristic, not item-specific research. Members: ` +
+            group.members.map((x) => x.title.slice(0, 50)).join(" | "),
+        });
+      }
+    }
+
+    // ── LOW_CONFIDENCE + CLOSING_DATA_STALE ──
+    for (const r of rows) {
+      const l = byId.get(r.id);
+      if (!l) continue;
+
+      const confidence = num(l.research?.confidenceScore);
+      if (isLowConfidence(confidence)) {
+        flagged.add(l.id);
+        await ctx.finding({
+          kind: "LOW_CONFIDENCE",
+          severity: "WARN",
+          listingId: l.id,
+          listingTitle: l.title,
+          title: `Low confidence (${confidence == null ? "none" : Math.round(confidence) + "/100"}) on "${l.title.slice(0, 70)}"`,
+          detail: "Identification/valuation confidence below 0.4 — verify comps before trusting the numbers.",
+        });
+      }
+
+      if (isClosingStale(l.closingAt, l.listingStatus)) {
+        flagged.add(l.id);
+        await ctx.finding({
+          kind: "CLOSING_DATA_STALE",
+          severity: "WARN",
+          listingId: l.id,
+          listingTitle: l.title,
+          title: `Closing data stale: "${l.title.slice(0, 70)}" closed ${l.closingAt?.toISOString().slice(0, 10)} but is still ACTIVE`,
+          detail: "closingAt is in the past while listingStatus=ACTIVE — the liveness sweep missed it or the source changed.",
+        });
+      }
+    }
+
+    return `${flagged.size}/${rows.length} top opportunities flagged`;
+  });
+}
diff --git a/govarbitrage/src/app/api/agents/search/route.ts b/govarbitrage/src/app/api/agents/search/route.ts
new file mode 100644
index 0000000..5fd8c65
--- /dev/null
+++ b/govarbitrage/src/app/api/agents/search/route.ts
@@ -0,0 +1,68 @@
+import { NextRequest, NextResponse } from "next/server";
+import { searchCommercialAgents, type AgentKind } from "@/lib/places";
+import { rateLimit, clientIp, sessionOrIpKey } from "@/lib/rate-limit";
+import { getCurrentUser } from "@/lib/current-user";
+
+export const dynamic = "force-dynamic";
+
+// Rate-limit bucket key. The upstream (Google Places) is a paid API, so one
+// session must not be able to run up the bill. Key on the VERIFIED session
+// subject (unforgeable) — NOT on X-Forwarded-For, which the client controls and
+// can rotate to reset the bucket. This route is login-gated by middleware, so a
+// user is normally present; fall back to the (spoofable) IP only as a last resort.
+async function clientKey(req: NextRequest): Promise<string> {
+  const user = await getCurrentUser();
+  return sessionOrIpKey("agents", user?.sub, clientIp(req));
+}
+
+// Lightweight input validation so user-supplied location can't pad the Places
+// textQuery with junk (cost/relevance noise) or a malformed ZIP.
+const ZIP_RE = /^\d{5}(?:-\d{4})?$/;
+const LOC_RE = /^[a-zA-Z0-9\s,.'-]{1,80}$/;
+
+export async function GET(req: NextRequest) {
+  const rl = rateLimit(await clientKey(req), 20, 60_000);
+  if (!rl.allowed) {
+    return NextResponse.json(
+      { error: "Too many searches — wait a moment." },
+      { status: 429, headers: { "Retry-After": String(rl.retryAfterSec) } },
+    );
+  }
+
+  const sp = req.nextUrl.searchParams;
+  const city = sp.get("city")?.trim() || undefined;
+  const state = sp.get("state")?.trim() || undefined;
+  const zip = sp.get("zip")?.trim() || undefined;
+  const typeParam = (sp.get("type") ?? "both").toLowerCase();
+  const kinds: AgentKind[] =
+    typeParam === "buyer" ? ["buyer"] : typeParam === "leasing" ? ["leasing"] : ["buyer", "leasing"];
+
+  if (!city && !state && !zip) {
+    return NextResponse.json({ error: "Provide a city, state, or ZIP." }, { status: 400 });
+  }
+  if (zip && !ZIP_RE.test(zip)) {
+    return NextResponse.json({ error: "Invalid ZIP — use 5 digits (or ZIP+4)." }, { status: 400 });
+  }
+  if (city && !LOC_RE.test(city)) {
+    return NextResponse.json({ error: "Invalid city." }, { status: 400 });
+  }
+  if (state && !LOC_RE.test(state)) {
+    return NextResponse.json({ error: "Invalid state." }, { status: 400 });
+  }
+
+  try {
+    const data = await searchCommercialAgents({ city, state, zip, kinds });
+    return NextResponse.json(data);
+  } catch (e) {
+    // Don't leak the raw upstream (Google) error to the client — it can reveal
+    // key/quota details. Log server-side; return a generic message. Preserve the
+    // 503 "not configured" signal so ops can tell a missing key from a bad call.
+    const raw = e instanceof Error ? e.message : "Search failed";
+    console.error("[agents/search] upstream error:", raw);
+    const notConfigured = /not configured/.test(raw);
+    return NextResponse.json(
+      { error: notConfigured ? "Agent search is temporarily unavailable." : "Search failed — try again." },
+      { status: notConfigured ? 503 : 502 },
+    );
+  }
+}
diff --git a/govarbitrage/src/app/api/auth/apple/route.ts b/govarbitrage/src/app/api/auth/apple/route.ts
new file mode 100644
index 0000000..90e62d9
--- /dev/null
+++ b/govarbitrage/src/app/api/auth/apple/route.ts
@@ -0,0 +1,104 @@
+import { NextRequest, NextResponse } from "next/server";
+import { randomBytes } from "node:crypto";
+import { z } from "zod";
+import { createRemoteJWKSet, jwtVerify } from "jose";
+import { prisma } from "@/lib/db";
+import { hashPassword } from "@/lib/password";
+import { createSession, SESSION_COOKIE, SESSION_MAX_AGE } from "@/lib/session";
+import { rateLimit, clientIp, tooManyRequests } from "@/lib/rate-limit";
+
+export const dynamic = "force-dynamic";
+
+// Sign in with Apple — NATIVE iOS flow. The iOS app (expo-apple-authentication)
+// obtains an Apple identity token whose `aud` is the app's bundle id; we verify
+// it against Apple's public JWKS and issue our OWN app session JWT in the JSON
+// body (the native app stores it and sends `Authorization: Bearer <jwt>`). A
+// session cookie is also set for web parity. Optional sign-in: the rest of the
+// app works logged-out; this just creates/links a lightweight account.
+const APPLE_ISSUER = "https://appleid.apple.com";
+const APPLE_JWKS = createRemoteJWKSet(new URL("https://appleid.apple.com/auth/keys"));
+// The app's bundle id is the `aud` of a native Sign in with Apple token.
+const APPLE_AUDIENCE = process.env.APPLE_BUNDLE_ID || "com.abrams.govarbitrage";
+
+// Apple returns name/email ONLY on the very first authorization; the app passes
+// them through so we can populate the account on create.
+const Schema = z.object({
+  identityToken: z.string().min(1),
+  fullName: z.string().trim().max(200).optional(),
+  email: z.string().email().max(320).optional(),
+});
+
+export async function POST(req: NextRequest) {
+  // Brute-force / abuse protection, mirroring the password login route.
+  const ip = clientIp(req);
+  const ipLimit = rateLimit(`apple:ip:${ip}`, 20, 5 * 60_000);
+  if (!ipLimit.allowed) return tooManyRequests(ipLimit);
+
+  const parsed = Schema.safeParse(await req.json().catch(() => ({})));
+  if (!parsed.success) {
+    return NextResponse.json({ error: "identityToken required" }, { status: 400 });
+  }
+  const { identityToken, fullName, email: providedEmail } = parsed.data;
+
+  // Verify the Apple identity token: signature (Apple JWKS) + issuer + audience.
+  let appleSub: string;
+  let tokenEmail: string | undefined;
+  try {
+    const { payload } = await jwtVerify(identityToken, APPLE_JWKS, {
+      issuer: APPLE_ISSUER,
+      audience: APPLE_AUDIENCE,
+    });
+    if (!payload.sub) throw new Error("no sub");
+    appleSub = payload.sub;
+    tokenEmail = typeof payload.email === "string" ? payload.email : undefined;
+  } catch {
+    await prisma.auditLog.create({
+      data: { action: "auth.apple.failed", entity: "User", meta: { reason: "token_verify" } },
+    });
+    return NextResponse.json({ error: "Invalid Apple token" }, { status: 401 });
+  }
+
+  // Prefer the verified email from the token, then the app-provided one, else a
+  // stable synthetic (Apple can withhold email on re-auth / private relay).
+  const email = (tokenEmail || providedEmail || `apple_${appleSub}@govarbitrage.apple`).toLowerCase();
+
+  // Upsert the account by Apple `sub`. Apple users never password-login, so give
+  // them an unguessable random passwordHash (keeps the column non-null without a
+  // migration and without a usable password).
+  let user = await prisma.user.findUnique({ where: { appleSub } });
+  if (!user) {
+    user = await prisma.user.create({
+      data: {
+        appleSub,
+        email,
+        name: fullName || null,
+        passwordHash: hashPassword(randomBytes(32).toString("hex")),
+        role: "VIEWER",
+      },
+    });
+    await prisma.auditLog.create({
+      data: { userId: user.id, action: "auth.apple.signup", entity: "User", entityId: user.id },
+    });
+  } else if (fullName && !user.name) {
+    // Backfill the name if Apple only sent it now and we didn't have it.
+    user = await prisma.user.update({ where: { id: user.id }, data: { name: fullName } });
+  }
+
+  const token = await createSession({ sub: user.id, email: user.email, role: user.role });
+  await prisma.auditLog.create({
+    data: { userId: user.id, action: "auth.apple.login", entity: "User", entityId: user.id },
+  });
+
+  const res = NextResponse.json({
+    token, // native app stores this and sends it as `Authorization: Bearer <token>`
+    user: { id: user.id, email: user.email, name: user.name, role: user.role },
+  });
+  res.cookies.set(SESSION_COOKIE, token, {
+    httpOnly: true,
+    sameSite: "lax",
+    secure: process.env.NODE_ENV === "production",
+    path: "/",
+    maxAge: SESSION_MAX_AGE,
+  });
+  return res;
+}
diff --git a/govarbitrage/src/app/api/auth/login/route.ts b/govarbitrage/src/app/api/auth/login/route.ts
new file mode 100644
index 0000000..4eebad2
--- /dev/null
+++ b/govarbitrage/src/app/api/auth/login/route.ts
@@ -0,0 +1,73 @@
+import { NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { prisma } from "@/lib/db";
+import { verifyPassword, hashPassword } from "@/lib/password";
+import { createSession, SESSION_COOKIE, SESSION_MAX_AGE } from "@/lib/session";
+import { rateLimit, clientIp, tooManyRequests } from "@/lib/rate-limit";
+
+export const dynamic = "force-dynamic";
+
+// A well-formed dummy hash so verifyPassword runs the full scrypt path even
+// when the user doesn't exist — keeps login timing uniform (no user-enumeration
+// oracle). Computed once at module load.
+const DUMMY_HASH = hashPassword("invalid-password-placeholder");
+
+// Username-only login (Steve 2026-07-22: "no more email login"). The username
+// is resolved against the unique user whose email local-part matches
+// ("admin" → admin@govarbitrage.local). Full emails are rejected outright.
+const Schema = z.object({ username: z.string().trim().min(1), password: z.string().min(1) });
+
+async function resolveUser(username: string) {
+  const id = username.toLowerCase();
+  if (id.includes("@")) return null; // email-style logins are retired
+  const matches = await prisma.user.findMany({
+    where: { email: { startsWith: `${id}@` } },
+    take: 2,
+  });
+  return matches.length === 1 ? matches[0] : null;
+}
+
+export async function POST(req: NextRequest) {
+  // Brute-force protection: cap login attempts per IP.
+  const ip = clientIp(req);
+  const ipLimit = rateLimit(`login:ip:${ip}`, 10, 5 * 60_000);
+  if (!ipLimit.allowed) return tooManyRequests(ipLimit);
+
+  const parsed = Schema.safeParse(await req.json().catch(() => ({})));
+  if (!parsed.success) {
+    return NextResponse.json({ error: "Username and password required" }, { status: 400 });
+  }
+  const { username, password } = parsed.data;
+
+  // And per targeted username (credential-stuffing protection).
+  const userLimit = rateLimit(`login:user:${username.toLowerCase()}`, 5, 15 * 60_000);
+  if (!userLimit.allowed) return tooManyRequests(userLimit);
+  const user = await resolveUser(username);
+
+  // Always run a full scrypt verification (against a dummy hash when the user
+  // is absent) so response timing doesn't reveal whether the account exists.
+  const ok = verifyPassword(password, user ? user.passwordHash : DUMMY_HASH);
+  if (!user || !ok) {
+    await prisma.auditLog.create({
+      data: { action: "auth.login.failed", entity: "User", meta: { username } },
+    });
+    return NextResponse.json({ error: "Invalid credentials" }, { status: 401 });
+  }
+
+  const token = await createSession({ sub: user.id, email: user.email, role: user.role });
+  await prisma.auditLog.create({
+    data: { userId: user.id, action: "auth.login", entity: "User", entityId: user.id },
+  });
+
+  const res = NextResponse.json({
+    user: { id: user.id, email: user.email, name: user.name, role: user.role },
+  });
+  res.cookies.set(SESSION_COOKIE, token, {
+    httpOnly: true,
+    sameSite: "lax",
+    secure: process.env.NODE_ENV === "production",
+    path: "/",
+    maxAge: SESSION_MAX_AGE,
+  });
+  return res;
+}
diff --git a/govarbitrage/src/app/api/auth/logout/route.ts b/govarbitrage/src/app/api/auth/logout/route.ts
new file mode 100644
index 0000000..21eb169
--- /dev/null
+++ b/govarbitrage/src/app/api/auth/logout/route.ts
@@ -0,0 +1,10 @@
+import { NextResponse } from "next/server";
+import { SESSION_COOKIE } from "@/lib/session";
+
+export const dynamic = "force-dynamic";
+
+export async function POST() {
+  const res = NextResponse.json({ ok: true });
+  res.cookies.set(SESSION_COOKIE, "", { httpOnly: true, path: "/", maxAge: 0 });
+  return res;
+}
diff --git a/govarbitrage/src/app/api/auth/me/route.ts b/govarbitrage/src/app/api/auth/me/route.ts
new file mode 100644
index 0000000..8259595
--- /dev/null
+++ b/govarbitrage/src/app/api/auth/me/route.ts
@@ -0,0 +1,10 @@
+import { NextResponse } from "next/server";
+import { getCurrentUser } from "@/lib/current-user";
+
+export const dynamic = "force-dynamic";
+
+export async function GET() {
+  const user = await getCurrentUser();
+  if (!user) return NextResponse.json({ user: null }, { status: 401 });
+  return NextResponse.json({ user: { id: user.sub, email: user.email, role: user.role } });
+}
diff --git a/govarbitrage/src/app/api/billing/checkout/route.ts b/govarbitrage/src/app/api/billing/checkout/route.ts
new file mode 100644
index 0000000..0aa2cb2
--- /dev/null
+++ b/govarbitrage/src/app/api/billing/checkout/route.ts
@@ -0,0 +1,22 @@
+import { NextRequest, NextResponse } from "next/server";
+import { getCurrentUser } from "@/lib/current-user";
+import { createCheckout } from "@/lib/billing";
+import type { Tier } from "@prisma/client";
+
+export const dynamic = "force-dynamic";
+
+// Start a (TEST/mock) subscription checkout for the chosen tier.
+export async function POST(req: NextRequest) {
+  const user = await getCurrentUser();
+  if (!user) return NextResponse.json({ error: "sign in first" }, { status: 401 });
+
+  const { tier } = (await req.json().catch(() => ({}))) as { tier?: Tier };
+  if (tier !== "STANDARD" && tier !== "PREMIUM") {
+    return NextResponse.json({ error: "tier must be STANDARD or PREMIUM" }, { status: 400 });
+  }
+
+  const baseUrl = process.env.PUBLIC_BASE_URL || req.nextUrl.origin;
+  const result = await createCheckout({ userId: user.sub, email: user.email, tier, baseUrl });
+  if (!result.ok) return NextResponse.json({ error: result.error }, { status: 400 });
+  return NextResponse.json({ url: result.url, mode: result.mode });
+}
diff --git a/govarbitrage/src/app/api/billing/webhook/route.ts b/govarbitrage/src/app/api/billing/webhook/route.ts
new file mode 100644
index 0000000..fb6be2c
--- /dev/null
+++ b/govarbitrage/src/app/api/billing/webhook/route.ts
@@ -0,0 +1,48 @@
+import { NextRequest, NextResponse } from "next/server";
+import { verifyWebhook } from "@/lib/billing";
+import { prisma } from "@/lib/db";
+import { tierFromPriceUsd } from "@/lib/tiers";
+import type { Tier } from "@prisma/client";
+
+export const dynamic = "force-dynamic";
+
+// Stripe TEST webhook — the real source of truth for subscription state.
+// checkout.session.completed / subscription updates -> set user.tier.
+export async function POST(req: NextRequest) {
+  const raw = await req.text();
+  const event = verifyWebhook(raw, req.headers.get("stripe-signature"));
+  if (!event) return NextResponse.json({ error: "unverified" }, { status: 400 });
+
+  try {
+    if (event.type === "checkout.session.completed") {
+      const s = event.data.object as {
+        client_reference_id?: string | null;
+        metadata?: { userId?: string; tier?: string } | null;
+        customer?: string | null;
+        subscription?: string | null;
+        amount_total?: number | null;
+      };
+      const userId = s.metadata?.userId || s.client_reference_id || undefined;
+      const tier = (s.metadata?.tier as Tier) || tierFromPriceUsd((s.amount_total ?? 0) / 100);
+      if (userId && (tier === "STANDARD" || tier === "PREMIUM")) {
+        await prisma.user.update({
+          where: { id: userId },
+          data: {
+            tier,
+            stripeCustomerId: (s.customer as string) || undefined,
+            stripeSubscriptionId: (s.subscription as string) || undefined,
+          },
+        });
+      }
+    } else if (event.type === "customer.subscription.deleted") {
+      const sub = event.data.object as { id: string };
+      await prisma.user.updateMany({
+        where: { stripeSubscriptionId: sub.id },
+        data: { tier: "FREE", stripeSubscriptionId: null },
+      });
+    }
+  } catch (e) {
+    return NextResponse.json({ error: (e as Error).message }, { status: 500 });
+  }
+  return NextResponse.json({ received: true });
+}
diff --git a/govarbitrage/src/app/api/import/apify-govdeals/route.ts b/govarbitrage/src/app/api/import/apify-govdeals/route.ts
new file mode 100644
index 0000000..26455c8
--- /dev/null
+++ b/govarbitrage/src/app/api/import/apify-govdeals/route.ts
@@ -0,0 +1,28 @@
+import { NextRequest, NextResponse } from "next/server";
+import { fetchApifyDataset, fetchApifyLastRun } from "@/importers/apify-govdeals";
+import { ingestMany } from "@/importers/ingest";
+import { requireWrite } from "@/lib/auth";
+
+export const dynamic = "force-dynamic";
+export const maxDuration = 300;
+
+// Ingest GovDeals listings scraped by Apify. READ-ONLY: pulls an already-finished
+// dataset (?dataset=ID) or the actor's last successful run (free). Triggering a
+// NEW Apify run costs money and is intentionally NOT exposed here — it stays a
+// deliberate, cost-surfaced action. ?ai=1 runs local Ollama identification.
+export async function POST(req: NextRequest) {
+  const denied = await requireWrite(req);
+  if (denied) return denied;
+  try {
+    const datasetId = req.nextUrl.searchParams.get("dataset") || undefined;
+    const useAI = req.nextUrl.searchParams.get("ai") === "1";
+    const rows = datasetId ? await fetchApifyDataset(datasetId) : await fetchApifyLastRun();
+    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).length;
+    return NextResponse.json({ source: "GOVDEALS", via: "apify", fetched: rows.length, created, updated, failed });
+  } catch (e) {
+    return NextResponse.json({ error: (e as Error).message }, { status: 502 });
+  }
+}

← 63dacc3 security: strip hardcoded secret -> env-first/passwordless.  ·  back to Japan Enrich  ·  security: strip hardcoded secret -> env-first/passwordless. f9d835f →