[object Object]

← back to Govarbitrage

Add GovArbitrage mobile app, browser extension, and import/digest scripts (with code-review hardening)

9bbed6233bdd47900a19db29211e8af0f948ca84 · 2026-09-16 11:26:34 -0700 · Steve Abrams

Introduces the Expo mobile app (apps/mobile), the listing-capture browser
extension, the auction-source importers, and the digest/newsletter/hot-deal
senders, plus test/build configs.

Code-review fixes applied to these files before commit:
- importers: exit non-zero on empty feed / all-rows-failed (no more false green);
  govdeals-free isolates each market so one failure can't abort the siblings
- liveness-sweep: scope GONE_MARKERS to <title>/no-live-signal so a sidebar
  "ended" string can't falsely mark a live listing REMOVED; add fetch timeout
- mobile auth-headers: UTF-8-safe base64 so a non-Latin1 password no longer
  throws out of every API call
- "Newest" sort now sorts by createdAt end-to-end (backend flattenListing +
  NATIVE_SORT_COLUMNS + sort test + mobile SortField), which also restores the
  admin created-date chip + location on the list grid
- hot-deal-alert: stamp hotAlertedAt before send with rollback-on-failure to
  avoid duplicate alerts
- send-digest: escapeHtml on scraped location; mkdir logs/ before dry-run write

Verified: backend tsc clean, 196 vitest pass, 13 mobile unit tests pass, lint clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014TiE2Px2C8yTX7JJFajxoP

Files touched

Diff

commit 9bbed6233bdd47900a19db29211e8af0f948ca84
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Wed Sep 16 11:26:34 2026 -0700

    Add GovArbitrage mobile app, browser extension, and import/digest scripts (with code-review hardening)
    
    Introduces the Expo mobile app (apps/mobile), the listing-capture browser
    extension, the auction-source importers, and the digest/newsletter/hot-deal
    senders, plus test/build configs.
    
    Code-review fixes applied to these files before commit:
    - importers: exit non-zero on empty feed / all-rows-failed (no more false green);
      govdeals-free isolates each market so one failure can't abort the siblings
    - liveness-sweep: scope GONE_MARKERS to <title>/no-live-signal so a sidebar
      "ended" string can't falsely mark a live listing REMOVED; add fetch timeout
    - mobile auth-headers: UTF-8-safe base64 so a non-Latin1 password no longer
      throws out of every API call
    - "Newest" sort now sorts by createdAt end-to-end (backend flattenListing +
      NATIVE_SORT_COLUMNS + sort test + mobile SortField), which also restores the
      admin created-date chip + location on the list grid
    - hot-deal-alert: stamp hotAlertedAt before send with rollback-on-failure to
      avoid duplicate alerts
    - send-digest: escapeHtml on scraped location; mkdir logs/ before dry-run write
    
    Verified: backend tsc clean, 196 vitest pass, 13 mobile unit tests pass, lint clean.
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
    Claude-Session: https://claude.ai/code/session_014TiE2Px2C8yTX7JJFajxoP
---
 apps/mobile/app/(tabs)/_layout.tsx                 |  48 ++
 apps/mobile/app/(tabs)/index.tsx                   | 302 ++++++++
 apps/mobile/app/(tabs)/settings.tsx                | 452 ++++++++++++
 apps/mobile/app/_layout.tsx                        |  40 +
 apps/mobile/app/listing/[id].tsx                   | 679 +++++++++++++++++
 apps/mobile/assets/android-icon-background.png     | Bin 0 -> 17549 bytes
 apps/mobile/assets/android-icon-foreground.png     | Bin 0 -> 78796 bytes
 apps/mobile/assets/android-icon-monochrome.png     | Bin 0 -> 4140 bytes
 apps/mobile/assets/favicon.png                     | Bin 0 -> 1129 bytes
 apps/mobile/assets/icon.png                        | Bin 0 -> 393493 bytes
 apps/mobile/assets/splash-icon.png                 | Bin 0 -> 17547 bytes
 apps/mobile/components/ErrorBoundary.tsx           |  61 ++
 apps/mobile/components/ErrorCard.tsx               |  66 ++
 apps/mobile/components/OpportunityCard.tsx         | 220 ++++++
 apps/mobile/components/ScoreBadge.tsx              |  64 ++
 apps/mobile/constants/theme.ts                     |  73 ++
 .../1-cold-launch.png                              | Bin 0 -> 329497 bytes
 .../2-detail-maxbid.png                            | Bin 0 -> 738677 bytes
 .../3-settings.png                                 | Bin 0 -> 239413 bytes
 .../4-connection-test.png                          | Bin 0 -> 229488 bytes
 .../CLEAN-stills-2026-09-03T17-31-41Z/5-siwa.png   | Bin 0 -> 229488 bytes
 .../GovArbUITest/GovArbUITests/GovArbUITests.swift | 119 +++
 .../GovArbUITest/watch-capture.sh                  |  62 ++
 ...-scan-contact-sheet-1-of-2-20260903T164350Z.jpg | Bin 0 -> 485084 bytes
 ...-scan-contact-sheet-2-of-2-20260903T164350Z.jpg | Bin 0 -> 447768 bytes
 apps/mobile/device-proof-evidence/capture-lib.sh   |  86 +++
 .../device-proof-evidence/capture-tap-scroll.sh    |  82 +++
 .../privacy-contact-sheet-2026-09-03T16-42-59Z.jpg | Bin 0 -> 169623 bytes
 apps/mobile/device-proof-evidence/privacy-scan.sh  |  50 ++
 .../privacy-sweep-CLEAN-2026-09-03T17-31-41Z.jpg   | Bin 0 -> 170301 bytes
 .../device-proof-evidence/readiness-probe.sh       |  73 ++
 .../1-cold-launch.png                              | Bin 0 -> 336377 bytes
 .../2-detail-maxbid.png                            | Bin 0 -> 875115 bytes
 .../run4-clean-2026-09-03T15-23-26Z/3-settings.png | Bin 0 -> 238812 bytes
 .../4-connection-test.png                          | Bin 0 -> 229770 bytes
 .../run4-clean-2026-09-03T15-23-26Z/5-siwa.png     | Bin 0 -> 229770 bytes
 .../1-cold-launch.png                              | Bin 0 -> 337886 bytes
 .../2-detail-maxbid.png                            | Bin 0 -> 337635 bytes
 .../3-settings.png                                 | Bin 0 -> 239766 bytes
 .../4-connection-test.png                          | Bin 0 -> 230766 bytes
 .../run5-frames-2026-09-03T16-42-59Z/5-siwa.png    | Bin 0 -> 230766 bytes
 .../device-proof-evidence/verify-recording.sh      |  44 ++
 apps/mobile/index.ts                               |   1 +
 apps/mobile/lib/api-error.ts                       |  14 +
 apps/mobile/lib/api.ts                             | 145 ++++
 apps/mobile/lib/auth-headers.ts                    |  34 +
 apps/mobile/lib/auth.ts                            |  83 +++
 apps/mobile/lib/format.ts                          | 125 ++++
 apps/mobile/lib/pnl.ts                             |  13 +
 apps/mobile/lib/settings.ts                        |  97 +++
 apps/mobile/lib/types.ts                           | 244 ++++++
 apps/mobile/lib/validate.ts                        |  95 +++
 apps/mobile/scripts/lint-financial-format.mjs      | 128 ++++
 .../screenshots/01-opportunities-6.9.png           | Bin 0 -> 363703 bytes
 .../store-assets/screenshots/02-settings-6.9.png   | Bin 0 -> 268865 bytes
 .../screenshots/03-listing-detail-6.9.png          | Bin 0 -> 759459 bytes
 .../screenshots/framed-01-opportunities-6.9.png    | Bin 0 -> 459292 bytes
 .../screenshots/framed-02-listing-detail-6.9.png   | Bin 0 -> 588213 bytes
 .../screenshots/framed-03-settings-6.9.png         | Bin 0 -> 360841 bytes
 .../screenshots/ipad-13/01-opportunities-13.png    | Bin 0 -> 517555 bytes
 .../screenshots/ipad-13/02-listing-detail-13.png   | Bin 0 -> 637329 bytes
 .../screenshots/ipad-13/03-settings-13.png         | Bin 0 -> 419353 bytes
 .../iphone-6.5/01-opportunities-6.5.png            | Bin 0 -> 529652 bytes
 .../iphone-6.5/02-listing-detail-6.5.png           | Bin 0 -> 672998 bytes
 .../screenshots/iphone-6.5/03-settings-6.5.png     | Bin 0 -> 423302 bytes
 apps/mobile/tests/auth-headers.test.mjs            |  49 ++
 apps/mobile/tests/format.test.mjs                  |  99 +++
 eslint.config.mjs                                  |  11 +
 extension/background.js                            |  28 +
 extension/content.js                               | 422 +++++++++++
 extension/popup.html                               | 203 +++++
 extension/popup.js                                 | 209 ++++++
 playwright.config.ts                               |  20 +
 prisma/seed.ts                                     | 820 +++++++++++++++++++++
 scripts/deploy-auctions.sh                         | 135 ++++
 scripts/george-env.py                              |  17 +
 scripts/george-env.sh                              |   6 +
 scripts/hot-deal-alert.ts                          | 172 +++++
 scripts/import-apify-govdeals.ts                   |  36 +
 scripts/import-govdeals-free.ts                    |  50 ++
 scripts/import-govplanet-free.ts                   |  35 +
 scripts/import-grays.ts                            |  31 +
 scripts/import-gsa.ts                              |  37 +
 scripts/import-municibid-free.ts                   |  35 +
 scripts/import-publicsurplus-free.ts               |  35 +
 scripts/liveness-sweep.ts                          | 167 +++++
 scripts/probe-govdeals.ts                          |  61 ++
 scripts/run-digest.sh                              |  19 +
 scripts/run-govdeals-free.sh                       |   7 +
 scripts/run-govplanet-free.sh                      |   8 +
 scripts/run-gsa-import.sh                          |   7 +
 scripts/run-hot-deals.sh                           |  15 +
 scripts/run-liveness.sh                            |   7 +
 scripts/run-municibid-free.sh                      |   8 +
 scripts/run-newsletter-digest.sh                   |  10 +
 scripts/run-newsletter-server.sh                   |  10 +
 scripts/run-publicsurplus-free.sh                  |   8 +
 scripts/seed-digest-snapshots.ts                   | 117 +++
 scripts/send-digest.ts                             | 257 +++++++
 scripts/send-newsletter-digest.ts                  |  12 +
 scripts/set-admin.ts                               |  42 ++
 src/lib/listings-sort.test.ts                      |   1 +
 src/lib/listings.ts                                |   7 +
 tests/e2e/smoke.spec.ts                            |  38 +
 vitest.config.ts                                   |  15 +
 105 files changed, 6766 insertions(+)

