← 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
A govarbitrage/apps/mobile/index.tsA govarbitrage/apps/mobile/lib/api-error.tsA govarbitrage/apps/mobile/lib/api.tsA govarbitrage/apps/mobile/lib/auth-headers.tsA govarbitrage/apps/mobile/lib/auth.tsA govarbitrage/apps/mobile/lib/format.tsA govarbitrage/apps/mobile/lib/pnl.tsA govarbitrage/apps/mobile/lib/settings.tsA govarbitrage/apps/mobile/lib/types.tsA govarbitrage/apps/mobile/lib/validate.tsA govarbitrage/apps/mobile/scripts/lint-financial-format.mjsA govarbitrage/apps/mobile/tests/auth-headers.test.mjsA govarbitrage/apps/mobile/tests/format.test.mjsA govarbitrage/eslint.config.mjsA govarbitrage/extension/background.jsA govarbitrage/extension/content.jsA govarbitrage/extension/popup.jsA govarbitrage/next.config.tsA govarbitrage/playwright.config.tsA govarbitrage/postcss.config.mjsA govarbitrage/prisma/seed.tsA govarbitrage/scripts/hot-deal-alert.tsA govarbitrage/scripts/import-apify-govdeals.tsA govarbitrage/scripts/import-govdeals-free.tsA govarbitrage/scripts/import-govplanet-free.tsA govarbitrage/scripts/import-grays.tsA govarbitrage/scripts/import-gsa.tsA govarbitrage/scripts/import-municibid-free.tsA govarbitrage/scripts/import-publicsurplus-free.tsA govarbitrage/scripts/liveness-sweep.tsA govarbitrage/scripts/probe-govdeals.tsA govarbitrage/scripts/seed-digest-snapshots.tsA govarbitrage/scripts/send-digest.tsA govarbitrage/scripts/send-newsletter-digest.tsA govarbitrage/scripts/set-admin.tsA govarbitrage/src/agents/appraiser.tsA govarbitrage/src/agents/framework.tsA govarbitrage/src/agents/run.tsA govarbitrage/src/agents/scout.tsA govarbitrage/src/agents/skeptic-rules.test.tsA govarbitrage/src/agents/skeptic-rules.tsA govarbitrage/src/agents/skeptic.tsA govarbitrage/src/app/api/agents/search/route.tsA govarbitrage/src/app/api/auth/apple/route.tsA govarbitrage/src/app/api/auth/login/route.tsA govarbitrage/src/app/api/auth/logout/route.tsA govarbitrage/src/app/api/auth/me/route.tsA govarbitrage/src/app/api/billing/checkout/route.tsA govarbitrage/src/app/api/billing/webhook/route.tsA govarbitrage/src/app/api/import/apify-govdeals/route.ts
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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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 & 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 & 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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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 →