← 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
A apps/mobile/app/(tabs)/_layout.tsxA apps/mobile/app/(tabs)/index.tsxA apps/mobile/app/(tabs)/settings.tsxA apps/mobile/app/_layout.tsxA apps/mobile/app/listing/[id].tsxA apps/mobile/assets/android-icon-background.pngA apps/mobile/assets/android-icon-foreground.pngA apps/mobile/assets/android-icon-monochrome.pngA apps/mobile/assets/favicon.pngA apps/mobile/assets/icon.pngA apps/mobile/assets/splash-icon.pngA apps/mobile/components/ErrorBoundary.tsxA apps/mobile/components/ErrorCard.tsxA apps/mobile/components/OpportunityCard.tsxA apps/mobile/components/ScoreBadge.tsxA apps/mobile/constants/theme.tsA apps/mobile/device-proof-evidence/CLEAN-stills-2026-09-03T17-31-41Z/1-cold-launch.pngA apps/mobile/device-proof-evidence/CLEAN-stills-2026-09-03T17-31-41Z/2-detail-maxbid.pngA apps/mobile/device-proof-evidence/CLEAN-stills-2026-09-03T17-31-41Z/3-settings.pngA apps/mobile/device-proof-evidence/CLEAN-stills-2026-09-03T17-31-41Z/4-connection-test.pngA apps/mobile/device-proof-evidence/CLEAN-stills-2026-09-03T17-31-41Z/5-siwa.pngA apps/mobile/device-proof-evidence/GovArbUITest/GovArbUITests/GovArbUITests.swiftA apps/mobile/device-proof-evidence/GovArbUITest/watch-capture.shA apps/mobile/device-proof-evidence/_pii-review/pii-scan-contact-sheet-1-of-2-20260903T164350Z.jpgA apps/mobile/device-proof-evidence/_pii-review/pii-scan-contact-sheet-2-of-2-20260903T164350Z.jpgA apps/mobile/device-proof-evidence/capture-lib.shA apps/mobile/device-proof-evidence/capture-tap-scroll.shA apps/mobile/device-proof-evidence/privacy-contact-sheet-2026-09-03T16-42-59Z.jpgA apps/mobile/device-proof-evidence/privacy-scan.shA apps/mobile/device-proof-evidence/privacy-sweep-CLEAN-2026-09-03T17-31-41Z.jpgA apps/mobile/device-proof-evidence/readiness-probe.shA apps/mobile/device-proof-evidence/run4-clean-2026-09-03T15-23-26Z/1-cold-launch.pngA apps/mobile/device-proof-evidence/run4-clean-2026-09-03T15-23-26Z/2-detail-maxbid.pngA apps/mobile/device-proof-evidence/run4-clean-2026-09-03T15-23-26Z/3-settings.pngA apps/mobile/device-proof-evidence/run4-clean-2026-09-03T15-23-26Z/4-connection-test.pngA apps/mobile/device-proof-evidence/run4-clean-2026-09-03T15-23-26Z/5-siwa.pngA apps/mobile/device-proof-evidence/run5-frames-2026-09-03T16-42-59Z/1-cold-launch.pngA apps/mobile/device-proof-evidence/run5-frames-2026-09-03T16-42-59Z/2-detail-maxbid.pngA apps/mobile/device-proof-evidence/run5-frames-2026-09-03T16-42-59Z/3-settings.pngA apps/mobile/device-proof-evidence/run5-frames-2026-09-03T16-42-59Z/4-connection-test.pngA apps/mobile/device-proof-evidence/run5-frames-2026-09-03T16-42-59Z/5-siwa.pngA apps/mobile/device-proof-evidence/verify-recording.shA apps/mobile/index.tsA apps/mobile/lib/api-error.tsA apps/mobile/lib/api.tsA apps/mobile/lib/auth-headers.tsA apps/mobile/lib/auth.tsA apps/mobile/lib/format.tsA apps/mobile/lib/pnl.tsA apps/mobile/lib/settings.tsA apps/mobile/lib/types.tsA apps/mobile/lib/validate.tsA apps/mobile/scripts/lint-financial-format.mjsA apps/mobile/store-assets/screenshots/01-opportunities-6.9.pngA apps/mobile/store-assets/screenshots/02-settings-6.9.pngA apps/mobile/store-assets/screenshots/03-listing-detail-6.9.pngA apps/mobile/store-assets/screenshots/framed-01-opportunities-6.9.pngA apps/mobile/store-assets/screenshots/framed-02-listing-detail-6.9.pngA apps/mobile/store-assets/screenshots/framed-03-settings-6.9.pngA apps/mobile/store-assets/screenshots/ipad-13/01-opportunities-13.pngA apps/mobile/store-assets/screenshots/ipad-13/02-listing-detail-13.pngA apps/mobile/store-assets/screenshots/ipad-13/03-settings-13.pngA apps/mobile/store-assets/screenshots/iphone-6.5/01-opportunities-6.5.pngA apps/mobile/store-assets/screenshots/iphone-6.5/02-listing-detail-6.5.pngA apps/mobile/store-assets/screenshots/iphone-6.5/03-settings-6.5.pngA apps/mobile/tests/auth-headers.test.mjsA apps/mobile/tests/format.test.mjsA eslint.config.mjsA extension/background.jsA extension/content.jsA extension/popup.htmlA extension/popup.jsA playwright.config.tsA prisma/seed.tsA scripts/deploy-auctions.shA scripts/george-env.pyA scripts/george-env.shA scripts/hot-deal-alert.tsA scripts/import-apify-govdeals.tsA scripts/import-govdeals-free.tsA scripts/import-govplanet-free.tsA scripts/import-grays.tsA scripts/import-gsa.tsA scripts/import-municibid-free.tsA scripts/import-publicsurplus-free.tsA scripts/liveness-sweep.tsA scripts/probe-govdeals.tsA scripts/run-digest.shA scripts/run-govdeals-free.shA scripts/run-govplanet-free.shA scripts/run-gsa-import.shA scripts/run-hot-deals.shA scripts/run-liveness.shA scripts/run-municibid-free.shA scripts/run-newsletter-digest.shA scripts/run-newsletter-server.shA scripts/run-publicsurplus-free.shA scripts/seed-digest-snapshots.tsA scripts/send-digest.tsA scripts/send-newsletter-digest.tsA scripts/set-admin.tsM src/lib/listings-sort.test.tsM src/lib/listings.tsA tests/e2e/smoke.spec.tsA vitest.config.ts
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'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) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[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 & Profit</div>
+ ${l.costBreakdown ? costTable(l.costBreakdown) : "<i>pending</i>"}
+ </td>
+ <td style="width:30%">
+ <div style="font-weight:600;color:#334155;font-size:12px;margin-bottom:2px">Scores</div>
+ ${scoreTable(l.scores)}
+ </td>
+ </tr></table>
+ </div>`;
+}
+
+function buildHtml(items: Detail[], skepticFlags: Map<string, string> = new Map()): string {
+ const when = new Date().toLocaleString("en-US", { dateStyle: "medium", timeStyle: "short" });
+ if (items.length === 0) {
+ return `<p>No open auction opportunities right now (${when}).</p>`;
+ }
+ return `<div style="font-family:-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif;color:#0f172a;max-width:900px">
+ <h2 style="margin:0 0 4px">GovArbitrage — Top ${items.length} Opportunities</h2>
+ <div style="color:#64748b;font-size:13px;margin-bottom:14px">${when} · ranked by Overall Opportunity · still-open auctions · detailed valuation, costs & scores per item</div>
+ ${items.map((l, i) => renderItem(l, i + 1, skepticFlags.get(l.id))).join("")}
+ <p style="margin-top:8px"><a href="${APP_URL}" style="color:#2563eb">Open the dashboard →</a></p>
+ <p style="color:#94a3b8;font-size:11px">Recommended max bids back-solve to a 40% target ROI. Figures are estimates — verify comps before bidding.</p>
+ </div>`;
+}
+
+function escapeHtml(s: string): string {
+ return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]!));
+}
+
+async function sendViaGeorge(subject: string, html: string) {
+ const base = process.env.GEORGE_URL;
+ const auth = process.env.GEORGE_BASIC_AUTH;
+ if (!base || !auth) throw new Error("GEORGE_URL / GEORGE_BASIC_AUTH not set");
+ const res = await fetch(`${base}/api/send`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json", Authorization: `Basic ${auth}` },
+ body: JSON.stringify({ account: FROM_ACCOUNT, to: DIGEST_TO, subject, body: html }),
+ });
+ const text = await res.text();
+ if (!res.ok) throw new Error(`George send failed ${res.status}: ${text.slice(0, 300)}`);
+ return text;
+}
+
+async function main() {
+ const ids = await topTenIds();
+ const items = await fetchDetails(ids);
+ const skepticFlags = await skepticCriticalFlags(ids);
+ const hour = new Date().getHours();
+ const slot = hour < 12 ? "Morning" : "Evening";
+ const subject = `GovArbitrage — Top ${items.length} Opportunities (${slot})`;
+ const html = buildHtml(items, skepticFlags);
+
+ if (process.env.DIGEST_DRY_RUN === "1") {
+ const { writeFileSync, 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 →