diff --git a/apps/mobile/app/(tabs)/_layout.tsx b/apps/mobile/app/(tabs)/_layout.tsx
new file mode 100644
index 0000000..ec84013
--- /dev/null
+++ b/apps/mobile/app/(tabs)/_layout.tsx
@@ -0,0 +1,48 @@
+import { Tabs } from "expo-router";
+import { Ionicons } from "@expo/vector-icons";
+import { Colors, Typography } from "../../constants/theme";
+
+export default function TabLayout() {
+  return (
+    <Tabs
+      screenOptions={{
+        tabBarStyle: {
+          backgroundColor: Colors.surface,
+          borderTopColor: Colors.border,
+          borderTopWidth: 1,
+        },
+        tabBarActiveTintColor: Colors.accent,
+        tabBarInactiveTintColor: Colors.textMuted,
+        tabBarLabelStyle: {
+          fontSize: Typography.sizes.xs,
+          fontWeight: "600",
+        },
+        headerStyle: { backgroundColor: Colors.surface },
+        headerTintColor: Colors.textPrimary,
+        headerTitleStyle: { fontWeight: "700" },
+        headerShadowVisible: false,
+      }}
+    >
+      <Tabs.Screen
+        name="index"
+        options={{
+          title: "Opportunities",
+          tabBarLabel: "Listings",
+          tabBarIcon: ({ color, size }) => (
+            <Ionicons name="list" size={size ?? 24} color={color as string} />
+          ),
+        }}
+      />
+      <Tabs.Screen
+        name="settings"
+        options={{
+          title: "Settings",
+          tabBarLabel: "Settings",
+          tabBarIcon: ({ color, size }) => (
+            <Ionicons name="settings-outline" size={size ?? 24} color={color as string} />
+          ),
+        }}
+      />
+    </Tabs>
+  );
+}
diff --git a/apps/mobile/app/(tabs)/index.tsx b/apps/mobile/app/(tabs)/index.tsx
new file mode 100644
index 0000000..ba7bd20
--- /dev/null
+++ b/apps/mobile/app/(tabs)/index.tsx
@@ -0,0 +1,302 @@
+/**
+ * Opportunities screen — ranked list of active auction listings.
+ * Dark financial-dashboard aesthetic. Sort control, pull-to-refresh,
+ * graceful error/offline states.
+ */
+import { useRouter } from "expo-router";
+import React, { useCallback, useEffect, useRef, useState } from "react";
+import {
+  ActivityIndicator,
+  FlatList,
+  Pressable,
+  StyleSheet,
+  Text,
+  View,
+} from "react-native";
+import { SafeAreaView } from "react-native-safe-area-context";
+import { OpportunityCard } from "../../components/OpportunityCard";
+import { ErrorCard } from "../../components/ErrorCard";
+import { Colors, Radius, Spacing, Typography } from "../../constants/theme";
+import { fetchListings } from "../../lib/api";
+import type { ListingRow, SortField } from "../../lib/types";
+
+type SortOption = {
+  label: string;
+  sort: SortField;
+  dir: "asc" | "desc";
+};
+
+const SORT_OPTIONS: SortOption[] = [
+  { label: "Opportunity", sort: "opportunityScore", dir: "desc" },
+  { label: "ROI %", sort: "roi", dir: "desc" },
+  { label: "Net Profit", sort: "netProfit", dir: "desc" },
+  { label: "Closing Soon", sort: "closingAt", dir: "asc" },
+  { label: "Newest", sort: "createdAt", dir: "desc" },
+];
+
+const PAGE_SIZE = 50;
+
+export default function OpportunitiesScreen() {
+  const router = useRouter();
+  const [rows, setRows] = useState<ListingRow[]>([]);
+  const [loading, setLoading] = useState(true);
+  const [refreshing, setRefreshing] = useState(false);
+  const [error, setError] = useState<string | null>(null);
+  const [sortIdx, setSortIdx] = useState(0);
+  const [total, setTotal] = useState(0);
+  const [gated, setGated] = useState(false);
+  const abortRef = useRef<AbortController | null>(null);
+
+  const currentSort = SORT_OPTIONS[sortIdx];
+
+  const load = useCallback(
+    async (isRefresh = false) => {
+      abortRef.current?.abort();
+      const ctrl = new AbortController();
+      abortRef.current = ctrl;
+
+      if (!isRefresh) setLoading(true);
+      setError(null);
+
+      try {
+        const data = await fetchListings(
+          {
+            sort: currentSort.sort,
+            dir: currentSort.dir,
+            pageSize: PAGE_SIZE,
+            page: 1,
+          },
+          { signal: ctrl.signal }
+        );
+        setRows(data.rows);
+        setTotal(data.total);
+        setGated(data.gated);
+      } catch (err: unknown) {
+        if (err instanceof Error && err.name === "AbortError") return;
+        const msg =
+          err instanceof Error ? err.message : "Failed to load listings";
+        setError(msg);
+      } finally {
+        // Skip state updates if this request was superseded (sort change) or unmounted.
+        if (!ctrl.signal.aborted) {
+          setLoading(false);
+          setRefreshing(false);
+        }
+      }
+    },
+    [currentSort.sort, currentSort.dir]
+  );
+
+  useEffect(() => {
+    load();
+    return () => abortRef.current?.abort();
+  }, [load]);
+
+  const onRefresh = useCallback(() => {
+    setRefreshing(true);
+    load(true);
+  }, [load]);
+
+  const renderHeader = () => (
+    <View>
+      {/* Sort bar */}
+      <View style={styles.sortBar}>
+        <Text style={styles.sortLabel}>Sort:</Text>
+        <FlatList
+          horizontal
+          data={SORT_OPTIONS}
+          keyExtractor={(_, i) => String(i)}
+          showsHorizontalScrollIndicator={false}
+          contentContainerStyle={styles.sortChips}
+          renderItem={({ item, index }) => (
+            <Pressable
+              style={[styles.sortChip, index === sortIdx && styles.sortChipActive]}
+              onPress={() => setSortIdx(index)}
+              accessibilityRole="button"
+              accessibilityState={{ selected: index === sortIdx }}
+              accessibilityLabel={`Sort by ${item.label}`}
+            >
+              <Text
+                style={[styles.sortChipText, index === sortIdx && styles.sortChipTextActive]}
+              >
+                {item.label}
+              </Text>
+            </Pressable>
+          )}
+        />
+      </View>
+
+      {/* Stats bar */}
+      {!loading && !error && (
+        <View style={styles.statsBar}>
+          <Text style={styles.statsText}>{total} active listings</Text>
+          {gated && (
+            <Text style={styles.gatedBadge}>FREE — upgrade for full data</Text>
+          )}
+        </View>
+      )}
+    </View>
+  );
+
+  if (loading && rows.length === 0) {
+    return (
+      <SafeAreaView style={styles.root} edges={["bottom"]}>
+        <View style={styles.centerContainer}>
+          <ActivityIndicator size="large" color={Colors.accent} />
+          <Text style={styles.loadingText}>Loading opportunities...</Text>
+        </View>
+      </SafeAreaView>
+    );
+  }
+
+  return (
+    <SafeAreaView style={styles.root} edges={["bottom"]}>
+      <FlatList
+        data={rows}
+        keyExtractor={(item) => item.id}
+        renderItem={({ item, index }) => (
+          <OpportunityCard
+            row={item}
+            rank={index + 1}
+            onPress={() =>
+              router.push({ pathname: "/listing/[id]", params: { id: item.id } })
+            }
+          />
+        )}
+        ListHeaderComponent={renderHeader}
+        ListEmptyComponent={
+          error ? (
+            <ErrorCard message={error} onRetry={() => load()} />
+          ) : (
+            <View style={styles.emptyContainer}>
+              <Text style={styles.emptyTitle}>No Opportunities Right Now</Text>
+              <Text style={styles.emptyText}>
+                Active government surplus auctions appear here as they&apos;re identified and
+                scored. Pull to refresh or check back soon.
+              </Text>
+              <Pressable
+                style={styles.emptyBtn}
+                onPress={() => load()}
+                accessibilityRole="button"
+                accessibilityLabel="Refresh opportunities"
+              >
+                <Text style={styles.emptyBtnText}>Refresh</Text>
+              </Pressable>
+            </View>
+          )
+        }
+        refreshing={refreshing}
+        onRefresh={onRefresh}
+        contentContainerStyle={styles.listContent}
+        ItemSeparatorComponent={() => <View style={{ height: 0 }} />}
+      />
+    </SafeAreaView>
+  );
+}
+
+const styles = StyleSheet.create({
+  root: {
+    flex: 1,
+    backgroundColor: Colors.bg,
+  },
+  centerContainer: {
+    flex: 1,
+    alignItems: "center",
+    justifyContent: "center",
+    gap: Spacing.md,
+  },
+  loadingText: {
+    color: Colors.textSecondary,
+    fontSize: Typography.sizes.base,
+  },
+  sortBar: {
+    flexDirection: "row",
+    alignItems: "center",
+    paddingVertical: Spacing.sm,
+    paddingLeft: Spacing.lg,
+    borderBottomWidth: 1,
+    borderBottomColor: Colors.border,
+  },
+  sortLabel: {
+    fontSize: Typography.sizes.xs,
+    color: Colors.textMuted,
+    textTransform: "uppercase",
+    letterSpacing: 0.5,
+    marginRight: Spacing.sm,
+  },
+  sortChips: {
+    gap: Spacing.sm,
+    paddingRight: Spacing.lg,
+  },
+  sortChip: {
+    paddingHorizontal: Spacing.md,
+    paddingVertical: Spacing.xs,
+    borderRadius: Radius.pill,
+    backgroundColor: Colors.surfaceAlt,
+    borderWidth: 1,
+    borderColor: Colors.border,
+  },
+  sortChipActive: {
+    backgroundColor: Colors.accent + "22",
+    borderColor: Colors.accent,
+  },
+  sortChipText: {
+    fontSize: Typography.sizes.xs,
+    color: Colors.textSecondary,
+    fontWeight: "600",
+  },
+  sortChipTextActive: {
+    color: Colors.accent,
+  },
+  statsBar: {
+    flexDirection: "row",
+    alignItems: "center",
+    paddingHorizontal: Spacing.lg,
+    paddingVertical: Spacing.sm,
+    gap: Spacing.md,
+  },
+  statsText: {
+    fontSize: Typography.sizes.xs,
+    color: Colors.textMuted,
+  },
+  gatedBadge: {
+    fontSize: Typography.sizes.xs,
+    color: Colors.warning,
+    fontWeight: "600",
+  },
+  listContent: {
+    paddingTop: Spacing.sm,
+    paddingBottom: Spacing.xl,
+  },
+  emptyContainer: {
+    padding: Spacing.xxl,
+    alignItems: "center",
+    gap: Spacing.sm,
+  },
+  emptyTitle: {
+    color: Colors.textPrimary,
+    fontSize: Typography.sizes.lg,
+    fontWeight: "700",
+    textAlign: "center",
+  },
+  emptyText: {
+    color: Colors.textSecondary,
+    fontSize: Typography.sizes.base,
+    textAlign: "center",
+    lineHeight: 20,
+  },
+  emptyBtn: {
+    marginTop: Spacing.md,
+    paddingHorizontal: Spacing.xl,
+    paddingVertical: Spacing.md,
+    borderRadius: Radius.pill,
+    backgroundColor: Colors.accent,
+    minHeight: 44,
+    justifyContent: "center",
+  },
+  emptyBtnText: {
+    color: Colors.textPrimary,
+    fontSize: Typography.sizes.base,
+    fontWeight: "700",
+  },
+});
diff --git a/apps/mobile/app/(tabs)/settings.tsx b/apps/mobile/app/(tabs)/settings.tsx
new file mode 100644
index 0000000..b47da1f
--- /dev/null
+++ b/apps/mobile/app/(tabs)/settings.tsx
@@ -0,0 +1,452 @@
+/**
+ * Settings screen — API base URL, Basic auth credentials, connection test.
+ * Credentials stored in expo-secure-store (encrypted on device).
+ */
+import React, { useCallback, useEffect, useRef, useState } from "react";
+import {
+  ActivityIndicator,
+  Alert,
+  KeyboardAvoidingView,
+  Platform,
+  Pressable,
+  ScrollView,
+  StyleSheet,
+  Text,
+  TextInput,
+  View,
+} from "react-native";
+import { SafeAreaView } from "react-native-safe-area-context";
+import * as AppleAuthentication from "expo-apple-authentication";
+import { Colors, Radius, Spacing, Typography } from "../../constants/theme";
+import {
+  loadSettings,
+  saveSettings,
+  loadAppleAccount,
+  type AppSettings,
+  type AppleAccount,
+} from "../../lib/settings";
+import { testConnection, type ConnectionTestResult } from "../../lib/api";
+import { signInWithApple, signOut, isAppleSignInAvailable } from "../../lib/auth";
+
+export default function SettingsScreen() {
+  const [settings, setSettings] = useState<AppSettings>({
+    baseUrl: "https://auctions.agentabrams.com",
+    username: "admin",
+    password: "",
+  });
+  const [saved, setSaved] = useState(false);
+  const [testing, setTesting] = useState(false);
+  const [testResult, setTestResult] = useState<ConnectionTestResult | null>(null);
+  const [account, setAccount] = useState<AppleAccount | null>(null);
+  const [appleAvailable, setAppleAvailable] = useState(false);
+  const [signingIn, setSigningIn] = useState(false);
+  const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
+
+  useEffect(() => {
+    loadSettings()
+      .then(setSettings)
+      .catch(() =>
+        Alert.alert(
+          "Settings unavailable",
+          "Could not read saved settings from secure storage. Using defaults."
+        )
+      );
+    loadAppleAccount().then(setAccount).catch(() => setAccount(null));
+    isAppleSignInAvailable().then(setAppleAvailable).catch(() => setAppleAvailable(false));
+    // Clear the "Saved!" timer if the user navigates away before it fires.
+    return () => {
+      if (timeoutRef.current) clearTimeout(timeoutRef.current);
+    };
+  }, []);
+
+  const handleAppleSignIn = useCallback(async () => {
+    // Persist the current base URL first so sign-in hits the right server.
+    if (!/^https?:\/\//i.test(settings.baseUrl.trim())) {
+      Alert.alert("Invalid URL", "Base URL must start with http:// or https://");
+      return;
+    }
+    setSigningIn(true);
+    try {
+      await saveSettings(settings);
+      const acct = await signInWithApple();
+      setAccount(acct);
+    } catch (err) {
+      const msg = err instanceof Error ? err.message : String(err);
+      // The user cancelling the native sheet is not an error worth alerting.
+      if (!/canceled|cancelled|ERR_REQUEST_CANCELED/i.test(msg)) {
+        Alert.alert("Sign in failed", msg);
+      }
+    } finally {
+      setSigningIn(false);
+    }
+  }, [settings]);
+
+  const handleSignOut = useCallback(async () => {
+    try {
+      await signOut();
+      setAccount(null);
+    } catch {
+      Alert.alert("Sign out failed", "Could not clear the session. Try again.");
+    }
+  }, []);
+
+  const handleSave = useCallback(async () => {
+    if (!/^https?:\/\//i.test(settings.baseUrl.trim())) {
+      Alert.alert("Invalid URL", "Base URL must start with http:// or https://");
+      return;
+    }
+    try {
+      await saveSettings(settings);
+      setSaved(true);
+      setTestResult(null);
+      if (timeoutRef.current) clearTimeout(timeoutRef.current);
+      timeoutRef.current = setTimeout(() => setSaved(false), 2000);
+    } catch {
+      Alert.alert(
+        "Save failed",
+        "Could not save settings to secure storage. Check device storage and try again."
+      );
+    }
+  }, [settings]);
+
+  const handleTest = useCallback(async () => {
+    if (!/^https?:\/\//i.test(settings.baseUrl.trim())) {
+      Alert.alert("Invalid URL", "Base URL must start with http:// or https://");
+      return;
+    }
+    setTesting(true);
+    setTestResult(null);
+    try {
+      // Save first so the test uses current values
+      await saveSettings(settings);
+      const result = await testConnection();
+      setTestResult(result);
+    } catch {
+      setTestResult({ ok: false, latencyMs: 0, error: "Could not save settings before testing." });
+    } finally {
+      setTesting(false);
+    }
+  }, [settings]);
+
+  return (
+    <SafeAreaView style={styles.root} edges={["bottom"]}>
+      <KeyboardAvoidingView
+        behavior={Platform.OS === "ios" ? "padding" : "height"}
+        style={{ flex: 1 }}
+      >
+        <ScrollView contentContainerStyle={styles.content} keyboardShouldPersistTaps="handled">
+
+          {/* Account — Sign in with Apple (optional) */}
+          <View style={styles.section}>
+            <Text style={styles.sectionTitle}>Account</Text>
+            {account ? (
+              <View style={styles.accountCard}>
+                <Text style={styles.accountName}>
+                  {account.name || "Signed in with Apple"}
+                </Text>
+                {account.email ? (
+                  <Text style={styles.accountEmail}>{account.email}</Text>
+                ) : null}
+                <Pressable
+                  style={[styles.btn, styles.btnSecondary, { marginTop: Spacing.sm }]}
+                  onPress={handleSignOut}
+                >
+                  <Text style={styles.btnText}>Sign Out</Text>
+                </Pressable>
+              </View>
+            ) : (
+              <>
+                <Text style={styles.sectionNote}>
+                  Sign in to save preferences across devices. Browsing works without
+                  an account.
+                </Text>
+                {appleAvailable ? (
+                  signingIn ? (
+                    <View style={[styles.btn, styles.appleButton]}>
+                      <ActivityIndicator size="small" color="#000" />
+                    </View>
+                  ) : (
+                    <AppleAuthentication.AppleAuthenticationButton
+                      buttonType={
+                        AppleAuthentication.AppleAuthenticationButtonType.SIGN_IN
+                      }
+                      buttonStyle={
+                        AppleAuthentication.AppleAuthenticationButtonStyle.WHITE
+                      }
+                      cornerRadius={Radius.md}
+                      style={styles.appleButton}
+                      onPress={handleAppleSignIn}
+                    />
+                  )
+                ) : (
+                  <Text style={styles.fieldHint}>
+                    Sign in with Apple is available on iOS devices.
+                  </Text>
+                )}
+              </>
+            )}
+          </View>
+
+          {/* API Config */}
+          <View style={styles.section}>
+            <Text style={styles.sectionTitle}>API Configuration</Text>
+
+            <View style={styles.field}>
+              <Text style={styles.fieldLabel}>Base URL</Text>
+              <TextInput
+                style={styles.input}
+                value={settings.baseUrl}
+                onChangeText={(v) => setSettings((s) => ({ ...s, baseUrl: v }))}
+                placeholder="https://auctions.agentabrams.com"
+                placeholderTextColor={Colors.textMuted}
+                autoCapitalize="none"
+                autoCorrect={false}
+                keyboardType="url"
+              />
+              <Text style={styles.fieldHint}>
+                Points at the GovArbitrage API. Change only to use a different server.
+              </Text>
+            </View>
+          </View>
+
+          {/* Auth */}
+          <View style={styles.section}>
+            <Text style={styles.sectionTitle}>Authentication (optional)</Text>
+            <Text style={styles.sectionNote}>
+              Leave blank to use the default server — its catalog is public, so no
+              login is needed. Only fill these in if you point the app at your own
+              self-hosted server. When set, they are sent as an Authorization:
+              Basic header and stored encrypted in device Secure Store.
+            </Text>
+
+            <View style={styles.field}>
+              <Text style={styles.fieldLabel}>Username</Text>
+              <TextInput
+                style={styles.input}
+                value={settings.username}
+                onChangeText={(v) => setSettings((s) => ({ ...s, username: v }))}
+                placeholder="admin"
+                placeholderTextColor={Colors.textMuted}
+                autoCapitalize="none"
+                autoCorrect={false}
+              />
+            </View>
+
+            <View style={styles.field}>
+              <Text style={styles.fieldLabel}>Password</Text>
+              <TextInput
+                style={styles.input}
+                value={settings.password}
+                onChangeText={(v) => setSettings((s) => ({ ...s, password: v }))}
+                placeholder="••••••••"
+                placeholderTextColor={Colors.textMuted}
+                secureTextEntry
+                autoCapitalize="none"
+                autoCorrect={false}
+              />
+            </View>
+          </View>
+
+          {/* Save button */}
+          <Pressable
+            style={[styles.btn, styles.btnPrimary, saved && styles.btnSuccess]}
+            onPress={handleSave}
+          >
+            <Text style={styles.btnText}>{saved ? "Saved!" : "Save Settings"}</Text>
+          </Pressable>
+
+          {/* Connection test */}
+          <View style={styles.section}>
+            <Text style={styles.sectionTitle}>Connection Test</Text>
+
+            <Pressable
+              style={[styles.btn, styles.btnSecondary]}
+              onPress={handleTest}
+              disabled={testing}
+            >
+              {testing ? (
+                <ActivityIndicator size="small" color={Colors.textPrimary} />
+              ) : (
+                <Text style={styles.btnText}>Test Connection</Text>
+              )}
+            </Pressable>
+
+            {testResult && (
+              <View
+                style={[
+                  styles.testResult,
+                  { borderColor: testResult.ok ? Colors.profit : Colors.loss },
+                ]}
+              >
+                <View style={styles.testResultRow}>
+                  <View
+                    style={[
+                      styles.statusDot,
+                      { backgroundColor: testResult.ok ? Colors.profit : Colors.loss },
+                    ]}
+                  />
+                  <Text style={styles.testResultStatus}>
+                    {testResult.ok ? "Connected" : "Failed"}
+                  </Text>
+                  <Text style={styles.testResultLatency}>{testResult.latencyMs}ms</Text>
+                </View>
+                {testResult.tier && (
+                  <Text style={styles.testResultDetail}>Tier: {testResult.tier}</Text>
+                )}
+                {testResult.error && (
+                  <Text style={styles.testResultError}>{testResult.error}</Text>
+                )}
+              </View>
+            )}
+          </View>
+
+          {/* App info */}
+          <View style={styles.section}>
+            <Text style={styles.sectionTitle}>About</Text>
+            <Text style={styles.infoLine}>GovArbitrage Mobile</Text>
+            <Text style={styles.infoLine}>com.abrams.govarbitrage</Text>
+            <Text style={styles.infoLine}>Expo SDK 57 / React Native 0.86</Text>
+          </View>
+
+        </ScrollView>
+      </KeyboardAvoidingView>
+    </SafeAreaView>
+  );
+}
+
+const styles = StyleSheet.create({
+  root: {
+    flex: 1,
+    backgroundColor: Colors.bg,
+  },
+  content: {
+    padding: Spacing.lg,
+    gap: Spacing.lg,
+    paddingBottom: Spacing.xxl,
+  },
+  section: {
+    gap: Spacing.md,
+  },
+  sectionTitle: {
+    fontSize: Typography.sizes.xs,
+    fontWeight: "700",
+    color: Colors.textMuted,
+    textTransform: "uppercase",
+    letterSpacing: 1,
+    marginBottom: Spacing.xs,
+  },
+  sectionNote: {
+    fontSize: Typography.sizes.xs,
+    color: Colors.textSecondary,
+    lineHeight: 16,
+  },
+  field: {
+    gap: Spacing.xs,
+  },
+  fieldLabel: {
+    fontSize: Typography.sizes.sm,
+    color: Colors.textSecondary,
+    fontWeight: "600",
+  },
+  input: {
+    backgroundColor: Colors.surface,
+    borderWidth: 1,
+    borderColor: Colors.border,
+    borderRadius: Radius.md,
+    paddingHorizontal: Spacing.md,
+    paddingVertical: Spacing.sm + 2,
+    fontSize: Typography.sizes.base,
+    color: Colors.textPrimary,
+    fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace",
+  },
+  fieldHint: {
+    fontSize: Typography.sizes.xs,
+    color: Colors.textMuted,
+  },
+  btn: {
+    paddingVertical: Spacing.md,
+    borderRadius: Radius.md,
+    alignItems: "center",
+    justifyContent: "center",
+    minHeight: 44,
+  },
+  btnPrimary: {
+    backgroundColor: Colors.accent,
+  },
+  btnSecondary: {
+    backgroundColor: Colors.surfaceAlt,
+    borderWidth: 1,
+    borderColor: Colors.border,
+  },
+  btnSuccess: {
+    backgroundColor: Colors.profit,
+  },
+  btnText: {
+    fontSize: Typography.sizes.base,
+    fontWeight: "700",
+    color: Colors.textPrimary,
+  },
+  testResult: {
+    backgroundColor: Colors.surface,
+    borderWidth: 1,
+    borderRadius: Radius.md,
+    padding: Spacing.md,
+    gap: Spacing.sm,
+  },
+  testResultRow: {
+    flexDirection: "row",
+    alignItems: "center",
+    gap: Spacing.sm,
+  },
+  statusDot: {
+    width: 10,
+    height: 10,
+    borderRadius: 5,
+  },
+  testResultStatus: {
+    fontSize: Typography.sizes.base,
+    fontWeight: "700",
+    color: Colors.textPrimary,
+    flex: 1,
+  },
+  testResultLatency: {
+    fontSize: Typography.sizes.sm,
+    color: Colors.textMuted,
+    fontVariant: ["tabular-nums"],
+  },
+  testResultDetail: {
+    fontSize: Typography.sizes.sm,
+    color: Colors.textSecondary,
+  },
+  testResultError: {
+    fontSize: Typography.sizes.sm,
+    color: Colors.loss,
+    lineHeight: 18,
+  },
+  infoLine: {
+    fontSize: Typography.sizes.sm,
+    color: Colors.textMuted,
+    fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace",
+  },
+  appleButton: {
+    height: 48,
+    width: "100%",
+  },
+  accountCard: {
+    backgroundColor: Colors.surface,
+    borderWidth: 1,
+    borderColor: Colors.border,
+    borderRadius: Radius.md,
+    padding: Spacing.md,
+    gap: Spacing.xs,
+  },
+  accountName: {
+    fontSize: Typography.sizes.base,
+    fontWeight: "700",
+    color: Colors.textPrimary,
+  },
+  accountEmail: {
+    fontSize: Typography.sizes.sm,
+    color: Colors.textMuted,
+  },
+});
diff --git a/apps/mobile/app/_layout.tsx b/apps/mobile/app/_layout.tsx
new file mode 100644
index 0000000..44fd50e
--- /dev/null
+++ b/apps/mobile/app/_layout.tsx
@@ -0,0 +1,40 @@
+import { Stack } from "expo-router";
+import { StatusBar } from "expo-status-bar";
+import { StyleSheet, View } from "react-native";
+import { Colors } from "../constants/theme";
+import { ErrorBoundary } from "../components/ErrorBoundary";
+
+export default function RootLayout() {
+  return (
+    <View style={styles.root}>
+      <StatusBar style="light" />
+      <ErrorBoundary>
+      <Stack
+        screenOptions={{
+          headerStyle: { backgroundColor: Colors.surface },
+          headerTintColor: Colors.textPrimary,
+          headerTitleStyle: { fontWeight: "700", color: Colors.textPrimary },
+          contentStyle: { backgroundColor: Colors.bg },
+          headerShadowVisible: false,
+        }}
+      >
+        <Stack.Screen name="(tabs)" options={{ headerShown: false }} />
+        <Stack.Screen
+          name="listing/[id]"
+          options={{
+            title: "Listing Detail",
+            headerBackTitle: "Back",
+          }}
+        />
+      </Stack>
+      </ErrorBoundary>
+    </View>
+  );
+}
+
+const styles = StyleSheet.create({
+  root: {
+    flex: 1,
+    backgroundColor: Colors.bg,
+  },
+});
diff --git a/apps/mobile/app/listing/[id].tsx b/apps/mobile/app/listing/[id].tsx
new file mode 100644
index 0000000..a7bcbe7
--- /dev/null
+++ b/apps/mobile/app/listing/[id].tsx
@@ -0,0 +1,679 @@
+/**
+ * Listing detail screen — full Valuation table, Cost Breakdown & Profit,
+ * Scores grid, Recommended Max Bid callout, risk + drop-ship badges,
+ * and link out to the original auction URL.
+ */
+import { useLocalSearchParams, useNavigation } from "expo-router";
+import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
+import {
+  ActivityIndicator,
+  Image,
+  Linking,
+  Platform,
+  Pressable,
+  ScrollView,
+  StyleSheet,
+  Text,
+  View,
+} from "react-native";
+import { SafeAreaView } from "react-native-safe-area-context";
+import { ScoreBadge } from "../../components/ScoreBadge";
+import { ErrorCard } from "../../components/ErrorCard";
+import { Colors, Radius, Spacing, Typography } from "../../constants/theme";
+import { fetchListing } from "../../lib/api";
+import {
+  closingCountdown,
+  conditionLabel,
+  dropShipLabel,
+  fmtDateTime,
+  fmtPct,
+  fmtScore,
+  fmtUSD,
+  isClosingSoon,
+  sourceLabel,
+} from "../../lib/format";
+import type { ListingDetail, Score } from "../../lib/types";
+import { pnlColor } from "../../lib/pnl";
+
+// ── Sub-components ────────────────────────────────────────────────────────────
+
+function SectionHeader({ title }: { title: string }) {
+  return (
+    <View style={styles.sectionHeader}>
+      <Text style={styles.sectionHeaderText}>{title}</Text>
+    </View>
+  );
+}
+
+function TableRow({
+  label,
+  value,
+  valueColor,
+  mono = false,
+}: {
+  label: string;
+  value: string;
+  valueColor?: string;
+  mono?: boolean;
+}) {
+  return (
+    <View style={styles.tableRow}>
+      <Text style={styles.tableLabel}>{label}</Text>
+      <Text
+        style={[
+          styles.tableValue,
+          valueColor ? { color: valueColor } : undefined,
+          mono ? styles.tableMono : undefined,
+        ]}
+      >
+        {value}
+      </Text>
+    </View>
+  );
+}
+
+function riskColor(risk: string) {
+  if (risk === "LOW") return Colors.riskLow;
+  if (risk === "HIGH") return Colors.riskHigh;
+  return Colors.riskMedium;
+}
+
+// Horizontal image strip for the hero. Broken URLs drop out silently (onError),
+// so a dead image never leaves a gray box on the screen.
+function HeroImageStrip({ urls }: { urls: string[] }) {
+  const [failed, setFailed] = useState<Set<string>>(new Set());
+  const visible = urls.filter((u) => u && !failed.has(u));
+  if (visible.length === 0) return null;
+  return (
+    <ScrollView
+      horizontal
+      showsHorizontalScrollIndicator={false}
+      contentContainerStyle={styles.imageStrip}
+      accessibilityLabel="Listing photos"
+    >
+      {visible.map((u) => (
+        <Image
+          key={u}
+          source={{ uri: u }}
+          style={styles.heroImage}
+          resizeMode="cover"
+          onError={() =>
+            setFailed((prev) => {
+              const next = new Set(prev);
+              next.add(u);
+              return next;
+            })
+          }
+        />
+      ))}
+    </ScrollView>
+  );
+}
+
+// ── Main screen ───────────────────────────────────────────────────────────────
+
+export default function ListingDetailScreen() {
+  const { id } = useLocalSearchParams<{ id: string }>();
+  const navigation = useNavigation();
+  const [detail, setDetail] = useState<ListingDetail | null>(null);
+  const [loading, setLoading] = useState(true);
+  const [error, setError] = useState<string | null>(null);
+
+  const abortRef = useRef<AbortController | null>(null);
+
+  const load = useCallback(async () => {
+    if (!id) return;
+    abortRef.current?.abort();
+    const ctrl = new AbortController();
+    abortRef.current = ctrl;
+    setLoading(true);
+    setError(null);
+    try {
+      const data = await fetchListing(id, { signal: ctrl.signal });
+      setDetail(data);
+    } catch (err: unknown) {
+      if (err instanceof Error && err.name === "AbortError") return;
+      setError(err instanceof Error ? err.message : "Failed to load listing");
+    } finally {
+      // Skip the state update if this request was superseded/unmounted.
+      if (!ctrl.signal.aborted) setLoading(false);
+    }
+  }, [id]);
+
+  useEffect(() => {
+    load();
+    return () => abortRef.current?.abort();
+  }, [load]);
+
+  // Update nav title once we have the detail
+  useLayoutEffect(() => {
+    if (detail?.title) {
+      navigation.setOptions({ title: detail.title.slice(0, 40) });
+    }
+  }, [detail, navigation]);
+
+  if (loading) {
+    return (
+      <SafeAreaView style={styles.root} edges={["bottom"]}>
+        <View style={styles.center}>
+          <ActivityIndicator size="large" color={Colors.accent} />
+        </View>
+      </SafeAreaView>
+    );
+  }
+
+  if (error || !detail) {
+    return (
+      <SafeAreaView style={styles.root} edges={["bottom"]}>
+        <ErrorCard message={error ?? "Listing not found"} onRetry={load} />
+      </SafeAreaView>
+    );
+  }
+
+  const cb = detail.costBreakdown;
+  const r = detail.research;
+  const primaryScore: Score | undefined = detail.scores[0];
+  const countdown = closingCountdown(detail.closingAt);
+  const soon = isClosingSoon(detail.closingAt);
+
+  return (
+    <SafeAreaView style={styles.root} edges={["bottom"]}>
+      <ScrollView contentContainerStyle={styles.content}>
+
+        {/* ── Hero block ─────────────────────────────────────────────────── */}
+        <View style={styles.heroCard}>
+          <View style={styles.heroTopRow}>
+            <View style={styles.sourceChip}>
+              <Text style={styles.sourceChipText}>{sourceLabel(detail.source)}</Text>
+            </View>
+            <Text style={styles.lotLabel}>Lot #{detail.sourceAuctionId}</Text>
+            <View style={styles.spacer} />
+            <Text style={[styles.countdown, soon && styles.countdownUrgent]}>
+              {countdown}
+            </Text>
+          </View>
+
+          <Text style={styles.heroTitle}>{detail.title}</Text>
+
+          {/* Location + condition */}
+          <View style={styles.heroMeta}>
+            {detail.locationCity && (
+              <Text style={styles.heroMetaItem}>
+                {detail.locationCity}
+                {detail.locationState ? `, ${detail.locationState}` : ""}
+              </Text>
+            )}
+            <Text style={styles.heroMetaItem}>{conditionLabel(detail.condition)}</Text>
+            {detail.quantity > 1 && (
+              <Text style={styles.heroMetaItem}>Qty: {detail.quantity}</Text>
+            )}
+          </View>
+
+          {/* Risk + dropship badges */}
+          {primaryScore && (
+            <View style={styles.badgeRow}>
+              <View
+                style={[styles.riskBadge, { backgroundColor: riskColor(primaryScore.risk) + "22", borderColor: riskColor(primaryScore.risk) }]}
+              >
+                <Text style={[styles.riskBadgeText, { color: riskColor(primaryScore.risk) }]}>
+                  {primaryScore.risk} RISK
+                </Text>
+              </View>
+              <View style={styles.dropShipBadge}>
+                <Text style={styles.dropShipText}>{dropShipLabel(primaryScore.dropShip)}</Text>
+              </View>
+            </View>
+          )}
+
+          {/* Admin timestamp — Steve's hard rule */}
+          <Text style={styles.createdAt} accessibilityLabel={`Imported ${detail.createdAt}`}>
+            Imported {fmtDateTime(detail.createdAt)}
+          </Text>
+        </View>
+
+        {/* ── Listing photos (broken URLs drop out silently) ─────────────── */}
+        {detail.imageUrls && detail.imageUrls.length > 0 && (
+          <HeroImageStrip urls={detail.imageUrls} />
+        )}
+
+        {/* ── Recommended Max Bid CALLOUT ────────────────────────────────── */}
+        {cb && (
+          <View style={styles.maxBidCallout}>
+            <Text style={styles.maxBidLabel}>Recommended Max Bid</Text>
+            <Text style={styles.maxBidValue}>{fmtUSD(cb.recommendedMaxBid)}</Text>
+            <Text style={styles.maxBidSub}>Current bid: {fmtUSD(detail.currentBid)} ({detail.bidCount} bids)</Text>
+          </View>
+        )}
+
+        {/* ── External link ──────────────────────────────────────────────── */}
+        {detail.sourceUrl && (
+          <Pressable
+            style={styles.linkBtn}
+            onPress={() => Linking.openURL(detail.sourceUrl!)}
+            accessibilityRole="link"
+            accessibilityLabel={`View this listing on ${sourceLabel(detail.source)}`}
+          >
+            <Text style={styles.linkBtnText}>View on {sourceLabel(detail.source)} →</Text>
+          </Pressable>
+        )}
+
+        {/* ── Valuation block ────────────────────────────────────────────── */}
+        {r ? (
+          <>
+            <SectionHeader title="Valuation" />
+            <View style={styles.card}>
+              <TableRow label="New Retail" value={fmtUSD(r.newRetail)} />
+              <TableRow label="Avg Retail" value={fmtUSD(r.avgRetail)} />
+              <TableRow label="Used — Low" value={fmtUSD(r.usedLow)} />
+              <TableRow label="Used — Avg" value={fmtUSD(r.usedSoldPrice)} />
+              <TableRow label="Used — High" value={fmtUSD(r.usedHigh)} />
+              <TableRow label="Wholesale" value={fmtUSD(r.wholesaleValue)} />
+              <TableRow label="Liquidation" value={fmtUSD(r.liquidationValue)} />
+              <TableRow label="Sell Today" value={fmtUSD(r.sellTodayValue)} />
+              <TableRow label="Expected Sale" value={fmtUSD(r.expectedSalePrice)} valueColor={Colors.profit} />
+              <TableRow
+                label="Prob. of Sale"
+                value={fmtPct(r.probabilityOfSale, { decimals: 0 })}
+              />
+              <TableRow
+                label="Days to Sell"
+                value={r.daysUntilSold != null && Number.isFinite(r.daysUntilSold) ? `${r.daysUntilSold}d` : "—"}
+              />
+              <TableRow
+                label="Confidence"
+                value={fmtScore(r.confidenceScore)}
+              />
+            </View>
+          </>
+        ) : (
+          detail.gated && (
+            <View style={styles.gatedBlock}>
+              <Text style={styles.gatedText}>Valuation data requires a paid tier.</Text>
+            </View>
+          )
+        )}
+
+        {/* ── Cost Breakdown & Profit ────────────────────────────────────── */}
+        {cb ? (
+          <>
+            <SectionHeader title="Cost Breakdown & Profit" />
+            <View style={styles.card}>
+              <TableRow label="Winning Bid" value={fmtUSD(cb.winningBid)} mono />
+              <TableRow label="Buyer Premium" value={fmtUSD(cb.buyerPremium)} mono />
+              <TableRow label="Sales Tax" value={fmtUSD(cb.salesTax)} mono />
+              <TableRow label="Shipping" value={fmtUSD(cb.shipping)} mono />
+              <TableRow label="Freight" value={fmtUSD(cb.freight)} mono />
+              <TableRow label="Repairs" value={fmtUSD(cb.repairs)} mono />
+              <TableRow label="Marketplace Fees" value={fmtUSD(cb.marketplaceFees)} mono />
+              <View style={styles.divider} />
+              <TableRow label="Total Investment" value={fmtUSD(cb.totalInvestment)} valueColor={Colors.warning} />
+              <TableRow label="Expected Returns" value={fmtUSD(cb.expectedReturns)} valueColor={Colors.profit} />
+              <TableRow
+                label="Net Profit"
+                value={fmtUSD(cb.expectedNetProfit)}
+                valueColor={pnlColor(cb.expectedNetProfit)}
+              />
+              <TableRow
+                label="ROI"
+                value={fmtPct(cb.roi)}
+                valueColor={pnlColor(cb.roi)}
+              />
+              <TableRow
+                label="Annualized Return"
+                value={fmtPct(cb.annualizedReturn)}
+              />
+            </View>
+          </>
+        ) : (
+          detail.gated && (
+            <View style={styles.gatedBlock}>
+              <Text style={styles.gatedText}>Cost breakdown requires a paid tier.</Text>
+            </View>
+          )
+        )}
+
+        {/* ── Scores grid ───────────────────────────────────────────────── */}
+        {detail.scores.length > 0 && (
+          <>
+            <SectionHeader title="Scores" />
+            {detail.scores.map((sc) => (
+              <View key={sc.id} style={styles.card}>
+                <Text style={styles.scoreProfileLabel}>{sc.profile.replace(/_/g, " ")}</Text>
+                <View style={styles.scoresGrid}>
+                  <ScoreBadge label="Opportunity" score={sc.value} size="lg" />
+                  <ScoreBadge label="Arbitrage" score={sc.arbitrage} />
+                  <ScoreBadge label="Demand" score={sc.demand} />
+                  <ScoreBadge label="Velocity" score={sc.velocity} />
+                  <ScoreBadge label="Logistics" score={sc.logistics} />
+                  <ScoreBadge label="Condition" score={sc.condition} />
+                  <ScoreBadge label="Competition" score={sc.competition} />
+                  <ScoreBadge label="Buyer" score={sc.buyer} />
+                </View>
+                {sc.explanation ? (
+                  <Text style={styles.scoreExplanation}>{sc.explanation}</Text>
+                ) : null}
+              </View>
+            ))}
+          </>
+        )}
+
+        {/* ── Description ───────────────────────────────────────────────── */}
+        {detail.description && (
+          <>
+            <SectionHeader title="Description" />
+            <View style={styles.card}>
+              <Text style={styles.description}>{detail.description}</Text>
+            </View>
+          </>
+        )}
+
+        {/* ── Comparables ──────────────────────────────────────────────── */}
+        {detail.comparables.length > 0 && (
+          <>
+            <SectionHeader title={`Comparables (${detail.comparables.length})`} />
+            <View style={styles.card}>
+              {detail.comparables.map((comp) => (
+                <View key={comp.id} style={styles.tableRow}>
+                  <View style={{ flex: 1 }}>
+                    <Text style={styles.compTitle} numberOfLines={1}>{comp.title}</Text>
+                    <Text style={styles.compMeta}>
+                      {comp.kind} · {comp.source ?? "unknown"}
+                      {comp.soldAt ? ` · ${fmtDateTime(comp.soldAt)}` : ""}
+                    </Text>
+                  </View>
+                  <Text style={[styles.tableValue, { color: Colors.profit }]}>
+                    {fmtUSD(comp.price)}
+                  </Text>
+                </View>
+              ))}
+            </View>
+          </>
+        )}
+
+      </ScrollView>
+    </SafeAreaView>
+  );
+}
+
+// ── Styles ────────────────────────────────────────────────────────────────────
+
+const styles = StyleSheet.create({
+  root: {
+    flex: 1,
+    backgroundColor: Colors.bg,
+  },
+  center: {
+    flex: 1,
+    alignItems: "center",
+    justifyContent: "center",
+  },
+  content: {
+    paddingBottom: Spacing.xxl,
+    gap: Spacing.xs,
+  },
+
+  // Hero
+  heroCard: {
+    backgroundColor: Colors.surface,
+    borderBottomWidth: 1,
+    borderBottomColor: Colors.border,
+    padding: Spacing.lg,
+    gap: Spacing.sm,
+  },
+  heroTopRow: {
+    flexDirection: "row",
+    alignItems: "center",
+    gap: Spacing.sm,
+  },
+  sourceChip: {
+    paddingHorizontal: Spacing.sm,
+    paddingVertical: 2,
+    backgroundColor: Colors.accent + "22",
+    borderRadius: Radius.pill,
+    borderWidth: 1,
+    borderColor: Colors.accent,
+  },
+  sourceChipText: {
+    fontSize: Typography.sizes.xs,
+    color: Colors.accent,
+    fontWeight: "600",
+    textTransform: "uppercase",
+    letterSpacing: 0.5,
+  },
+  lotLabel: {
+    fontSize: Typography.sizes.xs,
+    color: Colors.textMuted,
+    fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace",
+  },
+  spacer: { flex: 1 },
+  countdown: {
+    fontSize: Typography.sizes.sm,
+    color: Colors.textSecondary,
+    fontWeight: "700",
+    fontVariant: ["tabular-nums"],
+  },
+  countdownUrgent: {
+    color: Colors.warning,
+  },
+  heroTitle: {
+    fontSize: Typography.sizes.lg,
+    color: Colors.textPrimary,
+    fontWeight: "700",
+    lineHeight: 26,
+  },
+  heroMeta: {
+    flexDirection: "row",
+    flexWrap: "wrap",
+    gap: Spacing.sm,
+  },
+  heroMetaItem: {
+    fontSize: Typography.sizes.sm,
+    color: Colors.textSecondary,
+  },
+  badgeRow: {
+    flexDirection: "row",
+    gap: Spacing.sm,
+    flexWrap: "wrap",
+  },
+  riskBadge: {
+    paddingHorizontal: Spacing.sm,
+    paddingVertical: Spacing.xs,
+    borderRadius: Radius.sm,
+    borderWidth: 1,
+  },
+  riskBadgeText: {
+    fontSize: Typography.sizes.xs,
+    fontWeight: "700",
+    letterSpacing: 0.5,
+  },
+  dropShipBadge: {
+    paddingHorizontal: Spacing.sm,
+    paddingVertical: Spacing.xs,
+    borderRadius: Radius.sm,
+    backgroundColor: Colors.surfaceAlt,
+    borderWidth: 1,
+    borderColor: Colors.border,
+  },
+  dropShipText: {
+    fontSize: Typography.sizes.xs,
+    color: Colors.textSecondary,
+    fontWeight: "600",
+  },
+  createdAt: {
+    fontSize: Typography.sizes.xs,
+    color: Colors.textMuted,
+    marginTop: Spacing.xs,
+  },
+
+  // Hero image strip
+  imageStrip: {
+    paddingHorizontal: Spacing.lg,
+    paddingTop: Spacing.md,
+    gap: Spacing.sm,
+  },
+  heroImage: {
+    width: 280,
+    height: 200,
+    borderRadius: Radius.lg,
+    backgroundColor: Colors.surface,
+  },
+
+  // Max bid callout
+  maxBidCallout: {
+    backgroundColor: Colors.accent + "18",
+    borderWidth: 1,
+    borderColor: Colors.accent,
+    borderRadius: Radius.lg,
+    margin: Spacing.lg,
+    padding: Spacing.lg,
+    alignItems: "center",
+    gap: Spacing.xs,
+  },
+  maxBidLabel: {
+    fontSize: Typography.sizes.xs,
+    color: Colors.accent,
+    fontWeight: "700",
+    textTransform: "uppercase",
+    letterSpacing: 1,
+  },
+  maxBidValue: {
+    fontSize: Typography.sizes.xxl,
+    color: Colors.textPrimary,
+    fontWeight: "700",
+    fontVariant: ["tabular-nums"],
+  },
+  maxBidSub: {
+    fontSize: Typography.sizes.sm,
+    color: Colors.textSecondary,
+  },
+
+  // External link
+  linkBtn: {
+    marginHorizontal: Spacing.lg,
+    paddingVertical: Spacing.md,
+    borderRadius: Radius.md,
+    borderWidth: 1,
+    borderColor: Colors.accent,
+    alignItems: "center",
+  },
+  linkBtnText: {
+    fontSize: Typography.sizes.base,
+    color: Colors.accent,
+    fontWeight: "600",
+  },
+
+  // Section headers
+  sectionHeader: {
+    paddingHorizontal: Spacing.lg,
+    paddingTop: Spacing.md,
+    paddingBottom: Spacing.xs,
+  },
+  sectionHeaderText: {
+    fontSize: Typography.sizes.xs,
+    fontWeight: "700",
+    color: Colors.textMuted,
+    textTransform: "uppercase",
+    letterSpacing: 1,
+  },
+
+  // Cards / tables
+  card: {
+    backgroundColor: Colors.surface,
+    borderTopWidth: 1,
+    borderBottomWidth: 1,
+    borderColor: Colors.border,
+    paddingVertical: Spacing.xs,
+  },
+  tableRow: {
+    flexDirection: "row",
+    justifyContent: "space-between",
+    alignItems: "center",
+    paddingHorizontal: Spacing.lg,
+    paddingVertical: Spacing.sm,
+    borderBottomWidth: StyleSheet.hairlineWidth,
+    borderBottomColor: Colors.border,
+  },
+  tableLabel: {
+    fontSize: Typography.sizes.sm,
+    color: Colors.textSecondary,
+    flex: 1,
+  },
+  tableValue: {
+    fontSize: Typography.sizes.sm,
+    color: Colors.textPrimary,
+    fontWeight: "600",
+    fontVariant: ["tabular-nums"],
+    textAlign: "right",
+  },
+  tableMono: {
+    fontFamily: Platform.OS === "ios" ? "Menlo" : "monospace",
+  },
+  divider: {
+    height: 1,
+    backgroundColor: Colors.borderStrong,
+    marginVertical: Spacing.xs,
+    marginHorizontal: Spacing.lg,
+  },
+
+  // Scores
+  scoreProfileLabel: {
+    fontSize: Typography.sizes.xs,
+    color: Colors.textMuted,
+    textTransform: "uppercase",
+    letterSpacing: 1,
+    paddingHorizontal: Spacing.lg,
+    paddingTop: Spacing.md,
+    paddingBottom: Spacing.sm,
+  },
+  scoresGrid: {
+    flexDirection: "row",
+    flexWrap: "wrap",
+    paddingHorizontal: Spacing.lg,
+    paddingBottom: Spacing.md,
+    gap: Spacing.xl,
+  },
+  scoreExplanation: {
+    fontSize: Typography.sizes.xs,
+    color: Colors.textSecondary,
+    lineHeight: 16,
+    paddingHorizontal: Spacing.lg,
+    paddingBottom: Spacing.md,
+    fontStyle: "italic",
+  },
+
+  // Description
+  description: {
+    fontSize: Typography.sizes.sm,
+    color: Colors.textSecondary,
+    lineHeight: 20,
+    paddingHorizontal: Spacing.lg,
+    paddingVertical: Spacing.md,
+  },
+
+  // Comparables
+  compTitle: {
+    fontSize: Typography.sizes.sm,
+    color: Colors.textPrimary,
+    fontWeight: "500",
+  },
+  compMeta: {
+    fontSize: Typography.sizes.xs,
+    color: Colors.textMuted,
+  },
+
+  // Gated
+  gatedBlock: {
+    marginHorizontal: Spacing.lg,
+    padding: Spacing.lg,
+    backgroundColor: Colors.surface,
+    borderRadius: Radius.md,
+    borderWidth: 1,
+    borderColor: Colors.border,
+    alignItems: "center",
+  },
+  gatedText: {
+    fontSize: Typography.sizes.sm,
+    color: Colors.textMuted,
+    fontStyle: "italic",
+  },
+});
diff --git a/apps/mobile/assets/android-icon-background.png b/apps/mobile/assets/android-icon-background.png
new file mode 100644
index 0000000..5ffefc5
Binary files /dev/null and b/apps/mobile/assets/android-icon-background.png differ
diff --git a/apps/mobile/assets/android-icon-foreground.png b/apps/mobile/assets/android-icon-foreground.png
new file mode 100644
index 0000000..3a9e501
Binary files /dev/null and b/apps/mobile/assets/android-icon-foreground.png differ
diff --git a/apps/mobile/assets/android-icon-monochrome.png b/apps/mobile/assets/android-icon-monochrome.png
new file mode 100644
index 0000000..77484eb
Binary files /dev/null and b/apps/mobile/assets/android-icon-monochrome.png differ
diff --git a/apps/mobile/assets/favicon.png b/apps/mobile/assets/favicon.png
new file mode 100644
index 0000000..408bd74
Binary files /dev/null and b/apps/mobile/assets/favicon.png differ
diff --git a/apps/mobile/assets/icon.png b/apps/mobile/assets/icon.png
new file mode 100644
index 0000000..7165a53
Binary files /dev/null and b/apps/mobile/assets/icon.png differ
diff --git a/apps/mobile/assets/splash-icon.png b/apps/mobile/assets/splash-icon.png
new file mode 100644
index 0000000..03d6f6b
Binary files /dev/null and b/apps/mobile/assets/splash-icon.png differ
diff --git a/apps/mobile/components/ErrorBoundary.tsx b/apps/mobile/components/ErrorBoundary.tsx
new file mode 100644
index 0000000..953f49c
--- /dev/null
+++ b/apps/mobile/components/ErrorBoundary.tsx
@@ -0,0 +1,61 @@
+/**
+ * Root RENDER-error net. Scope (be honest about it): React error boundaries
+ * catch errors thrown during RENDER / lifecycle of descendants only — they do
+ * NOT catch errors in event handlers, promises, or async code (those are
+ * handled by local try/catch in the fetch/auth paths). Without this, a
+ * render-phase throw unmounts the whole tree to a blank screen; this shows a
+ * branded fallback + Retry instead. Retry clears the error and re-renders the
+ * children, so it recovers a TRANSIENT error; a deterministic one (e.g. a bad
+ * API payload) re-throws — the durable fix for those is validating the payload
+ * at the api.ts boundary, not this net.
+ */
+import React from "react";
+import { StyleSheet, View } from "react-native";
+import { Colors } from "../constants/theme";
+import { ErrorCard } from "./ErrorCard";
+
+interface Props {
+  children: React.ReactNode;
+}
+
+interface State {
+  hasError: boolean;
+  message: string;
+}
+
+export class ErrorBoundary extends React.Component<Props, State> {
+  state: State = { hasError: false, message: "" };
+
+  static getDerivedStateFromError(error: unknown): State {
+    return {
+      hasError: true,
+      message: error instanceof Error && error.message ? error.message : "An unexpected error occurred.",
+    };
+  }
+
+  componentDidCatch(error: unknown, info: unknown): void {
+    // Diagnostics only — no external telemetry is wired in this app.
+    console.error("[ErrorBoundary]", error, info);
+  }
+
+  reset = (): void => this.setState({ hasError: false, message: "" });
+
+  render(): React.ReactNode {
+    if (this.state.hasError) {
+      return (
+        <View style={styles.fallback}>
+          <ErrorCard title="Something went wrong" message={this.state.message} onRetry={this.reset} />
+        </View>
+      );
+    }
+    return this.props.children;
+  }
+}
+
+const styles = StyleSheet.create({
+  fallback: {
+    flex: 1,
+    justifyContent: "center",
+    backgroundColor: Colors.bg,
+  },
+});
diff --git a/apps/mobile/components/ErrorCard.tsx b/apps/mobile/components/ErrorCard.tsx
new file mode 100644
index 0000000..607ecf9
--- /dev/null
+++ b/apps/mobile/components/ErrorCard.tsx
@@ -0,0 +1,66 @@
+import React from "react";
+import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
+import { Colors, Radius, Spacing, Typography } from "../constants/theme";
+
+interface Props {
+  message: string;
+  onRetry?: () => void;
+  title?: string;
+}
+
+export function ErrorCard({ message, onRetry, title = "Connection Error" }: Props) {
+  return (
+    <View style={styles.container}>
+      <Text style={styles.icon}>!</Text>
+      <Text style={styles.title}>{title}</Text>
+      <Text style={styles.message}>{message}</Text>
+      {onRetry && (
+        <TouchableOpacity style={styles.retryBtn} onPress={onRetry} activeOpacity={0.7}>
+          <Text style={styles.retryText}>Retry</Text>
+        </TouchableOpacity>
+      )}
+    </View>
+  );
+}
+
+const styles = StyleSheet.create({
+  container: {
+    margin: Spacing.lg,
+    padding: Spacing.xl,
+    backgroundColor: Colors.surface,
+    borderRadius: Radius.lg,
+    borderWidth: 1,
+    borderColor: Colors.loss,
+    alignItems: "center",
+  },
+  icon: {
+    fontSize: 32,
+    color: Colors.loss,
+    fontWeight: "700",
+    marginBottom: Spacing.sm,
+  },
+  title: {
+    fontSize: Typography.sizes.md,
+    color: Colors.textPrimary,
+    fontWeight: "700",
+    marginBottom: Spacing.sm,
+  },
+  message: {
+    fontSize: Typography.sizes.sm,
+    color: Colors.textSecondary,
+    textAlign: "center",
+    lineHeight: 18,
+  },
+  retryBtn: {
+    marginTop: Spacing.lg,
+    paddingHorizontal: Spacing.xl,
+    paddingVertical: Spacing.sm,
+    backgroundColor: Colors.accent,
+    borderRadius: Radius.md,
+  },
+  retryText: {
+    color: Colors.textPrimary,
+    fontSize: Typography.sizes.base,
+    fontWeight: "600",
+  },
+});
diff --git a/apps/mobile/components/OpportunityCard.tsx b/apps/mobile/components/OpportunityCard.tsx
new file mode 100644
index 0000000..88ba56e
--- /dev/null
+++ b/apps/mobile/components/OpportunityCard.tsx
@@ -0,0 +1,220 @@
+import React from "react";
+import { StyleSheet, Text, TouchableOpacity, View } from "react-native";
+import { Colors, Radius, Spacing, Typography } from "../constants/theme";
+import { closingCountdown, fmtDateTime, fmtPct, fmtScore, fmtUSD, isClosingSoon, sourceLabel } from "../lib/format";
+import type { ListingRow } from "../lib/types";
+import { pnlColor } from "../lib/pnl";
+
+interface Props {
+  row: ListingRow;
+  rank: number;
+  onPress: () => void;
+}
+
+function sourceChipColor(source: string): string {
+  if (source === "GSA_AUCTIONS") return Colors.gsaColor;
+  return Colors.govdealsColor;
+}
+
+function riskColor(risk: string): string {
+  if (risk === "LOW") return Colors.riskLow;
+  if (risk === "HIGH") return Colors.riskHigh;
+  return Colors.riskMedium;
+}
+
+export function OpportunityCard({ row, rank, onPress }: Props) {
+  const countdown = closingCountdown(row.closingAt);
+  const soon = isClosingSoon(row.closingAt);
+  const chipColor = sourceChipColor(row.source);
+
+  return (
+    <TouchableOpacity
+      style={styles.card}
+      onPress={onPress}
+      activeOpacity={0.8}
+      accessible
+      accessibilityRole="button"
+      accessibilityLabel={`${row.title}. Rank ${rank}${
+        row.opportunityScore != null ? `, opportunity score ${fmtScore(row.opportunityScore)}` : ""
+      }. Current bid ${fmtUSD(row.currentBid)}. ${countdown}.`}
+      accessibilityHint="Opens the full listing detail"
+    >
+      {/* Header row: rank + source chip + countdown */}
+      <View style={styles.headerRow}>
+        <View style={styles.rankBadge}>
+          <Text style={styles.rankText}>#{rank}</Text>
+        </View>
+        <View style={[styles.chip, { backgroundColor: chipColor + "22", borderColor: chipColor }]}>
+          <Text style={[styles.chipText, { color: chipColor }]}>{sourceLabel(row.source)}</Text>
+        </View>
+        <View style={styles.spacer} />
+        <Text style={[styles.countdown, soon && styles.countdownUrgent]}>{countdown}</Text>
+      </View>
+
+      {/* Title */}
+      <Text style={styles.title} numberOfLines={2}>{row.title}</Text>
+
+      {/* Category + location row */}
+      <View style={styles.metaRow}>
+        {row.category ? (
+          <Text style={styles.metaItem}>{row.category}</Text>
+        ) : null}
+        {row.locationCity ? (
+          <Text style={styles.metaItem}>
+            {row.locationCity}
+            {row.locationState ? `, ${row.locationState}` : ""}
+          </Text>
+        ) : null}
+        <Text style={[styles.riskBadge, { color: riskColor(row.risk) }]}>
+          {row.risk} RISK
+        </Text>
+      </View>
+
+      {/* Financial summary row */}
+      <View style={styles.financialRow}>
+        <View style={styles.financialItem}>
+          <Text style={styles.financialLabel}>Current Bid</Text>
+          <Text style={styles.financialValue}>{fmtUSD(row.currentBid)}</Text>
+        </View>
+        {row.netProfit != null && (
+          <View style={styles.financialItem}>
+            <Text style={styles.financialLabel}>Net Profit</Text>
+            <Text style={[styles.financialValue, { color: pnlColor(row.netProfit) }]}>
+              {fmtUSD(row.netProfit)}
+            </Text>
+          </View>
+        )}
+        {row.roi != null && (
+          <View style={styles.financialItem}>
+            <Text style={styles.financialLabel}>ROI</Text>
+            <Text style={[styles.financialValue, { color: pnlColor(row.roi) }]}>
+              {fmtPct(row.roi)}
+            </Text>
+          </View>
+        )}
+        {row.opportunityScore != null && (
+          <View style={styles.financialItem}>
+            <Text style={styles.financialLabel}>Score</Text>
+            <Text style={styles.scoreValue}>{fmtScore(row.opportunityScore)}</Text>
+          </View>
+        )}
+      </View>
+
+      {/* Admin timestamp (Steve's hard rule: created date+time visible on card) */}
+      {row.createdAt && (
+        <Text style={styles.createdAt} accessibilityLabel={`Imported ${row.createdAt}`}>
+          Imported {fmtDateTime(row.createdAt)}
+        </Text>
+      )}
+    </TouchableOpacity>
+  );
+}
+
+const styles = StyleSheet.create({
+  card: {
+    backgroundColor: Colors.surface,
+    borderRadius: Radius.lg,
+    borderWidth: 1,
+    borderColor: Colors.border,
+    padding: Spacing.md,
+    marginHorizontal: Spacing.lg,
+    marginVertical: Spacing.xs,
+    gap: Spacing.sm,
+  },
+  headerRow: {
+    flexDirection: "row",
+    alignItems: "center",
+    gap: Spacing.sm,
+  },
+  rankBadge: {
+    width: 28,
+    height: 28,
+    borderRadius: Radius.sm,
+    backgroundColor: Colors.rankBg,
+    borderWidth: 1,
+    borderColor: Colors.rankGold,
+    alignItems: "center",
+    justifyContent: "center",
+  },
+  rankText: {
+    fontSize: Typography.sizes.xs,
+    fontWeight: "700",
+    color: Colors.rankGold,
+  },
+  chip: {
+    paddingHorizontal: Spacing.sm,
+    paddingVertical: 2,
+    borderRadius: Radius.pill,
+    borderWidth: 1,
+  },
+  chipText: {
+    fontSize: Typography.sizes.xs,
+    fontWeight: "600",
+    textTransform: "uppercase",
+    letterSpacing: 0.5,
+  },
+  spacer: { flex: 1 },
+  countdown: {
+    fontSize: Typography.sizes.sm,
+    color: Colors.textSecondary,
+    fontVariant: ["tabular-nums"],
+    fontWeight: "600",
+  },
+  countdownUrgent: {
+    color: Colors.warning,
+  },
+  title: {
+    fontSize: Typography.sizes.base,
+    color: Colors.textPrimary,
+    fontWeight: "600",
+    lineHeight: 20,
+  },
+  metaRow: {
+    flexDirection: "row",
+    flexWrap: "wrap",
+    gap: Spacing.sm,
+    alignItems: "center",
+  },
+  metaItem: {
+    fontSize: Typography.sizes.xs,
+    color: Colors.textSecondary,
+  },
+  riskBadge: {
+    fontSize: 10,
+    fontWeight: "700",
+    letterSpacing: 0.5,
+  },
+  financialRow: {
+    flexDirection: "row",
+    gap: Spacing.lg,
+    paddingTop: Spacing.xs,
+    borderTopWidth: 1,
+    borderTopColor: Colors.border,
+  },
+  financialItem: {
+    gap: 2,
+  },
+  financialLabel: {
+    fontSize: Typography.sizes.xs,
+    color: Colors.textMuted,
+    textTransform: "uppercase",
+    letterSpacing: 0.5,
+  },
+  financialValue: {
+    fontSize: Typography.sizes.md,
+    fontWeight: "700",
+    color: Colors.textPrimary,
+    fontVariant: ["tabular-nums"],
+  },
+  scoreValue: {
+    fontSize: Typography.sizes.md,
+    fontWeight: "700",
+    color: Colors.accent,
+    fontVariant: ["tabular-nums"],
+  },
+  createdAt: {
+    fontSize: Typography.sizes.xs,
+    color: Colors.textMuted,
+    marginTop: Spacing.xs,
+  },
+});
diff --git a/apps/mobile/components/ScoreBadge.tsx b/apps/mobile/components/ScoreBadge.tsx
new file mode 100644
index 0000000..3a67b23
--- /dev/null
+++ b/apps/mobile/components/ScoreBadge.tsx
@@ -0,0 +1,64 @@
+import React from "react";
+import { StyleSheet, Text, View } from "react-native";
+import { Colors, Radius, Spacing, Typography } from "../constants/theme";
+import { fmtScore } from "../lib/format";
+
+interface Props {
+  label: string;
+  score: number | null | undefined;
+  size?: "sm" | "md" | "lg";
+}
+
+function scoreColor(score: number | null | undefined): string {
+  if (score == null || !Number.isFinite(score)) return Colors.neutral;
+  if (score >= 70) return Colors.scoreHigh;
+  if (score >= 40) return Colors.scoreMid;
+  return Colors.scoreLow;
+}
+
+export function ScoreBadge({ label, score, size = "md" }: Props) {
+  const color = scoreColor(score);
+  const sz = size === "lg" ? 44 : size === "md" ? 36 : 28;
+  const fontSize = size === "lg" ? Typography.sizes.lg : size === "md" ? Typography.sizes.base : Typography.sizes.xs;
+
+  return (
+    <View style={styles.container}>
+      <View
+        style={[
+          styles.circle,
+          { width: sz, height: sz, borderRadius: sz / 2, borderColor: color },
+        ]}
+      >
+        <Text style={[styles.value, { fontSize, color }]}>{fmtScore(score)}</Text>
+      </View>
+      <Text style={[styles.label, size === "sm" && styles.labelSm]}>{label}</Text>
+    </View>
+  );
+}
+
+const styles = StyleSheet.create({
+  container: {
+    alignItems: "center",
+    gap: Spacing.xs,
+  },
+  circle: {
+    borderWidth: 2,
+    alignItems: "center",
+    justifyContent: "center",
+    backgroundColor: Colors.surface,
+  },
+  value: {
+    fontWeight: "700",
+    fontVariant: ["tabular-nums"],
+  },
+  label: {
+    fontSize: Typography.sizes.xs,
+    color: Colors.textMuted,
+    textAlign: "center",
+    textTransform: "uppercase",
+    letterSpacing: 0.5,
+  },
+  labelSm: {
+    fontSize: 9,
+  },
+});
diff --git a/apps/mobile/constants/theme.ts b/apps/mobile/constants/theme.ts
new file mode 100644
index 0000000..6c7a31a
--- /dev/null
+++ b/apps/mobile/constants/theme.ts
@@ -0,0 +1,73 @@
+/**
+ * Dark, dense, financial-dashboard color palette.
+ * Inspired by Bloomberg Terminal / trading cockpit aesthetics.
+ */
+export const Colors = {
+  bg: "#0D1117",           // near-black background
+  surface: "#161B22",      // card/panel surface
+  surfaceAlt: "#1C2128",   // alternate surface (elevated)
+  border: "#30363D",       // subtle border
+  borderStrong: "#484F58", // stronger border / divider
+
+  // Text
+  textPrimary: "#E6EDF3",   // primary text
+  textSecondary: "#8B949E", // muted / secondary
+  textMuted: "#6E7681",     // muted labels — ~4.7:1 on bg (WCAG AA); divider tone lives in borderStrong
+
+  // Accent / brand
+  accent: "#1F6FEB",        // electric blue — action / link
+  accentLight: "#388BFD",
+
+  // Financial states
+  profit: "#3FB950",        // positive / gain (green)
+  loss: "#F85149",          // negative / risk (red)
+  warning: "#D29922",       // caution / medium risk (amber)
+  neutral: "#8B949E",       // neutral / unknown
+
+  // Score color bands (0–100)
+  scoreHigh: "#3FB950",     // 70+
+  scoreMid: "#D29922",      // 40–69
+  scoreLow: "#F85149",      // 0–39
+
+  // Source chips
+  gsaColor: "#1F6FEB",
+  govdealsColor: "#388BFD",
+
+  // Risk badges
+  riskLow: "#3FB950",
+  riskMedium: "#D29922",
+  riskHigh: "#F85149",
+
+  // Rank badge
+  rankGold: "#D29922",
+  rankBg: "#21262D",
+} as const;
+
+export const Typography = {
+  mono: "System",  // will be overridden per-platform if needed
+  sizes: {
+    xs: 10,
+    sm: 12,
+    base: 14,
+    md: 16,
+    lg: 18,
+    xl: 22,
+    xxl: 28,
+  },
+} as const;
+
+export const Spacing = {
+  xs: 4,
+  sm: 8,
+  md: 12,
+  lg: 16,
+  xl: 24,
+  xxl: 32,
+} as const;
+
+export const Radius = {
+  sm: 4,
+  md: 8,
+  lg: 12,
+  pill: 100,
+} as const;
diff --git a/apps/mobile/device-proof-evidence/CLEAN-stills-2026-09-03T17-31-41Z/1-cold-launch.png b/apps/mobile/device-proof-evidence/CLEAN-stills-2026-09-03T17-31-41Z/1-cold-launch.png
new file mode 100644
index 0000000..726e481
Binary files /dev/null and b/apps/mobile/device-proof-evidence/CLEAN-stills-2026-09-03T17-31-41Z/1-cold-launch.png differ
diff --git a/apps/mobile/device-proof-evidence/CLEAN-stills-2026-09-03T17-31-41Z/2-detail-maxbid.png b/apps/mobile/device-proof-evidence/CLEAN-stills-2026-09-03T17-31-41Z/2-detail-maxbid.png
new file mode 100644
index 0000000..54168e7
Binary files /dev/null and b/apps/mobile/device-proof-evidence/CLEAN-stills-2026-09-03T17-31-41Z/2-detail-maxbid.png differ
diff --git a/apps/mobile/device-proof-evidence/CLEAN-stills-2026-09-03T17-31-41Z/3-settings.png b/apps/mobile/device-proof-evidence/CLEAN-stills-2026-09-03T17-31-41Z/3-settings.png
new file mode 100644
index 0000000..0e12d2d
Binary files /dev/null and b/apps/mobile/device-proof-evidence/CLEAN-stills-2026-09-03T17-31-41Z/3-settings.png differ
diff --git a/apps/mobile/device-proof-evidence/CLEAN-stills-2026-09-03T17-31-41Z/4-connection-test.png b/apps/mobile/device-proof-evidence/CLEAN-stills-2026-09-03T17-31-41Z/4-connection-test.png
new file mode 100644
index 0000000..687e6c8
Binary files /dev/null and b/apps/mobile/device-proof-evidence/CLEAN-stills-2026-09-03T17-31-41Z/4-connection-test.png differ
diff --git a/apps/mobile/device-proof-evidence/CLEAN-stills-2026-09-03T17-31-41Z/5-siwa.png b/apps/mobile/device-proof-evidence/CLEAN-stills-2026-09-03T17-31-41Z/5-siwa.png
new file mode 100644
index 0000000..687e6c8
Binary files /dev/null and b/apps/mobile/device-proof-evidence/CLEAN-stills-2026-09-03T17-31-41Z/5-siwa.png differ
diff --git a/apps/mobile/device-proof-evidence/GovArbUITest/GovArbUITests/GovArbUITests.swift b/apps/mobile/device-proof-evidence/GovArbUITest/GovArbUITests/GovArbUITests.swift
new file mode 100644
index 0000000..08ad9de
--- /dev/null
+++ b/apps/mobile/device-proof-evidence/GovArbUITest/GovArbUITests/GovArbUITests.swift
@@ -0,0 +1,119 @@
+import XCTest
+
+/// Standalone XCUITest that drives the ALREADY-INSTALLED GovArbitrage app on a
+/// physical device by bundle id, capturing each screen in-process via
+/// XCUIScreen.main.screenshot() as an XCTAttachment. This bypasses the dead
+/// idevicescreenshot / DDI screenshot path on iOS 26.
+///
+/// The Opportunities screen carries ~2500 listing cards, so a full accessibility
+/// snapshot of it is very expensive and can stall the a11y server. We therefore
+/// drive the big list with normalized-coordinate taps (zero snapshot) and reserve
+/// element queries for the small Settings hierarchy.
+///
+/// HARD GATES (local dev only): NO ASC submit/reply/upload, NO Apple identity
+/// beyond dev signing, NO prod/data writes. The app runs on live data (it is an
+/// ungated app-client). Sign-in-with-Apple is asserted to EXIST but NEVER tapped.
+final class GovArbUITests: XCTestCase {
+
+    let bundleId = "com.abrams.govarbitrage"
+
+    override func setUpWithError() throws {
+        continueAfterFailure = true
+    }
+
+    /// Save the current screen as a named PNG attachment (keepAlways) so it
+    /// survives into the result bundle for post-run export.
+    private func snap(_ name: String) {
+        let shot = XCUIScreen.main.screenshot()
+        let att = XCTAttachment(screenshot: shot)
+        att.name = name
+        att.lifetime = .keepAlways
+        add(att)
+        print("SNAP \(name)")
+    }
+
+    private func tapNormalized(_ app: XCUIApplication, _ dx: CGFloat, _ dy: CGFloat) {
+        app.coordinate(withNormalizedOffset: CGVector(dx: dx, dy: dy)).tap()
+    }
+
+    func testDeviceJourney() throws {
+        let app = XCUIApplication(bundleIdentifier: bundleId)
+        app.launch()
+
+        // ---- 1. Cold launch: Opportunities list ----------------------------
+        _ = app.wait(for: .runningForeground, timeout: 30)
+        sleep(5) // let ~2500 live listings load
+        snap("cold-launch-opportunities")
+
+        // ---- 2. Open the first listing card -> detail ----------------------
+        // Coordinate tap only: querying the giant list stalls the a11y server.
+        // The first card sits ~30% down the screen on cold launch.
+        tapNormalized(app, 0.5, 0.30)
+        sleep(3)
+        snap("detail-maxbid-valuation")
+
+        // ---- 3. Navigate back to the list ---------------------------------
+        // Prefer the nav-bar back button; fall back to a left-edge swipe.
+        let backBtn = app.navigationBars.buttons.element(boundBy: 0)
+        if backBtn.waitForExistence(timeout: 3) && backBtn.isHittable {
+            backBtn.tap()
+        } else {
+            let start = app.coordinate(withNormalizedOffset: CGVector(dx: 0.02, dy: 0.5))
+            let end = app.coordinate(withNormalizedOffset: CGVector(dx: 0.95, dy: 0.5))
+            start.press(forDuration: 0.05, thenDragTo: end)
+        }
+        sleep(2)
+
+        // ---- 4. Settings tab ----------------------------------------------
+        // The tab is labeled "Settings, tab, 2 of 2"; a plain "Settings" match
+        // fails, and querying the list is slow — so tap the tab by coordinate
+        // (bottom-right, gear). Bottom tab bar row is ~95% down; Settings ~75% across.
+        tapNormalized(app, 0.75, 0.95)
+        sleep(3)
+        snap("settings")
+
+        // From here the hierarchy is the small Settings screen -> queries are cheap.
+
+        // ---- 5. Test Connection control -----------------------------------
+        var tappedTest = false
+        let testPredicate = NSPredicate(format: "label CONTAINS[c] %@", "Test")
+        let testButtons = app.buttons.matching(testPredicate)
+        if testButtons.count > 0 {
+            let b = testButtons.element(boundBy: 0)
+            if b.isHittable { b.tap(); tappedTest = true }
+        }
+        if !tappedTest {
+            let testTexts = app.staticTexts.matching(NSPredicate(format: "label CONTAINS[c] %@", "Test Connection"))
+            if testTexts.count > 0 {
+                let t = testTexts.element(boundBy: 0)
+                if t.isHittable { t.tap(); tappedTest = true }
+            }
+        }
+        if !tappedTest {
+            // RN exposes the label on a child node (empty button label), so the
+            // predicate misses. The "Test Connection" button sits ~72% down the
+            // Settings screen — tap it by coordinate.
+            print("Test Connection not matched by label; using coordinate fallback (0.5, 0.72)")
+            tapNormalized(app, 0.5, 0.72)
+            tappedTest = true
+        }
+        sleep(5) // let the connection test round-trip
+        snap("settings-connection-test")
+
+        // ---- 6. Assert Sign in with Apple EXISTS (never tapped) -----------
+        let applePredicate = NSPredicate(format: "label CONTAINS[c] %@", "Apple")
+        var siwaExists = app.buttons.matching(applePredicate).count > 0
+        if !siwaExists { siwaExists = app.otherElements.matching(applePredicate).count > 0 }
+        if !siwaExists {
+            // It may live below the fold — scroll down once and re-check.
+            tapNormalized(app, 0.5, 0.6) // no-op-ish; ensure focus
+            app.swipeUp()
+            sleep(1)
+            siwaExists = app.buttons.matching(applePredicate).count > 0
+                || app.otherElements.matching(applePredicate).count > 0
+        }
+        snap("siwa-available")
+        print(siwaExists ? "SIWA present: assertion PASS" : "SIWA not found by label — screenshot saved for review")
+        XCTContext.runActivity(named: "siwa-exists=\(siwaExists)") { _ in }
+    }
+}
diff --git a/apps/mobile/device-proof-evidence/GovArbUITest/watch-capture.sh b/apps/mobile/device-proof-evidence/GovArbUITest/watch-capture.sh
new file mode 100755
index 0000000..924366b
--- /dev/null
+++ b/apps/mobile/device-proof-evidence/GovArbUITest/watch-capture.sh
@@ -0,0 +1,62 @@
+#!/bin/bash
+# Background watcher: retries the XCUITest until the iPhone is unlocked, then
+# captures the post-tap connection banner and finalizes evidence. Stops on first
+# success. HARD GATES unchanged (no ASC/prod writes).
+set -uo pipefail
+SP="/private/tmp/claude-501/-Volumes-Henry-mac2-offload-govarbitrage-apps-mobile/67a7d3e4-f953-493b-81f3-842d7b14f1d7/scratchpad/GovArbUITest"
+P8="/Users/macstudio3/.appstoreconnect/private_keys/AuthKey_72Y2TZT54R.p8"
+EV="/Volumes/Henry/mac2-offload/govarbitrage/apps/mobile/device-proof-evidence"
+DEV="00008150-001039662E3B401C"
+LOG="$SP/watch-capture.log"
+: > "$LOG"
+MAX=45   # ~45 attempts
+for i in $(seq 1 $MAX); do
+  echo "[attempt $i/$MAX $(date +%H:%M:%S)] running xcodebuild test..." >> "$LOG"
+  rm -rf "$SP/result.xcresult"
+  timeout 110 xcodebuild test \
+    -project "$SP/GovArbUITest.xcodeproj" -scheme GovArbUITests \
+    -destination "platform=iOS,id=$DEV" \
+    -derivedDataPath "$SP/DerivedData" -resultBundlePath "$SP/result.xcresult" \
+    -allowProvisioningUpdates -authenticationKeyPath "$P8" \
+    -authenticationKeyID 72Y2TZT54R -authenticationKeyIssuerID cfbd63ed-301b-465c-aad7-49e94420ad70 \
+    >> "$LOG" 2>&1
+  rc=$?
+  if [ -f "$SP/result.xcresult/Info.plist" ] && grep -q "TEST SUCCEEDED" "$LOG"; then
+    echo "[attempt $i] TEST SUCCEEDED — exporting" >> "$LOG"
+    OUT="$SP/attachments_final"; rm -rf "$OUT"
+    xcrun xcresulttool export attachments --path "$SP/result.xcresult" --output-path "$OUT" >> "$LOG" 2>&1
+    # map named attachments -> evidence files
+    python3 - "$OUT" "$EV" >> "$LOG" 2>&1 <<'PY'
+import json,sys,shutil,datetime,os
+out,ev=sys.argv[1],sys.argv[2]
+iso=datetime.datetime.now().strftime("%Y-%m-%dT%H-%M-%S")
+m=json.load(open(os.path.join(out,"manifest.json")))
+name_map={
+ "cold-launch-opportunities":"device-journey-1-cold-launch-opportunities",
+ "detail-maxbid-valuation":"device-journey-2-detail-maxbid-valuation",
+ "settings":"device-journey-3-settings",
+ "settings-connection-test":"device-journey-4-settings-connection-test",
+ "siwa-available":"device-journey-5-siwa-available",
+}
+copied=[]
+for t in m:
+  for a in t.get("attachments",[]):
+    nm=(a.get("suggestedHumanReadableName") or "").split("_")[0]
+    fn=a.get("exportedFileName")
+    if nm in name_map and fn and fn.endswith(".png"):
+      dst=os.path.join(ev,f"{name_map[nm]}-{iso}.png")
+      shutil.copy(os.path.join(out,fn),dst)
+      copied.append(os.path.basename(dst))
+print("COPIED:",copied)
+PY
+    echo "[done] finalized post-tap capture $(date +%H:%M:%S)" >> "$LOG"
+    export TK_AGENT=govarb-xcuitest
+    tk log TK-10279 "watch-capture: phone unlocked on attempt $i — post-tap connection banner captured + all 5 journey shots re-exported to device-proof-evidence/" >/dev/null 2>&1
+    exit 0
+  fi
+  echo "[attempt $i] not ready (rc=$rc, likely locked) — sleeping 20s" >> "$LOG"
+  pkill -9 xcodebuild >/dev/null 2>&1; pkill -9 -f GovArbUITests >/dev/null 2>&1
+  sleep 20
+done
+echo "[giveup] $MAX attempts exhausted, phone never unlocked" >> "$LOG"
+exit 1
diff --git a/apps/mobile/device-proof-evidence/_pii-review/pii-scan-contact-sheet-1-of-2-20260903T164350Z.jpg b/apps/mobile/device-proof-evidence/_pii-review/pii-scan-contact-sheet-1-of-2-20260903T164350Z.jpg
new file mode 100644
index 0000000..bf472c4
Binary files /dev/null and b/apps/mobile/device-proof-evidence/_pii-review/pii-scan-contact-sheet-1-of-2-20260903T164350Z.jpg differ
diff --git a/apps/mobile/device-proof-evidence/_pii-review/pii-scan-contact-sheet-2-of-2-20260903T164350Z.jpg b/apps/mobile/device-proof-evidence/_pii-review/pii-scan-contact-sheet-2-of-2-20260903T164350Z.jpg
new file mode 100644
index 0000000..c45204f
Binary files /dev/null and b/apps/mobile/device-proof-evidence/_pii-review/pii-scan-contact-sheet-2-of-2-20260903T164350Z.jpg differ
diff --git a/apps/mobile/device-proof-evidence/capture-lib.sh b/apps/mobile/device-proof-evidence/capture-lib.sh
new file mode 100644
index 0000000..9399d2c
--- /dev/null
+++ b/apps/mobile/device-proof-evidence/capture-lib.sh
@@ -0,0 +1,86 @@
+#!/bin/bash
+# GovArbitrage physical-device proof capture helpers (TK-10279)
+# GATES: read-only device inspection + local recording ONLY. No ASC. No identity actions.
+UDID="${GOVARB_DEVICE_UDID:-}"
+BUNDLE="com.abrams.govarbitrage"
+EVID="/Volumes/Henry/mac2-offload/govarbitrage/apps/mobile/device-proof-evidence"
+
+dp_require_udid() {
+  if [ -z "$UDID" ]; then
+    echo "Set GOVARB_DEVICE_UDID to the connected physical iPhone identifier." >&2
+    return 64
+  fi
+}
+
+# 1) Device identity + OS  -> device-info.txt
+dp_info() {
+  dp_require_udid || return
+  xcrun devicectl device info details --device "$UDID" 2>&1 | tee "$EVID/device-info.txt" | \
+    grep -iE "productType|osVersion|deviceName|marketingName|udid|developerMode" | head -20
+}
+
+# 2) Is build 5 installed? (cannot side-load; TestFlight install is Steve's action)
+dp_app_installed() {
+  dp_require_udid || return
+  local OUT
+  if ! OUT="$(xcrun devicectl device info apps --device "$UDID" 2>&1)"; then
+    printf '%s\n' "$OUT" | tee "$EVID/installed-apps.txt"
+    echo "DEVICE_QUERY_FAILED"
+    return 2
+  fi
+
+  printf '%s\n' "$OUT" | tee "$EVID/installed-apps.txt"
+  if printf '%s\n' "$OUT" | grep -qi "$BUNDLE\|govarbitrage"; then
+    echo "INSTALLED"
+    return 0
+  fi
+
+  echo "NOT_INSTALLED"
+  return 1
+}
+
+# 3) Find the iPhone's AVFoundation video index by name (only present when online+trusted)
+dp_avf_index() {
+  ffmpeg -f avfoundation -list_devices true -i "" 2>&1 | \
+    awk '/AVFoundation video devices/{v=1;next} /AVFoundation audio devices/{v=0} v' | \
+    grep -i "iphone" | grep -oE '\[[0-9]+\]' | tr -d '[]' | head -1
+}
+
+# 4) Start recording (call: dp_record_start <avf_index> ; writes PID to $EVID/rec.pid)
+dp_record_start() {
+  local IDX="$1"
+  local OUT="$EVID/govarbitrage-device-proof-$(date +%Y%m%dT%H%M%S).mov"
+  echo "$OUT" > "$EVID/rec.path"
+  # video-only (no mic) to avoid capturing room audio; 30fps; h264
+  ffmpeg -y -f avfoundation -framerate 30 -i "$IDX" -c:v h264_videotoolbox -b:v 8M "$OUT" \
+    > "$EVID/ffmpeg.log" 2>&1 &
+  echo $! > "$EVID/rec.pid"
+  echo "recording -> $OUT (pid $(cat "$EVID/rec.pid"))"
+}
+
+# 5) Stop recording cleanly (SIGINT so moov atom is written)
+dp_record_stop() {
+  local PID="$(cat "$EVID/rec.pid" 2>/dev/null)"
+  [ -n "$PID" ] && kill -INT "$PID" 2>/dev/null && sleep 3
+  echo "stopped; file:"; ls -la "$(cat "$EVID/rec.path" 2>/dev/null)" 2>/dev/null
+}
+
+# 5b) WIRELESS FALLBACK — record the Mac screen (screen 0) while iPhone is AirPlay-mirrored to this Mac.
+# PROVEN working 2026-09-02 (Screen Recording TCC already granted). Use when no USB cable.
+# Steve: System Settings>General>AirDrop&Handoff>AirPlay Receiver = On; iPhone Control Center>Screen Mirroring>this Mac.
+dp_record_screen0() {
+  local OUT="$EVID/govarbitrage-device-proof-airplay-$(date +%Y%m%dT%H%M%S).mov"
+  echo "$OUT" > "$EVID/rec.path"
+  ffmpeg -y -f avfoundation -framerate 30 -i "0" -c:v h264_videotoolbox -b:v 8M "$OUT" \
+    > "$EVID/ffmpeg.log" 2>&1 &
+  echo $! > "$EVID/rec.pid"
+  echo "recording Mac screen 0 (AirPlay mirror) -> $OUT (pid $(cat "$EVID/rec.pid"))"
+}
+
+# 6) Launch app cold
+dp_launch() {
+  dp_require_udid || return
+  xcrun devicectl device process launch --device "$UDID" "$BUNDLE" 2>&1 | tee "$EVID/launch.txt"
+}
+
+"$@"
diff --git a/apps/mobile/device-proof-evidence/capture-tap-scroll.sh b/apps/mobile/device-proof-evidence/capture-tap-scroll.sh
new file mode 100755
index 0000000..c79ece4
--- /dev/null
+++ b/apps/mobile/device-proof-evidence/capture-tap-scroll.sh
@@ -0,0 +1,82 @@
+#!/bin/bash
+# Follow-up capture: detail lower-sections (Cost Breakdown & Profit + Scores) scrolled into view
+# + Settings "Test Connection" tapped -> "Connected · latency · tier FREE".
+#
+# PREREQ: iTerm2 must have Accessibility (System Settings > Privacy > Accessibility > iTerm ON),
+#         AND iTerm must have been FULLY QUIT + REOPENED after granting (a running app can't see a
+#         new grant). Run this from that fresh iTerm. A PREFLIGHT below verifies taps actually land
+#         and ABORTS before recording if they don't — so you never get a video full of missed taps.
+#
+# Writes to /tmp (the simctl daemon can't write the external /Volumes volume), then copies here.
+set -u
+UDID=2061D117-F2B5-419D-82D6-E9567908D5E8
+BID=com.abrams.govarbitrage
+IMAC=cmtk1nnat00eysqssvwvbewq7
+ISO=$(date -u +%Y-%m-%dT%H%M%SZ)
+HERE="$(cd "$(dirname "$0")" && pwd)"
+MOV="/tmp/govarbitrage-SIM-tapscroll-${ISO}.mov"
+
+command -v cliclick >/dev/null || { echo "ABORT: cliclick not found (brew install cliclick)"; exit 1; }
+
+# --- calibrated device-region-in-window mapping ---
+# Hardcoded to the stable Sim window geometry (no osascript — that call can hang on an
+# Automation permission prompt). Override via env if the window is elsewhere:
+#   WX=.. WY=.. WW=.. WH=.. ./capture-tap-scroll.sh
+WX=${WX:-1473}; WY=${WY:-100}; WW=${WW:-494}; WH=${WH:-1054}
+YTOP=28; DEV_W=440; DEV_H=956
+DH=$((WH-YTOP))
+sx(){ python3 -c "s=($DH)/$DEV_H.0; dw=$DEV_W*s; xp=($WW-dw)/2; print(int($WX+xp+($1/$DEV_W.0)*dw))"; }
+sy(){ python3 -c "s=($DH)/$DEV_H.0; print(int($WY+$YTOP+($1/$DEV_H.0)*($DH)))"; }
+echo "Sim window ($WX,$WY) ${WW}x${WH}"
+
+activate(){ osascript -e 'tell application "Simulator" to activate' >/dev/null 2>&1; sleep 0.6; }
+
+# --- PREFLIGHT: prove synthetic taps reach the app before recording anything ---
+echo "[preflight] verifying taps land (needs iTerm Accessibility + a FRESH iTerm)..."
+xcrun simctl terminate "$UDID" "$BID" >/dev/null 2>&1; sleep 1
+xcrun simctl launch "$UDID" "$BID" >/dev/null 2>&1; sleep 6
+activate
+xcrun simctl io "$UDID" screenshot /tmp/pf-before.png >/dev/null 2>&1
+cliclick c:$(sx 330),$(sy 915) >/dev/null 2>&1   # Settings tab (right side of bottom bar)
+sleep 2
+xcrun simctl io "$UDID" screenshot /tmp/pf-after.png >/dev/null 2>&1
+if python3 -c "import sys;a=open('/tmp/pf-before.png','rb').read();b=open('/tmp/pf-after.png','rb').read();sys.exit(0 if a==b else 1)"; then
+  cat <<'MSG'
+ABORT: synthetic taps are NOT reaching the Simulator (the Settings tab did not switch).
+  Fix: System Settings > Privacy & Security > Accessibility > turn iTerm ON,
+       then FULLY QUIT iTerm (Cmd-Q) and reopen it, and run this script again from the fresh window.
+  (Nothing was recorded.)
+MSG
+  exit 2
+fi
+echo "[preflight] OK — taps land. Recording the journey."
+
+# --- record + drive ---
+xcrun simctl terminate "$UDID" "$BID" >/dev/null 2>&1; sleep 1
+activate
+xcrun simctl io "$UDID" recordVideo --codec=h264 --force "$MOV" & REC=$!
+sleep 2
+
+echo "[1] cold launch -> list"; xcrun simctl launch "$UDID" "$BID" >/dev/null 2>&1; sleep 7
+
+echo "[2] detail + scroll to Cost Breakdown & Scores"
+xcrun simctl openurl "$UDID" "govarbitrage://listing/$IMAC" >/dev/null 2>&1; sleep 4
+xcrun simctl io "$UDID" screenshot /tmp/ts-01-detail-top.png >/dev/null 2>&1
+CX=$(sx 220)
+for i in 1 2 3; do
+  cliclick -e 450 dd:$CX,$(sy 880) du:$CX,$(sy 240) >/dev/null 2>&1; sleep 1.3
+  xcrun simctl io "$UDID" screenshot /tmp/ts-02-detail-scroll$i.png >/dev/null 2>&1
+done
+
+echo "[3] settings -> tap Test Connection"
+xcrun simctl openurl "$UDID" "govarbitrage://settings" >/dev/null 2>&1; sleep 3
+cliclick c:$(sx 220),$(sy 690) >/dev/null 2>&1   # Test Connection button
+sleep 3
+xcrun simctl io "$UDID" screenshot /tmp/ts-03-connected.png >/dev/null 2>&1
+
+sleep 2
+kill -INT "$REC" 2>/dev/null; sleep 3
+
+cp "$MOV" "$HERE/" 2>/dev/null
+for f in /tmp/ts-*.png; do [ -e "$f" ] && cp "$f" "$HERE/$(basename "$f" .png)-${ISO}.png"; done
+echo "DONE -> $HERE/$(basename "$MOV")  + ts-*-${ISO}.png stills"
diff --git a/apps/mobile/device-proof-evidence/privacy-contact-sheet-2026-09-03T16-42-59Z.jpg b/apps/mobile/device-proof-evidence/privacy-contact-sheet-2026-09-03T16-42-59Z.jpg
new file mode 100644
index 0000000..99b6290
Binary files /dev/null and b/apps/mobile/device-proof-evidence/privacy-contact-sheet-2026-09-03T16-42-59Z.jpg differ
diff --git a/apps/mobile/device-proof-evidence/privacy-scan.sh b/apps/mobile/device-proof-evidence/privacy-scan.sh
new file mode 100755
index 0000000..8a99f66
--- /dev/null
+++ b/apps/mobile/device-proof-evidence/privacy-scan.sh
@@ -0,0 +1,50 @@
+#!/bin/bash
+# privacy-scan.sh — PII/private-content pre-screen for App Review recordings (TK-10279).
+#
+# verify-recording.sh checks ONLY codec/dimensions/duration/black — it canNOT see
+# private content, which is exactly how the first recording leaked a Messages thread
+# (name + phone + message body) and got the upload rejected. This scans the actual
+# PIXELS: it samples frames and asks a LOCAL vision model ($0, ollama — no paid API,
+# nothing leaves the machine) whether any frame shows content that must never appear
+# in an Apple review video.
+#
+# It is a PRE-SCREEN, not a substitute for a human eyeball: exit 0 = no PII detected
+# (still do a final manual review), exit 1 = PII flagged (DO NOT UPLOAD), exit >1 = error.
+#
+# Usage: privacy-scan.sh <recording.mov|.mp4> [seconds-between-frames=2]
+set -uo pipefail
+FILE="${1:-}"; STEP="${2:-2}"
+MODEL="${PRIVACY_SCAN_MODEL:-qwen2.5vl:7b}"
+OLLAMA="${OLLAMA_HOST:-http://localhost:11434}"
+[ -f "$FILE" ] || { echo "usage: $0 <recording.(mov|mp4)> [step-secs]" >&2; exit 64; }
+
+TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT
+echo "▶ extracting frames (1 every ${STEP}s) from $(basename "$FILE")…"
+ffmpeg -hide_banner -loglevel error -i "$FILE" -vf "fps=1/${STEP}" -q:v 3 "$TMP/f%04d.jpg" || { echo "FAIL: ffmpeg extract" >&2; exit 68; }
+N=$(ls "$TMP"/*.jpg 2>/dev/null | wc -l | tr -d ' ')
+[ "$N" -gt 0 ] || { echo "FAIL: no frames extracted" >&2; exit 68; }
+echo "  ${N} frames · model ${MODEL} (local, \$0)"
+
+PROMPT='You are screening ONE frame from an App Store review screen-recording of an iOS app called GovArbitrage (a government-surplus auction analyzer showing auction listings, prices, ROI, scores, and a settings screen). Report ONLY private/sensitive content that must NOT appear in a recording sent to Apple: an incoming notification/banner from another app, a Messages/SMS/WhatsApp/email conversation, a visible personal human name, a phone number, an email address, or a password field showing typed characters. The app own UI (auction items, dollar amounts, percentages, a Settings screen with blank or masked/dotted password fields, a server URL) is NOT private. Answer EXACTLY the single word CLEAN if the frame shows only the app or a neutral home screen with no such content. Otherwise answer FLAG: followed by a short description of the private content.'
+
+FLAGGED=0; FLAGS=""
+i=0
+for img in "$TMP"/*.jpg; do
+  i=$((i+1)); t=$(( (i-1) * STEP ))
+  b64=$(base64 -i "$img" | tr -d '\n')
+  resp=$(curl -s "$OLLAMA/api/generate" -d "{\"model\":\"$MODEL\",\"prompt\":$(printf '%s' "$PROMPT" | python3 -c 'import json,sys;print(json.dumps(sys.stdin.read()))'),\"images\":[\"$b64\"],\"stream\":false}" \
+    | python3 -c 'import json,sys;print((json.load(sys.stdin).get("response") or "").strip().replace(chr(10)," "))' 2>/dev/null)
+  if printf '%s' "$resp" | grep -qiE '^FLAG|\bFLAG:'; then
+    FLAGGED=$((FLAGGED+1)); FLAGS="${FLAGS}\n  ~${t}s: ${resp}"
+    printf '  \033[31m● %ss FLAG\033[0m %s\n' "$t" "$resp"
+  fi
+done
+
+echo "────────────────────────────────────────"
+if [ "$FLAGGED" -gt 0 ]; then
+  printf '\033[31m✗ PRIVACY FAIL: %d frame(s) flagged — DO NOT UPLOAD:\033[0m%b\n' "$FLAGGED" "$FLAGS"
+  exit 1
+fi
+echo "✓ privacy pre-scan CLEAN across ${N} frames — no PII detected."
+echo "  (still do a final human eyeball before any Apple upload — this is a pre-screen.)"
+exit 0
diff --git a/apps/mobile/device-proof-evidence/privacy-sweep-CLEAN-2026-09-03T17-31-41Z.jpg b/apps/mobile/device-proof-evidence/privacy-sweep-CLEAN-2026-09-03T17-31-41Z.jpg
new file mode 100644
index 0000000..1e82e1a
Binary files /dev/null and b/apps/mobile/device-proof-evidence/privacy-sweep-CLEAN-2026-09-03T17-31-41Z.jpg differ
diff --git a/apps/mobile/device-proof-evidence/readiness-probe.sh b/apps/mobile/device-proof-evidence/readiness-probe.sh
new file mode 100755
index 0000000..1957ab6
--- /dev/null
+++ b/apps/mobile/device-proof-evidence/readiness-probe.sh
@@ -0,0 +1,73 @@
+#!/bin/bash
+# TK-10279 — consolidated submit-readiness probe. Read-only, $0 local.
+# Emits one JSON object of every gate GovArbitrage's 2.1 response depends on.
+# PASS overall == everything but the physical recording + the (gated) Apple send is ready.
+# Usage: ./readiness-probe.sh [path-to-recording.mov]   (recording arg optional)
+set -uo pipefail
+cd "$(dirname "$0")"
+REC="${1:-}"
+BASE="https://auctions.agentabrams.com"
+
+json_bool(){ [ "$1" = "1" ] && echo true || echo false; }
+
+# --- GATE: backend (reviewer hits this) ---
+b_root=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$BASE" 2>/dev/null)
+b_priv=$(curl -s -o /dev/null -w '%{http_code}' --max-time 15 "$BASE/privacy" 2>/dev/null)
+b_list_code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 20 "$BASE/api/listings" 2>/dev/null)
+b_total=$(curl -s --max-time 20 "$BASE/api/listings" 2>/dev/null | sed -n 's/.*"total":\([0-9]*\).*/\1/p' | head -1)
+backend_ok=0; [ "$b_root" = 200 ] && [ "$b_priv" = 200 ] && [ "$b_list_code" = 200 ] && [ "${b_total:-0}" -gt 0 ] 2>/dev/null && backend_ok=1
+
+# --- GATE: reply-text source accuracy (must name every live source enum) ---
+reply_ok=1
+for s in GovDeals GovPlanet "GSA Auctions" "Public Surplus" GoIndustry; do
+  grep -qi "$s" ../ASC-PASTE-KIT.md || reply_ok=0
+done
+
+# --- GATE: verifier tooling present + executable ---
+tool_ok=0; [ -x ./verify-recording.sh ] && tool_ok=1
+
+# --- GATE: physical recording + manual privacy review ---
+rec_status="ABSENT"; rec_file=""; privacy_status="ABSENT"
+[ -z "$REC" ] && REC=$(ls -t ./govarbitrage-DEVICE-*.mov ./govarbitrage-DEVICE-*.mp4 2>/dev/null | head -1)
+if [ -n "$REC" ] && [ -f "$REC" ] && [[ "$(basename "$REC")" == govarbitrage-DEVICE-* ]]; then
+  rec_file="$REC"
+  if [ -x ./verify-recording.sh ] && ./verify-recording.sh "$REC" >/dev/null 2>&1; then
+    rec_status="TECH_PASS_PRIVACY_PENDING"
+    if [ -f "$REC.privacy-reviewed.sha256" ] && (cd "$(dirname "$REC")" && shasum -a 256 -c "$(basename "$REC").privacy-reviewed.sha256") >/dev/null 2>&1; then
+      privacy_status="PASS"
+      rec_status="PASS"
+    fi
+  else
+    rec_status="FAIL"
+  fi
+elif [ -n "$REC" ] && [ -f "$REC" ]; then
+  rec_file="$REC"
+  rec_status="REJECTED_NOT_DEVICE_NAMED"
+fi
+rec_ok=0; [ "$rec_status" = PASS ] && rec_ok=1
+
+# --- GATE: ASC artifact state (read-only; best-effort) ---
+asc_line=$(ASC_KEY_PATH="${ASC_KEY_PATH:-$HOME/.appstoreconnect/private_keys/AuthKey_72Y2TZT54R.p8}" \
+  ASC_KEY_ID="${ASC_KEY_ID:-72Y2TZT54R}" \
+  timeout 90 node "$HOME/.claude/skills/ipa-status/scripts/ipa-status.mjs" --ready 2>/dev/null | grep -i GovArbitrage | head -1)
+asc_ok=0; printf '%s' "$asc_line" | grep -qi "SUBMIT-READY" && asc_ok=1
+
+# overall = everything automatable green (recording is the human gate, reported separately)
+auto_ok=0; [ $backend_ok = 1 ] && [ $reply_ok = 1 ] && [ $tool_ok = 1 ] && [ $asc_ok = 1 ] && auto_ok=1
+
+cat <<JSON
+{
+  "ticket": "TK-10279",
+  "ts": "$(date -u +%Y-%m-%dT%H:%M:%SZ)",
+  "gates": {
+    "backend":   { "ok": $(json_bool $backend_ok), "root": "$b_root", "privacy": "$b_priv", "listings": "$b_list_code", "total": ${b_total:-0} },
+    "reply_text":{ "ok": $(json_bool $reply_ok), "note": "names GovDeals/GovPlanet/GSA/Public Surplus/GoIndustry" },
+    "verifier":  { "ok": $(json_bool $tool_ok), "path": "verify-recording.sh" },
+    "asc":       { "ok": $(json_bool $asc_ok), "state": "$(printf '%s' "$asc_line" | sed 's/^[^A-Za-z]*//; s/"/'"'"'/g' | tr -s ' ')" },
+    "recording": { "ok": $(json_bool $rec_ok), "status": "$rec_status", "privacy_review": "$privacy_status", "file": "$rec_file", "note": "physical iPhone capture; requires technical validation + matching manual privacy-review SHA-256 sidecar" }
+  },
+  "automatable_ready": $(json_bool $auto_ok),
+  "human_gated_remaining": ["physical-device recording", "ASC Resolution Center reply+upload"],
+  "verdict": "$( [ $auto_ok = 1 ] && [ $rec_ok = 1 ] && echo READY_TO_SEND || { [ $auto_ok = 1 ] && echo READY_EXCEPT_RECORDING || echo NOT_READY; } )"
+}
+JSON
diff --git a/apps/mobile/device-proof-evidence/run4-clean-2026-09-03T15-23-26Z/1-cold-launch.png b/apps/mobile/device-proof-evidence/run4-clean-2026-09-03T15-23-26Z/1-cold-launch.png
new file mode 100644
index 0000000..dcf0a1c
Binary files /dev/null and b/apps/mobile/device-proof-evidence/run4-clean-2026-09-03T15-23-26Z/1-cold-launch.png differ
diff --git a/apps/mobile/device-proof-evidence/run4-clean-2026-09-03T15-23-26Z/2-detail-maxbid.png b/apps/mobile/device-proof-evidence/run4-clean-2026-09-03T15-23-26Z/2-detail-maxbid.png
new file mode 100644
index 0000000..6cf1698
Binary files /dev/null and b/apps/mobile/device-proof-evidence/run4-clean-2026-09-03T15-23-26Z/2-detail-maxbid.png differ
diff --git a/apps/mobile/device-proof-evidence/run4-clean-2026-09-03T15-23-26Z/3-settings.png b/apps/mobile/device-proof-evidence/run4-clean-2026-09-03T15-23-26Z/3-settings.png
new file mode 100644
index 0000000..7c0055c
Binary files /dev/null and b/apps/mobile/device-proof-evidence/run4-clean-2026-09-03T15-23-26Z/3-settings.png differ
diff --git a/apps/mobile/device-proof-evidence/run4-clean-2026-09-03T15-23-26Z/4-connection-test.png b/apps/mobile/device-proof-evidence/run4-clean-2026-09-03T15-23-26Z/4-connection-test.png
new file mode 100644
index 0000000..6dcc2b8
Binary files /dev/null and b/apps/mobile/device-proof-evidence/run4-clean-2026-09-03T15-23-26Z/4-connection-test.png differ
diff --git a/apps/mobile/device-proof-evidence/run4-clean-2026-09-03T15-23-26Z/5-siwa.png b/apps/mobile/device-proof-evidence/run4-clean-2026-09-03T15-23-26Z/5-siwa.png
new file mode 100644
index 0000000..6dcc2b8
Binary files /dev/null and b/apps/mobile/device-proof-evidence/run4-clean-2026-09-03T15-23-26Z/5-siwa.png differ
diff --git a/apps/mobile/device-proof-evidence/run5-frames-2026-09-03T16-42-59Z/1-cold-launch.png b/apps/mobile/device-proof-evidence/run5-frames-2026-09-03T16-42-59Z/1-cold-launch.png
new file mode 100644
index 0000000..42eef57
Binary files /dev/null and b/apps/mobile/device-proof-evidence/run5-frames-2026-09-03T16-42-59Z/1-cold-launch.png differ
diff --git a/apps/mobile/device-proof-evidence/run5-frames-2026-09-03T16-42-59Z/2-detail-maxbid.png b/apps/mobile/device-proof-evidence/run5-frames-2026-09-03T16-42-59Z/2-detail-maxbid.png
new file mode 100644
index 0000000..db0f122
Binary files /dev/null and b/apps/mobile/device-proof-evidence/run5-frames-2026-09-03T16-42-59Z/2-detail-maxbid.png differ
diff --git a/apps/mobile/device-proof-evidence/run5-frames-2026-09-03T16-42-59Z/3-settings.png b/apps/mobile/device-proof-evidence/run5-frames-2026-09-03T16-42-59Z/3-settings.png
new file mode 100644
index 0000000..cee6b3a
Binary files /dev/null and b/apps/mobile/device-proof-evidence/run5-frames-2026-09-03T16-42-59Z/3-settings.png differ
diff --git a/apps/mobile/device-proof-evidence/run5-frames-2026-09-03T16-42-59Z/4-connection-test.png b/apps/mobile/device-proof-evidence/run5-frames-2026-09-03T16-42-59Z/4-connection-test.png
new file mode 100644
index 0000000..4569cf6
Binary files /dev/null and b/apps/mobile/device-proof-evidence/run5-frames-2026-09-03T16-42-59Z/4-connection-test.png differ
diff --git a/apps/mobile/device-proof-evidence/run5-frames-2026-09-03T16-42-59Z/5-siwa.png b/apps/mobile/device-proof-evidence/run5-frames-2026-09-03T16-42-59Z/5-siwa.png
new file mode 100644
index 0000000..4569cf6
Binary files /dev/null and b/apps/mobile/device-proof-evidence/run5-frames-2026-09-03T16-42-59Z/5-siwa.png differ
diff --git a/apps/mobile/device-proof-evidence/verify-recording.sh b/apps/mobile/device-proof-evidence/verify-recording.sh
new file mode 100755
index 0000000..b582ea4
--- /dev/null
+++ b/apps/mobile/device-proof-evidence/verify-recording.sh
@@ -0,0 +1,44 @@
+#!/bin/bash
+# Validate a locally captured physical-device recording before any gated upload.
+set -euo pipefail
+
+FILE="${1:-}"
+if [ -z "$FILE" ] || [ ! -f "$FILE" ]; then
+  echo "usage: $0 <recording.mov|recording.mp4>" >&2
+  exit 64
+fi
+
+case "${FILE##*.}" in
+  mov|MOV|mp4|MP4) ;;
+  *) echo "FAIL: expected .mov or .mp4" >&2; exit 65 ;;
+esac
+
+META="$(ffprobe -v error -select_streams v:0 \
+  -show_entries stream=codec_name,width,height -show_entries format=duration \
+  -of default=noprint_wrappers=1 "$FILE")"
+
+WIDTH="$(printf '%s\n' "$META" | awk -F= '$1=="width"{print $2; exit}')"
+HEIGHT="$(printf '%s\n' "$META" | awk -F= '$1=="height"{print $2; exit}')"
+DURATION="$(printf '%s\n' "$META" | awk -F= '$1=="duration"{print $2; exit}')"
+
+awk -v w="$WIDTH" -v h="$HEIGHT" -v d="$DURATION" 'BEGIN {
+  if ((w+0) < 1 || (h+0) < 1) exit 1;
+  if ((d+0) < 20) exit 2;
+}' || {
+  rc=$?
+  [ "$rc" -eq 2 ] && echo "FAIL: recording must be at least 20 seconds" >&2
+  [ "$rc" -ne 2 ] && echo "FAIL: missing or invalid video dimensions" >&2
+  exit 66
+}
+
+BLACK_LOG="$(ffmpeg -hide_banner -v info -i "$FILE" -vf blackdetect=d=5:pix_th=0.10 \
+  -an -f null - 2>&1 || true)"
+BLACK_DURATION="$(printf '%s\n' "$BLACK_LOG" | \
+  sed -n 's/.*black_duration:\([0-9.]*\).*/\1/p' | sort -nr | head -1)"
+BLACK_DURATION="${BLACK_DURATION:-0}"
+if awk -v black="$BLACK_DURATION" -v total="$DURATION" 'BEGIN { exit !(black >= total - 0.20) }'; then
+  echo "FAIL: recording appears entirely black" >&2
+  exit 67
+fi
+
+printf 'PASS: %sx%s, %ss\n' "$WIDTH" "$HEIGHT" "$DURATION"
diff --git a/apps/mobile/index.ts b/apps/mobile/index.ts
new file mode 100644
index 0000000..80d3d99
--- /dev/null
+++ b/apps/mobile/index.ts
@@ -0,0 +1 @@
+import "expo-router/entry";
diff --git a/apps/mobile/lib/api-error.ts b/apps/mobile/lib/api-error.ts
new file mode 100644
index 0000000..1c0036b
--- /dev/null
+++ b/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/apps/mobile/lib/api.ts b/apps/mobile/lib/api.ts
new file mode 100644
index 0000000..6400e23
--- /dev/null
+++ b/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/apps/mobile/lib/auth-headers.ts b/apps/mobile/lib/auth-headers.ts
new file mode 100644
index 0000000..cf157c7
--- /dev/null
+++ b/apps/mobile/lib/auth-headers.ts
@@ -0,0 +1,34 @@
+/**
+ * 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 {
+  // UTF-8-safe base64. Plain btoa() throws InvalidCharacterError on any code
+  // point > 255 (emoji, CJK, accented letters, smart quotes); since this runs
+  // BEFORE apiFetch's try/timeout block, that would propagate a raw Error out of
+  // every request for a self-hosting user with a non-Latin1 password. Encode to
+  // UTF-8 bytes first so btoa() only ever sees a Latin1 string.
+  const bytes = new TextEncoder().encode(`${username}:${password}`);
+  let binary = "";
+  for (const b of bytes) binary += String.fromCharCode(b);
+  return `Basic ${btoa(binary)}`;
+}
+
+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/apps/mobile/lib/auth.ts b/apps/mobile/lib/auth.ts
new file mode 100644
index 0000000..cc16f77
--- /dev/null
+++ b/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/apps/mobile/lib/format.ts b/apps/mobile/lib/format.ts
new file mode 100644
index 0000000..84be147
--- /dev/null
+++ b/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/apps/mobile/lib/pnl.ts b/apps/mobile/lib/pnl.ts
new file mode 100644
index 0000000..df90a67
--- /dev/null
+++ b/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/apps/mobile/lib/settings.ts b/apps/mobile/lib/settings.ts
new file mode 100644
index 0000000..7f91d47
--- /dev/null
+++ b/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/apps/mobile/lib/types.ts b/apps/mobile/lib/types.ts
new file mode 100644
index 0000000..2806c43
--- /dev/null
+++ b/apps/mobile/lib/types.ts
@@ -0,0 +1,244 @@
+// 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;
+  locationCity: string | null;
+  locationState: string | null;
+  // Now returned on list rows too (backend flattenListing), so the "Newest"
+  // sort and the admin created-date chip work on the grid, not just 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"
+  | "createdAt";
+
+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/apps/mobile/lib/validate.ts b/apps/mobile/lib/validate.ts
new file mode 100644
index 0000000..eb8f0c9
--- /dev/null
+++ b/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/apps/mobile/scripts/lint-financial-format.mjs b/apps/mobile/scripts/lint-financial-format.mjs
new file mode 100644
index 0000000..dd200e7
--- /dev/null
+++ b/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/apps/mobile/store-assets/screenshots/01-opportunities-6.9.png b/apps/mobile/store-assets/screenshots/01-opportunities-6.9.png
new file mode 100644
index 0000000..7923f0a
Binary files /dev/null and b/apps/mobile/store-assets/screenshots/01-opportunities-6.9.png differ
diff --git a/apps/mobile/store-assets/screenshots/02-settings-6.9.png b/apps/mobile/store-assets/screenshots/02-settings-6.9.png
new file mode 100644
index 0000000..0868fc4
Binary files /dev/null and b/apps/mobile/store-assets/screenshots/02-settings-6.9.png differ
diff --git a/apps/mobile/store-assets/screenshots/03-listing-detail-6.9.png b/apps/mobile/store-assets/screenshots/03-listing-detail-6.9.png
new file mode 100644
index 0000000..1acafec
Binary files /dev/null and b/apps/mobile/store-assets/screenshots/03-listing-detail-6.9.png differ
diff --git a/apps/mobile/store-assets/screenshots/framed-01-opportunities-6.9.png b/apps/mobile/store-assets/screenshots/framed-01-opportunities-6.9.png
new file mode 100644
index 0000000..29eb6ca
Binary files /dev/null and b/apps/mobile/store-assets/screenshots/framed-01-opportunities-6.9.png differ
diff --git a/apps/mobile/store-assets/screenshots/framed-02-listing-detail-6.9.png b/apps/mobile/store-assets/screenshots/framed-02-listing-detail-6.9.png
new file mode 100644
index 0000000..bb68a14
Binary files /dev/null and b/apps/mobile/store-assets/screenshots/framed-02-listing-detail-6.9.png differ
diff --git a/apps/mobile/store-assets/screenshots/framed-03-settings-6.9.png b/apps/mobile/store-assets/screenshots/framed-03-settings-6.9.png
new file mode 100644
index 0000000..2731446
Binary files /dev/null and b/apps/mobile/store-assets/screenshots/framed-03-settings-6.9.png differ
diff --git a/apps/mobile/store-assets/screenshots/ipad-13/01-opportunities-13.png b/apps/mobile/store-assets/screenshots/ipad-13/01-opportunities-13.png
new file mode 100644
index 0000000..2d8c4dc
Binary files /dev/null and b/apps/mobile/store-assets/screenshots/ipad-13/01-opportunities-13.png differ
diff --git a/apps/mobile/store-assets/screenshots/ipad-13/02-listing-detail-13.png b/apps/mobile/store-assets/screenshots/ipad-13/02-listing-detail-13.png
new file mode 100644
index 0000000..930bb00
Binary files /dev/null and b/apps/mobile/store-assets/screenshots/ipad-13/02-listing-detail-13.png differ
diff --git a/apps/mobile/store-assets/screenshots/ipad-13/03-settings-13.png b/apps/mobile/store-assets/screenshots/ipad-13/03-settings-13.png
new file mode 100644
index 0000000..e9b97d1
Binary files /dev/null and b/apps/mobile/store-assets/screenshots/ipad-13/03-settings-13.png differ
diff --git a/apps/mobile/store-assets/screenshots/iphone-6.5/01-opportunities-6.5.png b/apps/mobile/store-assets/screenshots/iphone-6.5/01-opportunities-6.5.png
new file mode 100644
index 0000000..bc3eb8e
Binary files /dev/null and b/apps/mobile/store-assets/screenshots/iphone-6.5/01-opportunities-6.5.png differ
diff --git a/apps/mobile/store-assets/screenshots/iphone-6.5/02-listing-detail-6.5.png b/apps/mobile/store-assets/screenshots/iphone-6.5/02-listing-detail-6.5.png
new file mode 100644
index 0000000..78251bc
Binary files /dev/null and b/apps/mobile/store-assets/screenshots/iphone-6.5/02-listing-detail-6.5.png differ
diff --git a/apps/mobile/store-assets/screenshots/iphone-6.5/03-settings-6.5.png b/apps/mobile/store-assets/screenshots/iphone-6.5/03-settings-6.5.png
new file mode 100644
index 0000000..64bd7c6
Binary files /dev/null and b/apps/mobile/store-assets/screenshots/iphone-6.5/03-settings-6.5.png differ
diff --git a/apps/mobile/tests/auth-headers.test.mjs b/apps/mobile/tests/auth-headers.test.mjs
new file mode 100644
index 0000000..aebb95b
--- /dev/null
+++ b/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/apps/mobile/tests/format.test.mjs b/apps/mobile/tests/format.test.mjs
new file mode 100644
index 0000000..8d2259e
--- /dev/null
+++ b/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/eslint.config.mjs b/eslint.config.mjs
new file mode 100644
index 0000000..0549540
--- /dev/null
+++ b/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/extension/background.js b/extension/background.js
new file mode 100644
index 0000000..378e426
--- /dev/null
+++ b/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/extension/content.js b/extension/content.js
new file mode 100644
index 0000000..c71fd96
--- /dev/null
+++ b/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/extension/popup.html b/extension/popup.html
new file mode 100644
index 0000000..064e180
--- /dev/null
+++ b/extension/popup.html
@@ -0,0 +1,203 @@
+<!DOCTYPE html>
+<html lang="en">
+  <head>
+    <meta charset="UTF-8" />
+    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+    <title>GovArbitrage Capture</title>
+    <style>
+      :root {
+        --bg: #0f141a;
+        --panel: #161d26;
+        --panel-2: #1d2733;
+        --border: #2a3644;
+        --text: #e6edf3;
+        --muted: #8b98a5;
+        --accent: #3fb950;
+        --accent-hover: #2ea043;
+        --blue: #388bfd;
+        --blue-hover: #2f7ce0;
+        --danger: #f85149;
+      }
+      * {
+        box-sizing: border-box;
+      }
+      html,
+      body {
+        margin: 0;
+        padding: 0;
+      }
+      body {
+        width: 400px;
+        background: var(--bg);
+        color: var(--text);
+        font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
+        font-size: 13px;
+        line-height: 1.45;
+      }
+      .wrap {
+        padding: 14px;
+      }
+      h1 {
+        font-size: 15px;
+        font-weight: 600;
+        margin: 0 0 12px;
+        display: flex;
+        align-items: center;
+        gap: 8px;
+      }
+      h1 .dot {
+        width: 9px;
+        height: 9px;
+        border-radius: 50%;
+        background: var(--accent);
+        display: inline-block;
+      }
+      label {
+        display: block;
+        font-size: 11px;
+        color: var(--muted);
+        margin: 0 0 4px;
+        text-transform: uppercase;
+        letter-spacing: 0.04em;
+      }
+      input[type="text"] {
+        width: 100%;
+        padding: 7px 9px;
+        background: var(--panel-2);
+        border: 1px solid var(--border);
+        border-radius: 6px;
+        color: var(--text);
+        font-size: 13px;
+        outline: none;
+      }
+      input[type="text"]:focus {
+        border-color: var(--blue);
+      }
+      .field {
+        margin-bottom: 12px;
+      }
+      .row {
+        display: flex;
+        gap: 8px;
+      }
+      button {
+        flex: 1;
+        padding: 9px 10px;
+        border: 1px solid var(--border);
+        border-radius: 6px;
+        font-size: 13px;
+        font-weight: 600;
+        cursor: pointer;
+        color: var(--text);
+        background: var(--panel-2);
+        transition: background 0.12s ease, border-color 0.12s ease;
+      }
+      button:hover {
+        background: #26313d;
+      }
+      button:disabled {
+        opacity: 0.5;
+        cursor: not-allowed;
+      }
+      button.primary {
+        background: var(--accent);
+        border-color: var(--accent);
+        color: #05130a;
+      }
+      button.primary:hover {
+        background: var(--accent-hover);
+      }
+      button.send {
+        background: var(--blue);
+        border-color: var(--blue);
+        color: #04101f;
+      }
+      button.send:hover {
+        background: var(--blue-hover);
+      }
+      .preview-head {
+        display: flex;
+        align-items: center;
+        justify-content: space-between;
+        margin: 14px 0 6px;
+      }
+      .preview-head label {
+        margin: 0;
+      }
+      .summary {
+        font-size: 11px;
+        color: var(--muted);
+      }
+      pre#preview {
+        background: var(--panel);
+        border: 1px solid var(--border);
+        border-radius: 6px;
+        padding: 10px;
+        margin: 0;
+        max-height: 240px;
+        overflow: auto;
+        font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
+        font-size: 11px;
+        white-space: pre-wrap;
+        word-break: break-word;
+        color: #c8d3de;
+      }
+      pre#preview:empty::before {
+        content: "No capture yet — click “Capture this page”.";
+        color: var(--muted);
+      }
+      #status {
+        margin-top: 10px;
+        min-height: 16px;
+        font-size: 12px;
+      }
+      #status.ok {
+        color: var(--accent);
+      }
+      #status.err {
+        color: var(--danger);
+      }
+      #status.info {
+        color: var(--muted);
+      }
+      .spacer {
+        height: 8px;
+      }
+    </style>
+  </head>
+  <body>
+    <div class="wrap">
+      <h1><span class="dot"></span> GovArbitrage Capture</h1>
+
+      <div class="field">
+        <label for="apiBase">API base URL</label>
+        <input type="text" id="apiBase" placeholder="http://localhost:3000" spellcheck="false" />
+      </div>
+
+      <div class="field">
+        <label for="importToken">Import token (x-import-token)</label>
+        <input type="text" id="importToken" placeholder="required if the server sets IMPORT_TOKEN" spellcheck="false" />
+      </div>
+
+      <div class="row">
+        <button id="captureBtn" class="primary">Capture this page</button>
+      </div>
+
+      <div class="preview-head">
+        <label>Extracted data</label>
+        <span class="summary" id="summary"></span>
+      </div>
+      <pre id="preview"></pre>
+
+      <div class="spacer"></div>
+
+      <div class="row">
+        <button id="sendBtn" class="send" disabled>Send to GovArbitrage</button>
+      </div>
+
+      <div id="status" class="info"></div>
+    </div>
+
+    <script src="popup.js"></script>
+  </body>
+</html>
diff --git a/extension/popup.js b/extension/popup.js
new file mode 100644
index 0000000..8b1f2c1
--- /dev/null
+++ b/extension/popup.js
@@ -0,0 +1,209 @@
+/**
+ * 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 STORAGE_KEY_TOKEN = 'govarbitrage_import_token';
+const IMPORT_PATH = '/api/import/extension';
+
+const els = {
+  apiBase: document.getElementById('apiBase'),
+  importToken: document.getElementById('importToken'),
+  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, STORAGE_KEY_TOKEN], (res) => {
+      els.apiBase.value = (res && res[STORAGE_KEY]) || DEFAULT_API_BASE;
+      els.importToken.value = (res && res[STORAGE_KEY_TOKEN]) || '';
+    });
+  } catch (_e) {
+    els.apiBase.value = DEFAULT_API_BASE;
+  }
+}
+
+function saveApiBase() {
+  const val = normalizeBase(els.apiBase.value);
+  const token = (els.importToken.value || '').trim();
+  try {
+    chrome.storage.local.set({ [STORAGE_KEY]: val, [STORAGE_KEY_TOKEN]: token });
+  } 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);
+  const token = (els.importToken.value || '').trim();
+  saveApiBase();
+  const endpoint = base + IMPORT_PATH;
+
+  setStatus('Sending to ' + endpoint + ' …', 'info');
+  els.sendBtn.disabled = true;
+
+  try {
+    // The server's requireWrite() authorizes a write via a matching x-import-token
+    // header (or an app session, which a cross-origin extension fetch can't carry).
+    // Send the token when set so Send works against any server with IMPORT_TOKEN set.
+    const headers = { 'Content-Type': 'application/json' };
+    if (token) headers['x-import-token'] = token;
+    const resp = await fetch(endpoint, {
+      method: 'POST',
+      headers,
+      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);
+els.importToken.addEventListener('change', saveApiBase);
+els.importToken.addEventListener('blur', saveApiBase);
+
+loadApiBase();
diff --git a/playwright.config.ts b/playwright.config.ts
new file mode 100644
index 0000000..7976673
--- /dev/null
+++ b/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/prisma/seed.ts b/prisma/seed.ts
new file mode 100644
index 0000000..c36bc00
--- /dev/null
+++ b/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/scripts/deploy-auctions.sh b/scripts/deploy-auctions.sh
new file mode 100755
index 0000000..85eb2b1
--- /dev/null
+++ b/scripts/deploy-auctions.sh
@@ -0,0 +1,135 @@
+#!/bin/bash
+# One-shot deploy of GovArbitrage → auctions.agentabrams.com (Kamatera).
+# Updates the existing stub in place: rsync code, install, migrate, build,
+# populate with the FREE feeds ($0), restart pm2. Preserves the server .env
+# (keeps DATABASE_URL); only appends missing auth secrets.
+set -euo pipefail
+
+HOST=root@45.61.58.125
+DIR=/root/public-projects/govarbitrage
+LOCAL="${LOCAL:-/Volumes/Henry/mac2-offload/govarbitrage}"  # fixed source (offload has a63b94f Decimal->number fix; ~/Projects copy had wrong git + lacked fix, TK-10279)
+
+echo "▶ rsync app → $HOST:$DIR"
+cd "$LOCAL"
+
+# Safety guard (TK-10279): NEVER rsync --delete a stub/empty tree to prod. Abort
+# unless the app source is genuinely present (the old ~/Projects/govarbitrage
+# LOCAL was a 0-file stub whose --delete would have wiped the live site).
+if [ ! -s src/lib/listing-detail.ts ] || [ "$(find src -type f 2>/dev/null | head -20 | wc -l | tr -d ' ')" -lt 5 ]; then
+  echo "✖ ABORT: $LOCAL/src looks empty or stub-like — refusing to rsync --delete to prod (TK-10279 guard)." >&2
+  exit 1
+fi
+
+# --delete makes the server match this repo exactly, removing stale files from
+# the prior stub build (its root app/ dir shadowed our src/app/ and broke the
+# build). Excludes below are protected from deletion (.env, node_modules, etc.).
+rsync -az --delete \
+  --exclude node_modules --exclude .next --exclude .git --exclude '.env' \
+  --exclude logs --exclude 'prisma/*.db' --exclude '.DS_Store' \
+  ./ "$HOST:$DIR/"
+
+echo "▶ remote: env, install, migrate, build, populate, restart"
+ssh "$HOST" bash -s <<'REMOTE'
+set -euo pipefail
+cd /root/public-projects/govarbitrage
+
+# Ensure required auth secrets exist without touching DATABASE_URL.
+touch .env
+grep -q '^AUTH_SECRET=' .env      || echo "AUTH_SECRET=\"$(openssl rand -hex 24)\"" >> .env
+grep -q '^ENCRYPTION_KEY=' .env    || echo "ENCRYPTION_KEY=\"$(openssl rand -hex 32)\"" >> .env
+grep -q '^GSA_API_KEY=' .env       || echo 'GSA_API_KEY="DEMO_KEY"' >> .env
+grep -q '^NODE_ENV=' .env          || echo 'NODE_ENV="production"' >> .env
+# Fleet single-sign-on: copy the shared HMAC secret so a valid *.agentabrams.com
+# "aafleet" cookie is accepted by the middleware (see src/lib/fleet-sso.ts).
+# Read at BUILD time by the Edge middleware, so it must exist before `npm run build`.
+if ! grep -q '^FLEET_SSO_SECRET=' .env && [ -r /var/www/fleet-sso/.secret ]; then
+  echo "FLEET_SSO_SECRET=\"$(tr -d '\n' < /var/www/fleet-sso/.secret)\"" >> .env
+fi
+
+# Google Places API key — REQUIRED by src/lib/places.ts (searchCommercialAgents
+# throws "GOOGLE_PLACES_API_KEY is not configured" when absent, which makes the
+# /api/agents/search route return HTTP 503). Provision it NON-destructively:
+# preserve an existing key, else copy it from the first readable shared on-host
+# secret file (same pattern as FLEET_SSO_SECRET above). If it's absent AND no
+# secret source can be found, DON'T invent a value (a placeholder would make
+# places.ts think it's configured and 503 at request time instead) — emit a
+# LOUD warning so the operator sees it in the deploy output.
+if ! grep -q '^GOOGLE_PLACES_API_KEY=' .env; then
+  PLACES_SECRET=""
+  for cand in /var/www/fleet-sso/google-places.secret /root/.secrets/google-places.secret /root/.secrets/GOOGLE_PLACES_API_KEY; do
+    if [ -r "$cand" ]; then PLACES_SECRET="$cand"; break; fi
+  done
+  if [ -n "$PLACES_SECRET" ]; then
+    echo "GOOGLE_PLACES_API_KEY=\"$(tr -d '\n' < "$PLACES_SECRET")\"" >> .env
+    echo "  provisioned GOOGLE_PLACES_API_KEY from $PLACES_SECRET"
+  else
+    echo "⚠ GOOGLE_PLACES_API_KEY not set and no secret source found — /agents live search will 503 until it is added"
+  fi
+fi
+
+echo "  npm install"
+npm install --no-audit --no-fund
+echo "  prisma generate + db push"
+npx prisma generate
+# NON-destructive push: our schema changes are additive (new enum values, new
+# ListingStatus enum, new nullable/defaulted columns), so they apply in place
+# WITHOUT wiping the live listings. (The old --force-reset was only needed for
+# the very first deploy over the incompatible stub schema.) The free-feed
+# imports below UPSERT by (source, sourceAuctionId), so re-running just refreshes.
+# --accept-data-loss: our changes are additive (new nullable columns, new unique
+# constraints like User.appleSub). Prisma flags ANY unique-constraint add as
+# potential data loss, but a nullable unique column applies cleanly (Postgres
+# allows unlimited NULLs) and a genuinely-conflicting change would still error
+# rather than drop data. Required so additive migrations don't stall the deploy.
+npx prisma db push --skip-generate --accept-data-loss
+echo "  build"
+npm run build
+echo "  seed users (+ demo) then import FREE feeds (\$0)"
+npx tsx prisma/seed.ts || true
+npx tsx scripts/import-govdeals-free.ts 120 || true
+npx tsx scripts/import-gsa.ts 200 || true
+npx tsx scripts/import-grays.ts 100 || true
+npx tsx scripts/import-municibid-free.ts 100 || true
+npx tsx scripts/import-publicsurplus-free.ts 100 || true
+npx tsx scripts/import-govplanet-free.ts 120 || true
+echo "  restart pm2"
+pm2 restart govarbitrage --update-env
+pm2 save
+
+echo "  install prod cron (auto-refresh + liveness) — self-maintaining live site"
+mkdir -p /root/public-projects/govarbitrage/logs
+# cron runs with a minimal PATH; bake in node's bin dir resolved right now.
+NODE_BIN="$(dirname "$(command -v node)")"
+cat > /etc/cron.d/govarbitrage <<CRON
+SHELL=/bin/bash
+PATH=$NODE_BIN:/usr/local/bin:/usr/bin:/bin
+# Refresh all 6 free feeds daily at 12:00 (UPSERT — keeps the live site fresh).
+0 12 * * * root cd /root/public-projects/govarbitrage && ( npx tsx scripts/import-govdeals-free.ts 120; npx tsx scripts/import-gsa.ts 200; npx tsx scripts/import-grays.ts 100; npx tsx scripts/import-municibid-free.ts 100; npx tsx scripts/import-publicsurplus-free.ts 100; npx tsx scripts/import-govplanet-free.ts 120 ) >> logs/refresh.log 2>&1
+# Fast dead-listing sweep every 15 min (Tier1 instant-expire + Tier2 verify).
+*/15 * * * * root cd /root/public-projects/govarbitrage && npx tsx scripts/liveness-sweep.ts 40 >> logs/liveness.log 2>&1
+CRON
+chmod 0644 /etc/cron.d/govarbitrage
+echo "  cron installed: refresh @12:00, liveness every 15m (PATH node=$NODE_BIN)"
+REMOTE
+
+echo "▶ smoke test"
+curl -s -o /dev/null -w "  auctions.agentabrams.com/login -> %{http_code}\n" https://auctions.agentabrams.com/login
+# Retry /login until it genuinely serves (200) or we exhaust the attempts, so a
+# transient 502 boot-race (pm2 mid-restart) isn't misreported as a failed deploy.
+LOGIN_URL="https://auctions.agentabrams.com/login"
+SMOKE_OK=""
+for attempt in $(seq 1 15); do
+  # Guarded so a non-2xx curl (and pipefail) can't abort the script mid-check.
+  code="$(curl -s -o /dev/null -w '%{http_code}' "$LOGIN_URL" || echo 000)"
+  if [ "$code" = "200" ]; then
+    echo "  ✔ /login serving 200 (attempt $attempt/15)"
+    SMOKE_OK=1
+    break
+  fi
+  echo "  … /login -> $code, retrying ($attempt/15)"
+  sleep 2
+done
+if [ -z "$SMOKE_OK" ]; then
+  echo "  ⚠ /login never returned 200 after 15 attempts — check pm2 logs (may be a real boot failure, not just a race)"
+fi
+echo "✔ deploy complete — https://auctions.agentabrams.com  (login: admin@govarbitrage.local / changeme)"
diff --git a/scripts/george-env.py b/scripts/george-env.py
new file mode 100644
index 0000000..4a4d1fc
--- /dev/null
+++ b/scripts/george-env.py
@@ -0,0 +1,17 @@
+#!/usr/bin/env python3
+"""Print export lines for George transport creds, read at runtime from their
+canonical homes (~/.claude.json, george-gmail/.env) — never duplicated to disk.
+Used via: eval "$(/usr/bin/python3 scripts/george-env.py)"."""
+import json, os, re, shlex
+
+env = json.load(open(os.path.expanduser("~/.claude.json")))["mcpServers"]["george"]["env"]
+print("export GEORGE_URL=" + shlex.quote(env["GEORGE_URL"]))
+print("export GEORGE_BASIC_AUTH=" + shlex.quote(env["GEORGE_BASIC_AUTH"]))
+
+tok = ""
+pat = re.compile(r"GEORGE_EXTERNAL_SEND_TOKEN=[\"']?([^\"'\n]+)")
+for line in open(os.path.expanduser("~/Projects/george-gmail/.env")):
+    m = pat.match(line)
+    if m:
+        tok = m.group(1)
+print("export GEORGE_EXTERNAL_SEND_TOKEN=" + shlex.quote(tok))
diff --git a/scripts/george-env.sh b/scripts/george-env.sh
new file mode 100755
index 0000000..4a5060d
--- /dev/null
+++ b/scripts/george-env.sh
@@ -0,0 +1,6 @@
+#!/bin/sh
+# Source this to export George transport credentials for THIS PROCESS ONLY.
+# Reads from their canonical homes at runtime — never duplicated to disk
+# (same pattern as run-digest.sh). Logic lives in george-env.py because
+# heredocs inside $() are unreliable under plain sh.
+eval "$(/usr/bin/python3 "$(dirname "$0")/george-env.py")"
diff --git a/scripts/hot-deal-alert.ts b/scripts/hot-deal-alert.ts
new file mode 100644
index 0000000..896f272
--- /dev/null
+++ b/scripts/hot-deal-alert.ts
@@ -0,0 +1,172 @@
+// 🔥 HOT DEAL alerter. Finds NEW listings that pass the hot-deal gate (buy-side
+// headroom + proven sell-side demand), emails them via George, and stamps
+// hotAlertedAt so each deal alerts exactly once. Meant to run right after every
+// import refresh. $0 (local DB + internal George send to Steve).
+//
+// Env (from .env): DATABASE_URL, GEORGE_URL, GEORGE_BASIC_AUTH,
+//   HOT_TO (default steve@designerwallcoverings.com), DIGEST_FROM_ACCOUNT,
+//   APP_URL, HOT_MAX (max deals per email, default 12),
+//   HOT_DRY_RUN=1 (print instead of send), plus the HOT_MIN_* thresholds.
+//
+// Run: npx tsx scripts/hot-deal-alert.ts
+
+import { readFileSync } from "node:fs";
+import { fileURLToPath } from "node:url";
+import { dirname, join } from "node:path";
+
+function loadEnv() {
+  const root = join(dirname(fileURLToPath(import.meta.url)), "..");
+  try {
+    const raw = readFileSync(join(root, ".env"), "utf8");
+    for (const line of raw.split("\n")) {
+      const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/i);
+      if (!m) continue;
+      const key = m[1];
+      let val = m[2].trim();
+      if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
+        val = val.slice(1, -1);
+      }
+      if (process.env[key] === undefined) process.env[key] = val;
+    }
+  } catch {
+    /* rely on ambient env */
+  }
+}
+loadEnv();
+
+const { prisma } = await import("../src/lib/db");
+const { findHotDeals } = await import("../src/lib/hot-deals");
+const { formatMoney, formatPercent, countdown } = await import("../src/lib/utils");
+
+const HOT_TO = process.env.HOT_TO || process.env.DIGEST_TO || "steve@designerwallcoverings.com";
+const FROM_ACCOUNT = process.env.DIGEST_FROM_ACCOUNT || "steve-office";
+const APP_URL = process.env.APP_URL || "http://localhost:3737";
+const HOT_MAX = Number(process.env.HOT_MAX || 12);
+
+function escapeHtml(s: string): string {
+  return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]!));
+}
+
+type Deal = Awaited<ReturnType<typeof findHotDeals>>[number];
+
+function renderDeal(d: Deal, i: number): string {
+  const l = d.listing;
+  const closes = l.closingAt ? countdown(l.closingAt) : "Make Offer (no deadline)";
+  const loc = [l.locationCity, l.locationState].filter(Boolean).join(", ") || "—";
+  // Honesty Gate: confidence badge + conservative figures (never the raw hype).
+  const confColor = d.confidence === "HIGH" ? "#166534" : d.confidence === "MEDIUM" ? "#b45309" : "#64748b";
+  const confBg = d.confidence === "HIGH" ? "#dcfce7" : d.confidence === "MEDIUM" ? "#fef3c7" : "#f1f5f9";
+  const confBadge = `<span style="background:${confBg};color:${confColor};padding:1px 6px;border-radius:4px;font-weight:700;font-size:11px">${d.confidence} confidence</span>`;
+  const roiText = `${formatPercent(d.roiConservative)}${d.roiCapped ? "+" : ""}`;
+  const sellProof =
+    d.buyerLeadCount > 0
+      ? `<span style="background:#dcfce7;color:#166534;padding:1px 6px;border-radius:4px;font-weight:700">${d.buyerLeadCount} buyer lead${d.buyerLeadCount > 1 ? "s" : ""}${d.buyerLeadTop ? ` · top offer ${formatMoney(d.buyerLeadTop)}` : ""}</span>`
+      : `<span style="color:#166534">${d.comparableCount} comps · ${formatPercent(d.probSale)} sell prob</span>`;
+  return `<div style="border:1px solid #fecaca;border-left:4px solid #ef4444;border-radius:8px;margin:0 0 14px;padding:12px">
+    <div style="font-size:15px;font-weight:700">🔥 #${i + 1} · <a href="${APP_URL}/listings/${l.id}" style="color:#b91c1c;text-decoration:none">${escapeHtml(l.title)}</a></div>
+    <div style="color:#64748b;font-size:12px;margin:2px 0 8px">
+      ${l.source.replace(/_/g, " ")} · #${escapeHtml(l.sourceAuctionId)} · ${loc} · closes ${closes}
+      ${l.sourceUrl ? ` · <a href="${escapeHtml(l.sourceUrl)}" style="color:#2563eb">source ↗</a>` : ""}
+    </div>
+    <table style="width:100%;border-collapse:collapse;font-size:13px">
+      <tr>
+        <td style="padding:3px 8px 3px 0;color:#166534;font-weight:700;width:50%">SELL side — can you flip it?</td>
+        <td style="padding:3px 8px 3px 0;color:#b91c1c;font-weight:700">BUY side — is there room?</td>
+      </tr>
+      <tr valign="top">
+        <td style="padding-right:8px">
+          <div>Est. resale (conservative): <b>${formatMoney(d.expectedSaleLow)}</b> ${confBadge}</div>
+          <div>Demand score: <b>${Math.round(d.demandScore)}/100</b></div>
+          <div>Sell proof: ${sellProof}</div>
+        </td>
+        <td>
+          <div>Current bid: <b>${formatMoney(d.currentBid)}</b></div>
+          <div>Recommended max bid: <b>${formatMoney(d.recMax)}</b> <span style="color:#64748b">(${formatPercent(d.headroom)} headroom)</span></div>
+          <div>Est. ROI: <b>${roiText}</b> <span style="color:#64748b">(low-side)</span></div>
+        </td>
+      </tr>
+    </table>
+    <div style="font-size:11px;color:#94a3b8;margin-top:6px">⚠︎ ${escapeHtml(d.disclaimer)}</div>
+    <div style="font-size:11px;color:#64748b;margin-top:3px">${escapeHtml(d.score.explanation)}</div>
+  </div>`;
+}
+
+function buildHtml(deals: Deal[]): string {
+  const when = new Date().toLocaleString("en-US", { dateStyle: "medium", timeStyle: "short" });
+  return `<div style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#0f172a;max-width:820px">
+    <h2 style="margin:0 0 4px;color:#b91c1c">🔥 ${deals.length} HOT DEAL${deals.length > 1 ? "S" : ""} — curated, verified-demand, early</h2>
+    <div style="color:#64748b;font-size:13px;margin-bottom:14px">${when} · each cleared the demand + bid-headroom gate; figures are conservative heuristic estimates (confidence-labeled) — always verify comps before bidding · act before close</div>
+    ${deals.map(renderDeal).join("")}
+    <p style="margin-top:8px"><a href="${APP_URL}" style="color:#2563eb">Open the dashboard →</a></p>
+    <p style="color:#94a3b8;font-size:11px">HOT gate: score≥${process.env.HOT_MIN_SCORE || 75}, arbitrage≥${process.env.HOT_MIN_ARBITRAGE || 70}, demand≥${process.env.HOT_MIN_DEMAND || 50}, net≥$${process.env.HOT_MIN_NET || 150}, ROI≥${Math.round(Number(process.env.HOT_MIN_ROI || 0.5) * 100)}%, bid headroom≥${Math.round(Number(process.env.HOT_MIN_HEADROOM_PCT || 0.15) * 100)}%. Verify comps before bidding.</p>
+  </div>`;
+}
+
+async function sendViaGeorge(subject: string, html: string) {
+  const base = process.env.GEORGE_URL;
+  const auth = process.env.GEORGE_BASIC_AUTH;
+  if (!base || !auth) throw new Error("GEORGE_URL / GEORGE_BASIC_AUTH not set");
+  const res = await fetch(`${base}/api/send`, {
+    method: "POST",
+    headers: { "Content-Type": "application/json", Authorization: `Basic ${auth}` },
+    body: JSON.stringify({ account: FROM_ACCOUNT, to: HOT_TO, subject, body: html }),
+  });
+  const text = await res.text();
+  if (!res.ok) throw new Error(`George send failed ${res.status}: ${text.slice(0, 300)}`);
+  return text;
+}
+
+async function main() {
+  const deals = await findHotDeals({ onlyUnalerted: true, limit: HOT_MAX });
+  if (deals.length === 0) {
+    console.log(`[${new Date().toISOString()}] No new hot deals. No alert sent.`);
+    await prisma.$disconnect();
+    return;
+  }
+  const subject = `🔥 ${deals.length} HOT DEAL${deals.length > 1 ? "S" : ""} on GovArbitrage`;
+  const html = buildHtml(deals);
+
+  if (process.env.HOT_DRY_RUN === "1") {
+    const { writeFileSync, mkdirSync } = await import("node:fs");
+    mkdirSync("logs", { recursive: true });
+    writeFileSync("logs/hot-deal-preview.html", html);
+    console.log(`[DRY RUN] to=${HOT_TO} subject="${subject}" (${deals.length} deals)`);
+    deals.forEach((d, i) =>
+      console.log(
+        `  ${i + 1}. ${d.listing.title.slice(0, 44)} — bid ${Math.round(d.currentBid)}→max ${Math.round(d.recMax)}, net ${Math.round(d.netProfit)}, ROI ${Math.round(d.roi * 100)}%`,
+      ),
+    );
+    console.log("Preview: logs/hot-deal-preview.html (NOT marking alerted in dry-run)");
+    await prisma.$disconnect();
+    return;
+  }
+
+  // Stamp BEFORE sending so a crash/DB failure AFTER the email can't leave the
+  // deals un-stamped and cause the next run to re-email them (duplicate alert).
+  // If the send itself then fails, roll the stamp back so they re-alert normally.
+  const ids = deals.map((d) => d.listing.id);
+  const stampedAt = new Date();
+  await prisma.listing.updateMany({
+    where: { id: { in: ids } },
+    data: { hotAlertedAt: stampedAt },
+  });
+  let result: string;
+  try {
+    result = await sendViaGeorge(subject, html);
+  } catch (err) {
+    await prisma.listing.updateMany({
+      where: { id: { in: ids }, hotAlertedAt: stampedAt },
+      data: { hotAlertedAt: null },
+    });
+    throw err;
+  }
+  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/scripts/import-apify-govdeals.ts b/scripts/import-apify-govdeals.ts
new file mode 100644
index 0000000..9619926
--- /dev/null
+++ b/scripts/import-apify-govdeals.ts
@@ -0,0 +1,36 @@
+// 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}`));
+  // Fail the run (non-zero exit) so a scheduler sees red when the dataset was
+  // empty or every row failed — not a false "success".
+  if (rows.length === 0 || (results.length > 0 && failed.length === results.length)) {
+    console.error(`[import-apify-govdeals] RUN FAILED: fetched ${rows.length}, ${failed.length}/${results.length} ingests failed.`);
+    process.exitCode = 1;
+  }
+  await prisma.$disconnect();
+}
+
+main().catch(async (e) => {
+  console.error("[import-apify-govdeals] ERROR:", e.message);
+  await prisma.$disconnect();
+  process.exit(1);
+});
diff --git a/scripts/import-govdeals-free.ts b/scripts/import-govdeals-free.ts
new file mode 100644
index 0000000..9c53638
--- /dev/null
+++ b/scripts/import-govdeals-free.ts
@@ -0,0 +1,50 @@
+// 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, marketErrors = 0;
+  for (const { biz, limit } of MARKETS) {
+    console.log(`[${new Date().toISOString()}] Fetching ${limit} newest ${biz} listings (FREE maestro API)…`);
+    // Isolate each market: one market's transient failure must not abort the
+    // siblings in the same run.
+    try {
+      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;
+    } catch (e) {
+      marketErrors++;
+      console.error(`  ✗ market ${biz} failed:`, e instanceof Error ? e.message : e);
+    }
+  }
+  console.log(`Done: fetched ${fetched}, ${created} created, ${updated} updated, ${failed} failed, ${marketErrors} market(s) errored. Cost: $0 (free API).`);
+  // Fail the run (non-zero exit) so a scheduler sees red when nothing was
+  // fetched or every market errored — not a false "success".
+  if (fetched === 0 || marketErrors === MARKETS.length) {
+    console.error(`[import-govdeals-free] RUN FAILED: fetched ${fetched}, ${marketErrors}/${MARKETS.length} markets errored.`);
+    process.exitCode = 1;
+  }
+  await prisma.$disconnect();
+}
+
+main().catch(async (e) => {
+  console.error("[import-govdeals-free] ERROR:", e.message);
+  await prisma.$disconnect();
+  process.exit(1);
+});
diff --git a/scripts/import-govplanet-free.ts b/scripts/import-govplanet-free.ts
new file mode 100644
index 0000000..75e05a4
--- /dev/null
+++ b/scripts/import-govplanet-free.ts
@@ -0,0 +1,35 @@
+// 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.`,
+  );
+  // Fail the run (non-zero exit) so a scheduler sees red when the feed returned
+  // nothing (upstream shape change) or every row failed — not a false "success".
+  if (rows.length === 0 || (results.length > 0 && failed === results.length)) {
+    console.error(`[import-govplanet-free] RUN FAILED: fetched ${rows.length}, ${failed}/${results.length} ingests failed.`);
+    process.exitCode = 1;
+  }
+  await prisma.$disconnect();
+}
+
+main().catch(async (e) => {
+  console.error("[import-govplanet-free] ERROR:", e.message);
+  await prisma.$disconnect();
+  process.exit(1);
+});
diff --git a/scripts/import-grays.ts b/scripts/import-grays.ts
new file mode 100644
index 0000000..2f0af49
--- /dev/null
+++ b/scripts/import-grays.ts
@@ -0,0 +1,31 @@
+// 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.`);
+  // Fail the run (non-zero exit) so a scheduler sees red when the feed returned
+  // nothing (upstream shape change) or every row failed — not a false "success".
+  if (rows.length === 0 || (results.length > 0 && failed === results.length)) {
+    console.error(`[import-grays] RUN FAILED: fetched ${rows.length}, ${failed}/${results.length} ingests failed.`);
+    process.exitCode = 1;
+  }
+  await prisma.$disconnect();
+}
+
+main().catch(async (e) => {
+  console.error("[import-grays] ERROR:", e.message);
+  await prisma.$disconnect();
+  process.exit(1);
+});
diff --git a/scripts/import-gsa.ts b/scripts/import-gsa.ts
new file mode 100644
index 0000000..4b552ab
--- /dev/null
+++ b/scripts/import-gsa.ts
@@ -0,0 +1,37 @@
+// 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}`));
+  // Fail the run (non-zero exit) so a scheduler sees red when the feed returned
+  // nothing (upstream shape change) or every row failed — not a false "success".
+  if (rows.length === 0 || (results.length > 0 && failed.length === results.length)) {
+    console.error(`[import-gsa] RUN FAILED: fetched ${rows.length}, ${failed.length}/${results.length} ingests failed.`);
+    process.exitCode = 1;
+  }
+  await prisma.$disconnect();
+}
+
+main().catch(async (e) => {
+  console.error("[import-gsa] ERROR:", e.message);
+  await prisma.$disconnect();
+  process.exit(1);
+});
diff --git a/scripts/import-municibid-free.ts b/scripts/import-municibid-free.ts
new file mode 100644
index 0000000..0066b57
--- /dev/null
+++ b/scripts/import-municibid-free.ts
@@ -0,0 +1,35 @@
+// 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.`,
+  );
+  // Fail the run (non-zero exit) so a scheduler sees red when the feed returned
+  // nothing (upstream shape change) or every row failed — not a false "success".
+  if (rows.length === 0 || (results.length > 0 && failed === results.length)) {
+    console.error(`[import-municibid-free] RUN FAILED: fetched ${rows.length}, ${failed}/${results.length} ingests failed.`);
+    process.exitCode = 1;
+  }
+  await prisma.$disconnect();
+}
+
+main().catch(async (e) => {
+  console.error("[import-municibid-free] ERROR:", e.message);
+  await prisma.$disconnect();
+  process.exit(1);
+});
diff --git a/scripts/import-publicsurplus-free.ts b/scripts/import-publicsurplus-free.ts
new file mode 100644
index 0000000..a241164
--- /dev/null
+++ b/scripts/import-publicsurplus-free.ts
@@ -0,0 +1,35 @@
+// 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.`,
+  );
+  // Fail the run (non-zero exit) so a scheduler sees red when the feed returned
+  // nothing (upstream shape change) or every row failed — not a false "success".
+  if (rows.length === 0 || (results.length > 0 && failed === results.length)) {
+    console.error(`[import-publicsurplus-free] RUN FAILED: fetched ${rows.length}, ${failed}/${results.length} ingests failed.`);
+    process.exitCode = 1;
+  }
+  await prisma.$disconnect();
+}
+
+main().catch(async (e) => {
+  console.error("[import-publicsurplus-free] ERROR:", e.message);
+  await prisma.$disconnect();
+  process.exit(1);
+});
diff --git a/scripts/liveness-sweep.ts b/scripts/liveness-sweep.ts
new file mode 100644
index 0000000..a937831
--- /dev/null
+++ b/scripts/liveness-sweep.ts
@@ -0,0 +1,167 @@
+// 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",
+];
+
+// Signals that a still-LIVE listing is rendered. A gone-marker found anywhere in
+// the body (nav/footer/"related items" widget/cookie banner) is NOT trusted when
+// any of these are present, so an unrelated ended-lot string can't falsely
+// remove a live listing. Only a marker in the page <title> is trusted outright.
+const LIVE_SIGNALS = [
+  "current bid",
+  "place bid",
+  "time remaining",
+  "time left",
+  "bid history",
+  "add to watchlist",
+  "buy it now",
+];
+
+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",
+      // Bound each request so one host that accepts the connection but never
+      // finishes the body can't block the whole sequential batch indefinitely.
+      signal: AbortSignal.timeout(10_000),
+    });
+    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 html = await res.text();
+    const body = html.toLowerCase();
+    const title = (html.match(/<title[^>]*>([\s\S]*?)<\/title>/i)?.[1] ?? "").toLowerCase();
+    // A gone-marker in the <title> is authoritative for THIS page.
+    if (GONE_MARKERS.some((m) => title.includes(m))) return { gone: true, live: false };
+    // A body-only marker is trusted ONLY when the page shows no live-listing
+    // signals — otherwise an unrelated ended-lot string in a sidebar/footer/
+    // related-items widget would falsely remove a still-live listing.
+    const bodyGone = GONE_MARKERS.some((m) => body.includes(m));
+    const looksLive = LIVE_SIGNALS.some((s) => body.includes(s));
+    if (bodyGone && !looksLive) return { gone: true, live: false };
+    return { gone: false, live: true };
+  } catch {
+    return { gone: false, live: false }; // network error / timeout — 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/scripts/probe-govdeals.ts b/scripts/probe-govdeals.ts
new file mode 100644
index 0000000..43cb30b
--- /dev/null
+++ b/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/scripts/run-digest.sh b/scripts/run-digest.sh
new file mode 100755
index 0000000..7e223f6
--- /dev/null
+++ b/scripts/run-digest.sh
@@ -0,0 +1,19 @@
+#!/bin/sh
+# Wrapper for the twice-daily GovArbitrage opportunity digest (launchd-invoked).
+# Reads George's credentials from their single canonical home (~/.claude.json)
+# at runtime and exports them for this process only — never duplicated to disk.
+set -e
+cd "$(dirname "$0")/.."
+
+# Pull GEORGE_URL + GEORGE_BASIC_AUTH from the george MCP env in ~/.claude.json.
+eval "$(/usr/bin/python3 - <<'PY'
+import json, os, shlex
+p = os.path.expanduser("~/.claude.json")
+env = json.load(open(p))["mcpServers"]["george"]["env"]
+print("export GEORGE_URL=" + shlex.quote(env["GEORGE_URL"]))
+print("export GEORGE_BASIC_AUTH=" + shlex.quote(env["GEORGE_BASIC_AUTH"]))
+PY
+)"
+
+export PATH="/opt/homebrew/bin:/usr/bin:/bin:$PATH"
+exec /opt/homebrew/bin/npx tsx scripts/send-digest.ts
diff --git a/scripts/run-govdeals-free.sh b/scripts/run-govdeals-free.sh
new file mode 100755
index 0000000..f92d88f
--- /dev/null
+++ b/scripts/run-govdeals-free.sh
@@ -0,0 +1,7 @@
+#!/bin/sh
+# FREE daily GovDeals refresh (launchd 04:40, before the 05:00 GSA import +
+# 06:00 digest). Hits GovDeals' own public API — $0, no Apify, no browser.
+set -e
+cd "$(dirname "$0")/.."
+export PATH="/opt/homebrew/bin:/usr/bin:/bin:$PATH"
+exec /opt/homebrew/bin/npx tsx scripts/import-govdeals-free.ts 120
diff --git a/scripts/run-govplanet-free.sh b/scripts/run-govplanet-free.sh
new file mode 100755
index 0000000..c7ac9a4
--- /dev/null
+++ b/scripts/run-govplanet-free.sh
@@ -0,0 +1,8 @@
+#!/bin/sh
+# FREE daily govplanet refresh (launchd ~noon). $0, no browser, no API key —
+# server-rendered HTML / embedded JSON parsed straight from the site.
+# Refreshes the LOCAL govarbitrage DB (feeds the digest email).
+set -e
+cd "$(dirname "$0")/.."
+export PATH="/opt/homebrew/bin:/usr/bin:/bin:$PATH"
+exec /opt/homebrew/bin/npx tsx scripts/import-govplanet-free.ts 100
diff --git a/scripts/run-gsa-import.sh b/scripts/run-gsa-import.sh
new file mode 100755
index 0000000..2d62704
--- /dev/null
+++ b/scripts/run-gsa-import.sh
@@ -0,0 +1,7 @@
+#!/bin/sh
+# Refresh GovArbitrage with live GSA Auctions data (launchd-invoked at 05:00,
+# one hour before the 06:00 digest). Heuristic/local research — $0, no scraping.
+set -e
+cd "$(dirname "$0")/.."
+export PATH="/opt/homebrew/bin:/usr/bin:/bin:$PATH"
+exec /opt/homebrew/bin/npx tsx scripts/import-gsa.ts 200
diff --git a/scripts/run-hot-deals.sh b/scripts/run-hot-deals.sh
new file mode 100755
index 0000000..0c9f29b
--- /dev/null
+++ b/scripts/run-hot-deals.sh
@@ -0,0 +1,15 @@
+#!/bin/sh
+# 🔥 HOT DEAL alerter (launchd-invoked, mac3 only — needs George creds).
+# Reads GEORGE_* from ~/.claude.json at runtime (never duplicated to disk),
+# then emails any NEW hot deals. $0. Runs after the import refresh jobs.
+set -e
+cd "$(dirname "$0")/.."
+eval "$(/usr/bin/python3 - <<'PY'
+import json, os, shlex
+env = json.load(open(os.path.expanduser("~/.claude.json")))["mcpServers"]["george"]["env"]
+print("export GEORGE_URL=" + shlex.quote(env["GEORGE_URL"]))
+print("export GEORGE_BASIC_AUTH=" + shlex.quote(env["GEORGE_BASIC_AUTH"]))
+PY
+)"
+export PATH="/opt/homebrew/bin:/usr/bin:/bin:$PATH"
+exec /opt/homebrew/bin/npx tsx scripts/hot-deal-alert.ts
diff --git a/scripts/run-liveness.sh b/scripts/run-liveness.sh
new file mode 100755
index 0000000..9eb96ee
--- /dev/null
+++ b/scripts/run-liveness.sh
@@ -0,0 +1,7 @@
+#!/bin/sh
+# Fast dead-listing sweep (launchd/cron-invoked). Tier1 expires past-closing
+# listings instantly ($0); Tier2 network-verifies a batch. No creds needed.
+set -e
+cd "$(dirname "$0")/.."
+export PATH="/opt/homebrew/bin:/usr/bin:/bin:$PATH"
+exec /opt/homebrew/bin/npx tsx scripts/liveness-sweep.ts 40
diff --git a/scripts/run-municibid-free.sh b/scripts/run-municibid-free.sh
new file mode 100755
index 0000000..30a4f21
--- /dev/null
+++ b/scripts/run-municibid-free.sh
@@ -0,0 +1,8 @@
+#!/bin/sh
+# FREE daily municibid refresh (launchd ~noon). $0, no browser, no API key —
+# server-rendered HTML / embedded JSON parsed straight from the site.
+# Refreshes the LOCAL govarbitrage DB (feeds the digest email).
+set -e
+cd "$(dirname "$0")/.."
+export PATH="/opt/homebrew/bin:/usr/bin:/bin:$PATH"
+exec /opt/homebrew/bin/npx tsx scripts/import-municibid-free.ts 100
diff --git a/scripts/run-newsletter-digest.sh b/scripts/run-newsletter-digest.sh
new file mode 100755
index 0000000..b0181ef
--- /dev/null
+++ b/scripts/run-newsletter-digest.sh
@@ -0,0 +1,10 @@
+#!/bin/sh
+# Twice-daily newsletter digest → CONFIRMED subscribers (launchd-invoked).
+# George creds injected at runtime; .env supplies the newsletter config.
+# sendNewsletterEmail() enforces the live gate (mode + approval token) and
+# appends a redacted record of every send to logs/newsletter-outbox.jsonl.
+set -e
+cd "$(dirname "$0")/.."
+. scripts/george-env.sh
+export PATH="/opt/homebrew/bin:/usr/bin:/bin:$PATH"
+exec /opt/homebrew/bin/npx tsx --tsconfig tsconfig.json scripts/send-newsletter-digest.ts
diff --git a/scripts/run-newsletter-server.sh b/scripts/run-newsletter-server.sh
new file mode 100755
index 0000000..f08d74c
--- /dev/null
+++ b/scripts/run-newsletter-server.sh
@@ -0,0 +1,10 @@
+#!/bin/sh
+# Production server wrapper (pm2-invoked). Injects George transport creds at
+# runtime, then starts Next on :3737. AUTH_SECRET must be present in .env —
+# the session module refuses the dev fallback in production.
+set -e
+cd "$(dirname "$0")/.."
+. scripts/george-env.sh
+export PATH="/opt/homebrew/bin:/usr/bin:/bin:$PATH"
+export NODE_ENV=production
+exec /opt/homebrew/bin/npx next start -p 3737
diff --git a/scripts/run-publicsurplus-free.sh b/scripts/run-publicsurplus-free.sh
new file mode 100755
index 0000000..a42c310
--- /dev/null
+++ b/scripts/run-publicsurplus-free.sh
@@ -0,0 +1,8 @@
+#!/bin/sh
+# FREE daily publicsurplus refresh (launchd ~noon). $0, no browser, no API key —
+# server-rendered HTML / embedded JSON parsed straight from the site.
+# Refreshes the LOCAL govarbitrage DB (feeds the digest email).
+set -e
+cd "$(dirname "$0")/.."
+export PATH="/opt/homebrew/bin:/usr/bin:/bin:$PATH"
+exec /opt/homebrew/bin/npx tsx scripts/import-publicsurplus-free.ts 100
diff --git a/scripts/seed-digest-snapshots.ts b/scripts/seed-digest-snapshots.ts
new file mode 100644
index 0000000..5525b05
--- /dev/null
+++ b/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/scripts/send-digest.ts b/scripts/send-digest.ts
new file mode 100644
index 0000000..45d8c97
--- /dev/null
+++ b/scripts/send-digest.ts
@@ -0,0 +1,257 @@
+// 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, " ")} ·
+      ${escapeHtml([l.locationCity, l.locationState].filter(Boolean).join(", ") || "—")} ·
+      closes ${closes} · <b style="color:#16a34a">${net} net</b> · <b>${roi} ROI</b>
+    </div>
+    <table style="width:100%;border-collapse:collapse"><tr valign="top">
+      <td style="width:34%;padding-right:12px">
+        <div style="font-weight:600;color:#334155;font-size:12px;margin-bottom:2px">Valuation</div>
+        ${l.research ? valuationTable(l.research) : "<i>pending</i>"}
+      </td>
+      <td style="width:36%;padding-right:12px">
+        <div style="font-weight:600;color:#334155;font-size:12px;margin-bottom:2px">Cost Breakdown &amp; Profit</div>
+        ${l.costBreakdown ? costTable(l.costBreakdown) : "<i>pending</i>"}
+      </td>
+      <td style="width:30%">
+        <div style="font-weight:600;color:#334155;font-size:12px;margin-bottom:2px">Scores</div>
+        ${scoreTable(l.scores)}
+      </td>
+    </tr></table>
+  </div>`;
+}
+
+function buildHtml(items: Detail[], skepticFlags: Map<string, string> = new Map()): string {
+  const when = new Date().toLocaleString("en-US", { dateStyle: "medium", timeStyle: "short" });
+  if (items.length === 0) {
+    return `<p>No open auction opportunities right now (${when}).</p>`;
+  }
+  return `<div style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#0f172a;max-width:900px">
+    <h2 style="margin:0 0 4px">GovArbitrage — Top ${items.length} Opportunities</h2>
+    <div style="color:#64748b;font-size:13px;margin-bottom:14px">${when} · ranked by Overall Opportunity · still-open auctions · detailed valuation, costs &amp; scores per item</div>
+    ${items.map((l, i) => renderItem(l, i + 1, skepticFlags.get(l.id))).join("")}
+    <p style="margin-top:8px"><a href="${APP_URL}" style="color:#2563eb">Open the dashboard →</a></p>
+    <p style="color:#94a3b8;font-size:11px">Recommended max bids back-solve to a 40% target ROI. Figures are estimates — verify comps before bidding.</p>
+  </div>`;
+}
+
+function escapeHtml(s: string): string {
+  return s.replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]!));
+}
+
+async function sendViaGeorge(subject: string, html: string) {
+  const base = process.env.GEORGE_URL;
+  const auth = process.env.GEORGE_BASIC_AUTH;
+  if (!base || !auth) throw new Error("GEORGE_URL / GEORGE_BASIC_AUTH not set");
+  const res = await fetch(`${base}/api/send`, {
+    method: "POST",
+    headers: { "Content-Type": "application/json", Authorization: `Basic ${auth}` },
+    body: JSON.stringify({ account: FROM_ACCOUNT, to: DIGEST_TO, subject, body: html }),
+  });
+  const text = await res.text();
+  if (!res.ok) throw new Error(`George send failed ${res.status}: ${text.slice(0, 300)}`);
+  return text;
+}
+
+async function main() {
+  const ids = await topTenIds();
+  const items = await fetchDetails(ids);
+  const skepticFlags = await skepticCriticalFlags(ids);
+  const hour = new Date().getHours();
+  const slot = hour < 12 ? "Morning" : "Evening";
+  const subject = `GovArbitrage — Top ${items.length} Opportunities (${slot})`;
+  const html = buildHtml(items, skepticFlags);
+
+  if (process.env.DIGEST_DRY_RUN === "1") {
+    const { writeFileSync, mkdirSync } = await import("node:fs");
+    mkdirSync("logs", { recursive: true }); // logs/ is gitignored — create it so a fresh checkout's dry-run doesn't ENOENT
+    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/scripts/send-newsletter-digest.ts b/scripts/send-newsletter-digest.ts
new file mode 100644
index 0000000..91f74c2
--- /dev/null
+++ b/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/scripts/set-admin.ts b/scripts/set-admin.ts
new file mode 100644
index 0000000..8b09170
--- /dev/null
+++ b/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/src/lib/listings-sort.test.ts b/src/lib/listings-sort.test.ts
index 38b4553..3de8abf 100644
--- a/src/lib/listings-sort.test.ts
+++ b/src/lib/listings-sort.test.ts
@@ -27,6 +27,7 @@ const NATIVE_SORT_COLUMNS = [
   "condition",
   "quantity",
   "researchStatus",
+  "createdAt",
 ] as const;
 
 const NULLABLE_NATIVE_SORT_COLUMNS = new Set<string>([
diff --git a/src/lib/listings.ts b/src/lib/listings.ts
index c0d826b..5a74bf1 100644
--- a/src/lib/listings.ts
+++ b/src/lib/listings.ts
@@ -50,6 +50,9 @@ export interface ListingRow {
   closingAt: string | null;
   researchStatus: string;
   imageUrl: string | null;
+  locationCity: string | null;
+  locationState: string | null;
+  createdAt: string; // ISO8601 — enables the "Newest" (recency) sort + admin created-date chip
 }
 
 type FullListing = Prisma.ListingGetPayload<{
@@ -113,6 +116,9 @@ export function flattenListing(l: FullListing, profile = "OVERALL_OPPORTUNITY"):
     closingAt: l.closingAt ? l.closingAt.toISOString() : null,
     researchStatus: l.researchStatus,
     imageUrl: l.imageUrls[0] ?? null,
+    locationCity: l.locationCity,
+    locationState: l.locationState,
+    createdAt: l.createdAt.toISOString(),
   };
 }
 
@@ -148,6 +154,7 @@ const NATIVE_SORT_COLUMNS = new Set<keyof ListingRow>([
   "condition",
   "quantity",
   "researchStatus",
+  "createdAt",
 ]);
 
 const NULLABLE_NATIVE_SORT_COLUMNS = new Set<keyof ListingRow>([
diff --git a/tests/e2e/smoke.spec.ts b/tests/e2e/smoke.spec.ts
new file mode 100644
index 0000000..ce56f01
--- /dev/null
+++ b/tests/e2e/smoke.spec.ts
@@ -0,0 +1,38 @@
+import { test, expect } from "@playwright/test";
+
+test("dashboard renders with cards and the listings table", async ({ page }) => {
+  await page.goto("/");
+  await expect(page.getByRole("heading", { name: /GovArbitrage/i })).toBeVisible();
+  await expect(page.getByText("Active Auctions")).toBeVisible();
+  await expect(page.getByText("Expected Profit")).toBeVisible();
+  // The table loads listings from the API.
+  await expect(page.getByText(/listings$/)).toBeVisible();
+});
+
+test("listings API returns computed rows", async ({ request }) => {
+  const res = await request.get("/api/listings?pageSize=5");
+  expect(res.ok()).toBeTruthy();
+  const data = await res.json();
+  expect(data.total).toBeGreaterThan(0);
+  expect(data.rows[0]).toHaveProperty("netProfit");
+  expect(data.rows[0]).toHaveProperty("opportunityScore");
+});
+
+test("scoring profile switch changes ordering", async ({ request }) => {
+  const overall = await (await request.get("/api/listings?profile=OVERALL_OPPORTUNITY&pageSize=1&sort=opportunityScore&dir=desc")).json();
+  const highProfit = await (await request.get("/api/listings?profile=HIGH_PROFIT&pageSize=1&sort=opportunityScore&dir=desc")).json();
+  expect(overall.rows[0]).toBeTruthy();
+  expect(highProfit.rows[0]).toBeTruthy();
+});
+
+test("selling-avenues page lists real sourcing links", async ({ page }) => {
+  await page.goto("/selling-avenues");
+  await expect(page.getByRole("heading", { name: /Sourcing & Selling Avenues/i })).toBeVisible();
+  await expect(page.getByRole("link", { name: /GovDeals/i }).first()).toBeVisible();
+});
+
+test("reports page renders performance tables", async ({ page }) => {
+  await page.goto("/reports");
+  await expect(page.getByRole("heading", { name: "Reports" })).toBeVisible();
+  await expect(page.getByText("Category Performance")).toBeVisible();
+});
diff --git a/vitest.config.ts b/vitest.config.ts
new file mode 100644
index 0000000..3dbf34f
--- /dev/null
+++ b/vitest.config.ts
@@ -0,0 +1,15 @@
+import { defineConfig } from "vitest/config";
+import { fileURLToPath } from "node:url";
+
+export default defineConfig({
+  resolve: {
+    alias: {
+      "@": fileURLToPath(new URL("./src", import.meta.url)),
+    },
+  },
+  test: {
+    environment: "node",
+    include: ["src/**/*.test.ts", "tests/unit/**/*.test.ts"],
+    globals: true,
+  },
+});

← 8c048d3 auto-data-snapshot: 2026-09-16T11:14:29 (1 data files) — tsc  ·  back to Govarbitrage  ·  auto-data-snapshot: 2026-09-16T14:34:47 (1 data files) — tsc bfb96b1 →