diff --git a/.github/scripts/env.ts b/.github/scripts/env.ts
index 9323386095..02b339a629 100644
--- a/.github/scripts/env.ts
+++ b/.github/scripts/env.ts
@@ -52,7 +52,10 @@ const apiFileContent = envFileContent
.split('\n')
.map((line) => {
if (line.startsWith('ENVIRONMENT=')) {
- return 'ENVIRONMENT=dev';
+ // apiEnvSchema enforces z.enum(['development', 'production']) and
+ // throws on parse failure — keep the canonical 'development' value
+ // rather than the short 'dev' the script previously hardcoded.
+ return 'ENVIRONMENT=development';
}
return line;
})
diff --git a/.github/scripts/run-android-maestro-ci.ts b/.github/scripts/run-android-maestro-ci.ts
index 8ed9726799..ad6f935fa4 100644
--- a/.github/scripts/run-android-maestro-ci.ts
+++ b/.github/scripts/run-android-maestro-ci.ts
@@ -52,6 +52,7 @@ await waitForReadyDevice();
await run(['adb', 'install', '-r', '-d', 'apps/expo/build/PackRat.apk']);
await waitForReadyDevice();
await run(['adb', 'shell', 'pm', 'path', appId]);
+await run(['adb', 'shell', 'pm', 'clear', appId]);
await run(['adb', 'shell', 'monkey', '-p', appId, '-c', 'android.intent.category.LAUNCHER', '1']);
await waitForReadyDevice();
await run(['adb', 'shell', 'input', 'keyevent', 'KEYCODE_BACK'], { allowFailure: true });
diff --git a/.github/workflows/web-e2e-tests.yml b/.github/workflows/web-e2e-tests.yml
index b4981dbf09..55c00edf60 100644
--- a/.github/workflows/web-e2e-tests.yml
+++ b/.github/workflows/web-e2e-tests.yml
@@ -88,10 +88,16 @@ jobs:
- name: Cache node_modules
uses: actions/cache@v4
with:
+ # Hash apps/expo/package.json too: bun's --frozen-lockfile happily
+ # installs on top of a partial cache, so when only the workspace
+ # package.json changes (e.g. nativewindui pin), the lockfile hash
+ # alone misses the bust and partial restore-keys hands us a stale
+ # tree (most recent symptom: @babel/helper-compilation-targets'
+ # nested lru-cache@5 disappears, breaking expo export).
path: node_modules
- key: node-modules-${{ runner.os }}-${{ hashFiles('bun.lock') }}
- restore-keys: |
- node-modules-${{ runner.os }}-
+ key: node-modules-${{ runner.os }}-${{ hashFiles('bun.lock', 'apps/expo/package.json', 'package.json') }}
+ # Drop the partial-prefix fallback. A miss should be a clean
+ # install, not a slow drift toward an inconsistent tree.
- name: Install dependencies
env:
@@ -115,14 +121,16 @@ jobs:
# Node-side scripts (migrate/seed) can't use the worker-only
# db.localtest.me neon-proxy routing, so they hit raw Postgres on :5433.
# The worker (.dev.vars below) uses db.localtest.me → proxy :4444.
- - name: Run migrations + seed e2e user (local DB)
+ - name: Run migrations + seed e2e data (local DB)
env:
NEON_DATABASE_URL: postgres://test_user:test_password@localhost:5433/packrat_test
E2E_TEST_EMAIL: ${{ env.TEST_EMAIL }}
E2E_TEST_PASSWORD: ${{ env.TEST_PASSWORD }}
+ OPENAI_API_KEY: sk-e2e-stub-placeholder
run: |
bun run --filter @packrat/api db:migrate
bun run --filter @packrat/api db:seed:e2e-user
+ bun run --filter @packrat/api db:seed:e2e-catalog
# ── Write the worker's .dev.vars: real URLs + dummy-but-valid stand-ins ──
# env-validation.ts requires ~25 keys to boot; sign-in only exercises the
diff --git a/apps/expo/app/(app)/ai-chat.tsx b/apps/expo/app/(app)/ai-chat.tsx
index 0521028793..f5f48de852 100644
--- a/apps/expo/app/(app)/ai-chat.tsx
+++ b/apps/expo/app/(app)/ai-chat.tsx
@@ -221,7 +221,10 @@ export default function AIChat() {
trigger,
messageId,
}) => {
- const authToken = token ?? (await getStoredSessionToken());
+ // Pull the live web token at request time. useChat captures the
+ // transport at mount, so relying only on the hook token can go stale.
+ const { data } = await authClient.getSession();
+ const authToken = data?.session?.token ?? token ?? (await getStoredSessionToken());
return {
api,
credentials: credentials ?? 'include',
@@ -245,7 +248,7 @@ export default function AIChat() {
}),
transportKey: 'remote',
};
- }, [aiMode, isLocalReady, modelStatus, token, tools, userId]);
+ }, [aiMode, isLocalReady, modelStatus, tools, userId]);
// transportKey forces useChat to remount when the transport type switches,
// since useChat captures the transport reference on mount and won't update it.
diff --git a/apps/expo/app/(app)/trail-conditions.tsx b/apps/expo/app/(app)/trail-conditions.tsx
index 984a7cf2f3..ce6f3675e6 100644
--- a/apps/expo/app/(app)/trail-conditions.tsx
+++ b/apps/expo/app/(app)/trail-conditions.tsx
@@ -57,7 +57,7 @@ export default function TrailConditionsScreen() {
const filteredReports = useMemo(() => {
if (!reports) return [];
if (selectedSurface === 'all') return reports;
- return reports.filter((r) => r.surface === selectedSurface);
+ return reports.filter((r: TrailConditionReport) => r.surface === selectedSurface);
}, [reports, selectedSurface]);
if (!featureFlags.enableTrailConditions) return null;
diff --git a/apps/expo/features/ai/hooks/useReportedContent.ts b/apps/expo/features/ai/hooks/useReportedContent.ts
index 45ec9a78a2..7744d7fbac 100644
--- a/apps/expo/features/ai/hooks/useReportedContent.ts
+++ b/apps/expo/features/ai/hooks/useReportedContent.ts
@@ -19,6 +19,8 @@ type ReportedContentResponse = {
}>;
};
+export type ReportedContentItem = ReportedContentResponse['reportedItems'][number];
+
type ReportedContentCount = {
count: number;
total: number;
diff --git a/apps/expo/features/ai/screens/ReportedContentScreen.tsx b/apps/expo/features/ai/screens/ReportedContentScreen.tsx
index 8c4fc83e94..64ff93c459 100644
--- a/apps/expo/features/ai/screens/ReportedContentScreen.tsx
+++ b/apps/expo/features/ai/screens/ReportedContentScreen.tsx
@@ -7,7 +7,7 @@ import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme';
import { useTranslation } from 'expo-app/lib/hooks/useTranslation';
import { useState } from 'react';
import { ActivityIndicator, FlatList, TouchableOpacity, View } from 'react-native';
-import { useReportedContent } from '../hooks/useReportedContent';
+import { type ReportedContentItem, useReportedContent } from '../hooks/useReportedContent';
import { useUpdateReportStatus } from '../hooks/useUpdateReportStatus';
import { reportReasonTranslationKeys } from '../lib/reportReasons';
@@ -21,7 +21,7 @@ export default function ReportedContentScreen() {
const { data, isLoading, error } = useReportedContent();
const updateMutation = useUpdateReportStatus();
- const filteredData = data?.filter((item) => {
+ const filteredData = data?.filter((item: ReportedContentItem) => {
if (selectedFilter === 'all') return true;
return item.status === selectedFilter;
});
diff --git a/apps/expo/features/catalog/screens/CatalogItemDetailScreen.tsx b/apps/expo/features/catalog/screens/CatalogItemDetailScreen.tsx
index 82c09368ff..c80391dc9c 100644
--- a/apps/expo/features/catalog/screens/CatalogItemDetailScreen.tsx
+++ b/apps/expo/features/catalog/screens/CatalogItemDetailScreen.tsx
@@ -100,7 +100,7 @@ export function CatalogItemDetailScreen() {
{t('catalog.categoriesLabel')}
- {item.categories.map((category) => (
+ {item.categories.map((category: string) => (
{decodeHtmlEntities(category)}
@@ -212,7 +212,7 @@ export function CatalogItemDetailScreen() {
{Object.entries(item.techs).map(([key, value]) => (
{key}
- {value}
+ {String(value)}
))}
diff --git a/apps/expo/features/packs/screens/PackListScreen.tsx b/apps/expo/features/packs/screens/PackListScreen.tsx
index 3e22be8b08..956d8407a0 100644
--- a/apps/expo/features/packs/screens/PackListScreen.tsx
+++ b/apps/expo/features/packs/screens/PackListScreen.tsx
@@ -40,6 +40,12 @@ type FilterOption = {
value: PackCategory | 'all';
};
+type PackListRow = {
+ id: string;
+ name: string;
+ category: PackCategory | null;
+};
+
function CreatePackIconButton() {
const { colors } = useColorScheme();
const { t } = useTranslation();
@@ -90,7 +96,7 @@ export function PackListScreen() {
const packs = selectedTypeIndex === USER_PACKS_INDEX ? userPacks : allPacksQuery.data;
- const filteredPacks = packs?.filter((pack) => {
+ const filteredPacks = packs?.filter((pack: PackListRow) => {
const matchesSearch = pack.name.toLowerCase().includes(searchValue.toLowerCase());
const matchesCategory = activeFilter === 'all' || pack.category === activeFilter;
return matchesSearch && matchesCategory;
diff --git a/apps/expo/features/packs/utils/getPackDetailOptions.tsx b/apps/expo/features/packs/utils/getPackDetailOptions.tsx
index ef544a589f..78161333f6 100644
--- a/apps/expo/features/packs/utils/getPackDetailOptions.tsx
+++ b/apps/expo/features/packs/utils/getPackDetailOptions.tsx
@@ -22,14 +22,16 @@ export function getPackDetailOptions(id: string) {
if (!isOwner) return null;
- const confirmDelete = () => {
- const deleteAndNavigate = () => {
- deletePack(id);
- if (router.canGoBack()) {
- router.back();
- }
- };
+ const deleteAndNavigate = () => {
+ deletePack(id);
+ if (router.canGoBack()) {
+ router.back();
+ } else {
+ router.replace('/packs');
+ }
+ };
+ const confirmDelete = () => {
if (Platform.OS === 'web') {
if (globalThis.confirm(t('packs.deletePackConfirm'))) {
deleteAndNavigate();
diff --git a/apps/expo/features/trips/hooks/useDeleteTrip.ts b/apps/expo/features/trips/hooks/useDeleteTrip.ts
index 2bf30e8441..8b3f0d9922 100644
--- a/apps/expo/features/trips/hooks/useDeleteTrip.ts
+++ b/apps/expo/features/trips/hooks/useDeleteTrip.ts
@@ -6,20 +6,33 @@ import { useCallback } from 'react';
export function useDeleteTrip() {
const deleteTrip = useCallback(async (id: string) => {
- // Optimistically mark as deleted in the local store so the UI updates immediately
+ // Optimistic local flip so the UI hides the trip immediately.
const tripObs = obs({ store: tripsStore, id });
- if (tripObs) {
- tripObs.deleted.set(true);
- }
- // Hard-delete on the server so the list GET won't return the trip on any subsequent reload
- const { error } = await apiClient.trips({ tripId: id }).delete();
- if (error) {
- const err = new Error(String(error.value ?? 'Failed to delete trip'));
- Sentry.captureException(err, {
- tags: { feature: 'trips', action: 'deleteTrip' },
- extra: { tripId: id, apiError: error.value, httpStatus: error.status },
+ if (tripObs) tripObs.deleted.set(true);
+
+ Sentry.addBreadcrumb({
+ category: 'trips',
+ message: 'Deleting trip',
+ level: 'info',
+ data: { tripId: id },
+ });
+
+ // Two failure modes: a transport rejection (network drop, fetch abort)
+ // and a non-2xx response (server still has the trip). In both we need
+ // to undo the optimistic flip, capture, and rethrow so the caller can
+ // surface it. 404 is treated as success since the trip is already gone.
+ try {
+ const response = await apiClient.trips({ tripId: id }).delete();
+ if (response.error && response.status !== 404) {
+ throw new Error(`Trip delete failed (${response.status})`);
+ }
+ } catch (error) {
+ if (tripObs) tripObs.deleted.set(false);
+ Sentry.captureException(error, {
+ tags: { feature: 'trips', action: 'delete' },
+ extra: { tripId: id },
});
- throw err;
+ throw error;
}
}, []);
diff --git a/apps/expo/features/trips/screens/TripDetailScreen.tsx b/apps/expo/features/trips/screens/TripDetailScreen.tsx
index 99bbe2ef63..98e1de69d0 100644
--- a/apps/expo/features/trips/screens/TripDetailScreen.tsx
+++ b/apps/expo/features/trips/screens/TripDetailScreen.tsx
@@ -5,6 +5,7 @@ import { featureFlags } from 'expo-app/config';
import { SubmitConditionReportForm } from 'expo-app/features/trail-conditions/components/SubmitConditionReportForm';
import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme';
import { useTranslation } from 'expo-app/lib/hooks/useTranslation';
+import { testIds } from 'expo-app/lib/testIds';
import { useLocalSearchParams, useRouter } from 'expo-router';
import { useMemo, useState } from 'react';
import { Modal, ScrollView, View } from 'react-native';
@@ -74,7 +75,7 @@ export function TripDetailScreen() {
{trip.name}
{/* Dates */}
-
+
{t('trips.dates')}
diff --git a/apps/expo/lib/api/packrat.ts b/apps/expo/lib/api/packrat.ts
index abf1c674cd..40c57e69fc 100644
--- a/apps/expo/lib/api/packrat.ts
+++ b/apps/expo/lib/api/packrat.ts
@@ -2,20 +2,53 @@ import { createApiClient } from '@packrat/api-client';
import { store } from 'expo-app/atoms/store';
import { needsReauthAtom } from 'expo-app/features/auth/atoms/authAtoms';
import { getApiBaseUrl } from 'expo-app/lib/api/getBaseUrl';
-import { authClient, getStoredSessionToken } from 'expo-app/lib/auth-client';
+import { authClient, parseSessionToken } from 'expo-app/lib/auth-client';
+import * as SecureStore from 'expo-app/lib/secureStore';
+import { Platform } from 'react-native';
+
+const COOKIE_STORE_KEY = 'packrat_cookie';
+
+// On web expoClient short-circuits and expo-secure-store is an empty stub, so
+// we fall back to authClient.getSession(). Cache the token for 30s to keep
+// apiClient's per-request token lookup from tripping Better Auth's prod
+// rate limit. Invalidated by onNeedsReauth.
+const WEB_TOKEN_CACHE_MS = 30_000;
+let cachedToken: string | null = null;
+let cachedTokenExpiresAt = 0;
+let pendingTokenRequest: Promise | null = null;
export const apiClient = createApiClient({
baseUrl: getApiBaseUrl(),
auth: {
- // Read the token from SecureStore — no network call on every API request.
- getAccessToken: getStoredSessionToken,
+ getAccessToken: async () => {
+ if (Platform.OS === 'web') {
+ const now = Date.now();
+ if (cachedToken && now < cachedTokenExpiresAt) return cachedToken;
+ pendingTokenRequest ??= authClient
+ .getSession()
+ .then(({ data }) => {
+ cachedToken = data?.session?.token ?? null;
+ cachedTokenExpiresAt = Date.now() + WEB_TOKEN_CACHE_MS;
+ return cachedToken;
+ })
+ .finally(() => {
+ pendingTokenRequest = null;
+ });
+ return pendingTokenRequest;
+ }
+ const cookieStr = await SecureStore.getItemAsync(COOKIE_STORE_KEY);
+ return parseSessionToken(cookieStr);
+ },
// Better Auth has no separate refresh-token endpoint; the 7-day session
// token is the only credential. Returning null here is intentional.
getRefreshToken: () => null,
onAccessTokenRefreshed: () => {},
onNeedsReauth: async () => {
- // A 401 can be transient (e.g. the server briefly returned an error).
- // Verify the session is actually gone before alarming the user.
+ cachedToken = null;
+ cachedTokenExpiresAt = 0;
+ pendingTokenRequest = null;
+ // 401 can be transient; verify the session is really gone before
+ // bouncing the user.
const { data } = await authClient.getSession();
if (data?.session) return;
store.set(needsReauthAtom, true);
diff --git a/apps/expo/lib/i18n/locales/en.json b/apps/expo/lib/i18n/locales/en.json
index 8f960aee78..8b11bbb841 100644
--- a/apps/expo/lib/i18n/locales/en.json
+++ b/apps/expo/lib/i18n/locales/en.json
@@ -462,6 +462,7 @@
"viewDetails": "View Details",
"deleteTrip": "Delete trip?",
"deleteTripConfirmation": "Are you sure you want to delete this trip? This action cannot be undone.",
+ "deleteTripFailed": "Could not delete this trip. Please try again.",
"noTripsYetTitle": "No Trips Yet",
"createTripsToSee": "Create trips to start seeing your upcoming adventures!",
"gotIt": "Got it",
diff --git a/apps/expo/mocks/react-native-community-datetimepicker.tsx b/apps/expo/mocks/react-native-community-datetimepicker.tsx
index a437cced6a..4b541ac0eb 100644
--- a/apps/expo/mocks/react-native-community-datetimepicker.tsx
+++ b/apps/expo/mocks/react-native-community-datetimepicker.tsx
@@ -10,6 +10,10 @@ type Props = {
minimumDate?: Date;
maximumDate?: Date;
style?: unknown;
+ // RN's `testID` doesn't auto-flow into a plain the way it does for
+ // react-native-web's /. Forward it explicitly so Playwright's
+ // getByTestId can target the rendered date input.
+ testID?: string;
};
function toInputValue(date: Date, mode: Props['mode']): string {
@@ -25,6 +29,7 @@ export default function DateTimePicker({
onChange,
minimumDate,
maximumDate,
+ testID,
}: Props) {
const inputType = mode === 'time' ? 'time' : mode === 'datetime' ? 'datetime-local' : 'date';
@@ -39,6 +44,7 @@ export default function DateTimePicker({
return (
{
page.on('dialog', (dialog) => dialog.accept());
- const deleteSyncPromise = page.waitForResponse(
- (r) =>
- r.url().includes(`/api/packs/${packId}`) &&
- (r.request().method() === 'PUT' || r.request().method() === 'PATCH'),
- { timeout: 20_000 },
- );
-
const deleteButton = page.getByTestId(testIds.packs.deleteBtn);
await deleteButton.waitFor({ timeout: 15_000 });
await deleteButton.click();
- const response = await deleteSyncPromise;
- expect(response.ok()).toBeTruthy();
-
- // After deletion has synced, go to list and confirm pack is gone.
+ // Pack deletion is a local soft-delete in the synced store; confirm the UI result.
await page.goto(`${BASE_URL}/packs`);
await expect(page.getByText(packName)).not.toBeVisible({ timeout: 10_000 });
});
@@ -213,8 +203,8 @@ test.describe('Item CRUD within a pack', () => {
if (await moreActionsButton.isVisible()) {
await moreActionsButton.click();
const deleteOption = page
- .getByText(/delete/i)
- .or(page.getByRole('menuitem', { name: /delete/i }))
+ .getByRole('menuitem', { name: /^Delete$/ })
+ .or(page.getByRole('button', { name: /^Delete$/ }))
.first();
await deleteOption.waitFor({ timeout: 5_000 });
await deleteOption.click();
diff --git a/apps/expo/providers/index.web.tsx b/apps/expo/providers/index.web.tsx
index c50e6145c5..406317a70e 100644
--- a/apps/expo/providers/index.web.tsx
+++ b/apps/expo/providers/index.web.tsx
@@ -1,3 +1,4 @@
+import { ActionSheetProvider } from '@expo/react-native-action-sheet';
import { BottomSheetModalProvider } from '@gorhom/bottom-sheet';
import { PortalHost } from '@rn-primitives/portal';
import { ErrorBoundary } from 'expo-app/components/initial/ErrorBoundary';
@@ -9,11 +10,10 @@ import { JotaiProvider } from './JotaiProvider';
import { TanstackProvider } from './TanstackProvider';
/**
- * Web version of Providers.
- * Removes native-only providers:
- * - KeyboardProvider (react-native-keyboard-controller — no web support)
- * - ActionSheetProvider (@expo/react-native-action-sheet uses React.Children.only which breaks on web)
- * Metro automatically picks this file over providers/index.tsx for web builds.
+ * Web Providers. Drops KeyboardProvider (no web support); keeps
+ * BottomSheetModalProvider for inline BottomSheetView and ActionSheetProvider
+ * for useActionSheet(). CustomActionSheet wraps its child in
+ * React.Children.only — keep the direct child a single element.
*/
export function Providers({ children }: { children: ReactNode }) {
return (
@@ -22,10 +22,14 @@ export function Providers({ children }: { children: ReactNode }) {
-
- {children}
-
-
+
+
+ <>
+ {children}
+
+ >
+
+
diff --git a/packages/api/package.json b/packages/api/package.json
index 3cb512b880..4f370879f4 100644
--- a/packages/api/package.json
+++ b/packages/api/package.json
@@ -28,6 +28,7 @@
"db:generate": "drizzle-kit generate --config=drizzle.config.ts",
"db:migrate": "bun run ./migrate.ts",
"db:seed": "bun run ./src/db/seed.ts",
+ "db:seed:e2e-catalog": "bun run ./src/db/seed-e2e-catalog.ts",
"db:seed:e2e-user": "bun run ./src/db/seed-e2e-user.ts",
"deploy": "wrangler deploy --minify",
"deploy:dev": "wrangler deploy --minify -e=dev",
diff --git a/packages/api/src/auth/auth.config.ts b/packages/api/src/auth/auth.config.ts
index dba1f58e03..d5c9793e26 100644
--- a/packages/api/src/auth/auth.config.ts
+++ b/packages/api/src/auth/auth.config.ts
@@ -13,7 +13,7 @@
import { drizzleAdapter } from '@better-auth/drizzle-adapter';
import { neon } from '@neondatabase/serverless';
import * as schema from '@packrat/db';
-import { betterAuth } from 'better-auth';
+import { type BetterAuthPlugin, betterAuth } from 'better-auth';
import { admin, bearer, jwt } from 'better-auth/plugins';
import { drizzle } from 'drizzle-orm/neon-http';
@@ -24,7 +24,9 @@ export const auth = betterAuth({
secret: 'cli-stub-secret',
advanced: {
- generateId: () => crypto.randomUUID(),
+ database: {
+ generateId: () => crypto.randomUUID(),
+ },
ipAddress: {
ipAddressHeaders: ['cf-connecting-ip', 'x-forwarded-for'],
},
@@ -69,7 +71,12 @@ export const auth = betterAuth({
},
},
- plugins: [bearer(), jwt({ jwks: { disablePrivateKeyEncryption: true } }), admin()],
+ plugins: [
+ bearer(),
+ jwt({ jwks: { disablePrivateKeyEncryption: true } }),
+ // safe-cast: Better Auth 1.6.13's admin plugin return type is narrower than BetterAuthPlugin.
+ admin() as unknown as BetterAuthPlugin,
+ ],
trustedOrigins: ['http://localhost:8787', 'packrat://'],
});
diff --git a/packages/api/src/auth/index.ts b/packages/api/src/auth/index.ts
index 4958db7aab..83bcda5e60 100644
--- a/packages/api/src/auth/index.ts
+++ b/packages/api/src/auth/index.ts
@@ -14,7 +14,7 @@ import { createConnection } from '@packrat/api/db';
import type { ValidatedEnv } from '@packrat/api/utils/env-validation';
import * as schema from '@packrat/db';
import { isObject } from '@packrat/guards';
-import { betterAuth } from 'better-auth';
+import { type BetterAuthPlugin, betterAuth } from 'better-auth';
import { admin, bearer, jwt } from 'better-auth/plugins';
// ─── Per-isolate auth instance cache ─────────────────────────────────────────
@@ -61,7 +61,9 @@ async function buildAuth(env: ValidatedEnv): Promise {
advanced: {
// All IDs are UUID-formatted text (matching the DB migration).
- generateId: () => crypto.randomUUID(),
+ database: {
+ generateId: () => crypto.randomUUID(),
+ },
// Trust the X-Forwarded-For header added by Cloudflare.
ipAddress: {
ipAddressHeaders: ['cf-connecting-ip', 'x-forwarded-for'],
@@ -190,7 +192,8 @@ async function buildAuth(env: ValidatedEnv): Promise {
}),
// Admin: role-based user management endpoints.
- admin(),
+ // safe-cast: Better Auth 1.6.13's admin plugin return type is narrower than BetterAuthPlugin.
+ admin() as unknown as BetterAuthPlugin,
// Expo: promotes the expo-origin header → Origin so the CSRF check
// passes for requests from the native app (which can't send a browser
diff --git a/packages/api/src/db/seed-e2e-catalog.ts b/packages/api/src/db/seed-e2e-catalog.ts
new file mode 100644
index 0000000000..f009cde313
--- /dev/null
+++ b/packages/api/src/db/seed-e2e-catalog.ts
@@ -0,0 +1,267 @@
+/**
+ * Seed a handful of catalog items so the catalog-tab and catalog-search
+ * Playwright tests have data to render and filter against. Idempotent on
+ * SKU — re-running won't duplicate rows.
+ *
+ * Usage:
+ * NEON_DATABASE_URL= OPENAI_API_KEY= \
+ * bun run packages/api/src/db/seed-e2e-catalog.ts
+ *
+ * Why this script exists: in production the `catalog_items` table is
+ * populated by the ETL workflow scraping product pages. Local dev DBs
+ * (docker-compose.test.yml) start empty, so anything that scrolls the
+ * catalog tab or runs a similarity search has nothing to find.
+ */
+
+import { neon, neonConfig } from '@neondatabase/serverless';
+import * as schema from '@packrat/db/schema';
+import { nodeEnv } from '@packrat/env/node';
+import { drizzle, type NeonHttpDatabase } from 'drizzle-orm/neon-http';
+import { drizzle as drizzlePg, type NodePgDatabase } from 'drizzle-orm/node-postgres';
+import { Client } from 'pg';
+import WebSocket from 'ws';
+
+neonConfig.webSocketConstructor = WebSocket;
+
+// Mirrors packages/api/src/db/index.ts so the seed script picks the same
+// driver path as the runtime — pg for raw Postgres (postgres:// or
+// postgresql://), neon-http for Neon and for the local db.localtest.me proxy
+// (which speaks Neon's HTTP wire format despite the URL looking standard).
+const isStandardPostgresUrl = (url: string) => {
+ try {
+ const u = new URL(url);
+ const host = u.hostname.toLowerCase();
+ const isNeonTech = host === 'neon.tech' || host.endsWith('.neon.tech');
+ const isNeonCom = host === 'neon.com' || host.endsWith('.neon.com');
+ const isLocalNeonProxy = host === 'db.localtest.me';
+ return (
+ (u.protocol === 'postgres:' || u.protocol === 'postgresql:') &&
+ !isNeonTech &&
+ !isNeonCom &&
+ !isLocalNeonProxy
+ );
+ } catch {
+ return false;
+ }
+};
+
+type SeedItem = {
+ sku: string;
+ name: string;
+ description: string;
+ weight: number;
+ weightUnit: 'g' | 'oz';
+ categories: string[];
+ brand?: string;
+};
+
+// A small, hand-picked set covering distinct gear categories so the catalog
+// search test ("sleeping bag") and the add-from-catalog test (just needs at
+// least one item) both have meaningful hits.
+const ITEMS: SeedItem[] = [
+ {
+ sku: 'e2e-sleeping-bag-20f',
+ name: 'Mountain Loft Down Sleeping Bag 20°F',
+ description: 'Lightweight down sleeping bag rated to 20°F for 3-season backpacking.',
+ weight: 980,
+ weightUnit: 'g',
+ categories: ['Sleep System'],
+ brand: 'TrailLab',
+ },
+ {
+ sku: 'e2e-sleeping-bag-30f',
+ name: 'Sierra Lite Sleeping Bag 30°F',
+ description: 'Compact synthetic sleeping bag for summer hiking.',
+ weight: 720,
+ weightUnit: 'g',
+ categories: ['Sleep System'],
+ brand: 'PackRat Gear',
+ },
+ {
+ sku: 'e2e-tent-2p',
+ name: 'Cascade 2P Backpacking Tent',
+ description: 'Two-person freestanding tent, 1.6 kg packed weight, double-wall.',
+ weight: 1600,
+ weightUnit: 'g',
+ categories: ['Shelter'],
+ brand: 'TrailLab',
+ },
+ {
+ sku: 'e2e-backpack-55l',
+ name: '55L Hiking Backpack',
+ description: 'Internal frame pack with hydration sleeve and rain cover.',
+ weight: 1500,
+ weightUnit: 'g',
+ categories: ['Packs'],
+ brand: 'PackRat Gear',
+ },
+ {
+ sku: 'e2e-stove-canister',
+ name: 'Spark Mini Canister Stove',
+ description: 'Pocket-sized canister stove, boils 500ml in 3:30.',
+ weight: 78,
+ weightUnit: 'g',
+ categories: ['Cooking'],
+ },
+ {
+ sku: 'e2e-headlamp',
+ name: 'Beam 350 Headlamp',
+ description: 'Rechargeable 350-lumen headlamp with red-light mode.',
+ weight: 68,
+ weightUnit: 'g',
+ categories: ['Lighting'],
+ },
+ {
+ sku: 'e2e-water-filter',
+ name: 'Squeeze Water Filter',
+ description: 'Hollow-fiber filter that removes 99.99% of bacteria and protozoa.',
+ weight: 90,
+ weightUnit: 'g',
+ categories: ['Water'],
+ },
+ {
+ sku: 'e2e-rain-jacket',
+ name: 'Stormshield Rain Jacket',
+ description: 'Three-layer waterproof breathable shell with pit zips.',
+ weight: 320,
+ weightUnit: 'g',
+ categories: ['Apparel'],
+ },
+ {
+ sku: 'e2e-puffy',
+ name: 'Featherlite 800-Fill Down Jacket',
+ description: 'Insulated puffy jacket for cold belays and camp wear.',
+ weight: 290,
+ weightUnit: 'g',
+ categories: ['Apparel'],
+ brand: 'PackRat Gear',
+ },
+ {
+ sku: 'e2e-sleeping-pad',
+ name: 'Aircell Insulated Sleeping Pad',
+ description: 'R-value 4.2 inflatable sleeping pad, 460 g packed.',
+ weight: 460,
+ weightUnit: 'g',
+ categories: ['Sleep System'],
+ },
+];
+
+const EMBEDDING_DIMENSIONS = 1536;
+const EMBEDDING_REQUEST_TIMEOUT_MS = 10_000;
+
+async function embedAll(opts: { values: string[]; openAiKey: string }): Promise {
+ const { values, openAiKey } = opts;
+ if (openAiKey.startsWith('sk-e2e-stub-')) {
+ return values.map((value) => {
+ let hash = 2166136261;
+ for (let i = 0; i < value.length; i++) {
+ hash ^= value.charCodeAt(i);
+ hash = Math.imul(hash, 16777619);
+ }
+
+ return Array.from({ length: EMBEDDING_DIMENSIONS }, (_, i) => {
+ hash ^= i;
+ hash = Math.imul(hash, 16777619);
+ return (hash >>> 0) / 0xffffffff;
+ });
+ });
+ }
+
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), EMBEDDING_REQUEST_TIMEOUT_MS);
+ let res: Response;
+ try {
+ res = await fetch('https://api.openai.com/v1/embeddings', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ Authorization: `Bearer ${openAiKey}`,
+ },
+ body: JSON.stringify({ model: 'text-embedding-3-small', input: values }),
+ signal: controller.signal,
+ });
+ } catch (error) {
+ if (error instanceof Error && error.name === 'AbortError') {
+ throw new Error(`OpenAI embeddings timed out after ${EMBEDDING_REQUEST_TIMEOUT_MS}ms`);
+ }
+ throw error;
+ } finally {
+ clearTimeout(timeout);
+ }
+
+ if (!res.ok) {
+ const body = await res.text();
+ throw new Error(`OpenAI embeddings failed ${res.status}: ${body.slice(0, 200)}`);
+ }
+ const json = (await res.json()) as { data: Array<{ embedding: number[] }> };
+ return json.data.map((d) => d.embedding);
+}
+
+async function seedCatalog() {
+ const dbUrl = nodeEnv.NEON_DATABASE_URL;
+ const openAiKey = nodeEnv.OPENAI_API_KEY;
+ if (!dbUrl) throw new Error('NEON_DATABASE_URL is required');
+ if (!openAiKey) throw new Error('OPENAI_API_KEY is required');
+
+ type SeedDatabase = NodePgDatabase | NeonHttpDatabase;
+ let db: SeedDatabase;
+ let pgClient: Client | undefined;
+
+ if (isStandardPostgresUrl(dbUrl)) {
+ pgClient = new Client({ connectionString: dbUrl });
+ await pgClient.connect();
+ db = drizzlePg(pgClient, { schema });
+ } else {
+ db = drizzle(neon(dbUrl), { schema });
+ }
+
+ try {
+ const existing = await db.select({ sku: schema.catalogItems.sku }).from(schema.catalogItems);
+ const knownSkus = new Set(existing.map((r) => r.sku));
+ const newItems = ITEMS.filter((i) => !knownSkus.has(i.sku));
+ if (newItems.length === 0) {
+ console.log(`Catalog already seeded (${existing.length} rows).`);
+ return;
+ }
+
+ console.log(`Generating ${newItems.length} embeddings via OpenAI...`);
+ const embeddings = await embedAll({
+ values: newItems.map((i) => `${i.name}. ${i.description}`),
+ openAiKey,
+ });
+ if (embeddings.length !== newItems.length) {
+ throw new Error(
+ `Embedding count mismatch: expected ${newItems.length}, got ${embeddings.length}`,
+ );
+ }
+
+ for (let i = 0; i < newItems.length; i++) {
+ const item = newItems[i];
+ const embedding = embeddings[i];
+ if (!item || !embedding || embedding.length !== EMBEDDING_DIMENSIONS) {
+ throw new Error(
+ `Invalid embedding at index ${i}: expected ${EMBEDDING_DIMENSIONS} dimensions`,
+ );
+ }
+ await db.insert(schema.catalogItems).values({
+ name: item.name,
+ productUrl: `https://example.com/${item.sku}`,
+ sku: item.sku,
+ weight: item.weight,
+ weightUnit: item.weightUnit,
+ description: item.description,
+ categories: item.categories,
+ brand: item.brand ?? null,
+ embedding,
+ });
+ }
+ console.log(`Seeded ${newItems.length} catalog items.`);
+ } finally {
+ await pgClient?.end();
+ }
+}
+
+seedCatalog().catch((err) => {
+ console.error(err);
+ process.exit(1);
+});
diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts
index 738e616269..3399f715df 100644
--- a/packages/api/src/index.ts
+++ b/packages/api/src/index.ts
@@ -151,12 +151,16 @@ function enrichEnv(env: Env): Env {
// and prod share the exact same driver path — no node-postgres TCP sockets
// (which workerd silently drops between requests).
let neonLocalConfigured = false;
-function maybeConfigureLocalNeon(databaseUrl: string | undefined): void {
+function maybeConfigureLocalNeon(opts: {
+ databaseUrl: string | undefined;
+ proxyPortOverride: string | undefined;
+}): void {
+ const { databaseUrl, proxyPortOverride } = opts;
if (neonLocalConfigured || !databaseUrl) return;
try {
const host = new URL(databaseUrl).hostname.toLowerCase();
if (host !== 'db.localtest.me') return;
- const proxyPort = '4444';
+ const proxyPort = proxyPortOverride ?? '4444';
neonConfig.fetchEndpoint = (h) =>
h === 'db.localtest.me' ? `http://${h}:${proxyPort}/sql` : `https://${h}/sql`;
neonConfig.wsProxy = (h) => (h === 'db.localtest.me' ? `${h}:${proxyPort}/v2` : `${h}/v2`);
@@ -171,7 +175,10 @@ function maybeConfigureLocalNeon(databaseUrl: string | undefined): void {
const handler: ExportedHandler = {
async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise {
const e = enrichEnv(env);
- maybeConfigureLocalNeon(e.NEON_DATABASE_URL);
+ maybeConfigureLocalNeon({
+ databaseUrl: e.NEON_DATABASE_URL,
+ proxyPortOverride: e.NEON_LOCAL_PROXY_PORT,
+ });
setWorkerEnv(e as unknown as Record); // safe-cast: setWorkerEnv accepts Record; ValidatedEnv has no index signature by design
return (app.fetch as unknown as CfFetchFn)(request, e, ctx); // safe-cast: Elysia's fetch has Cloudflare-specific env/ctx params not in the standard type
diff --git a/packages/api/src/services/__tests__/embeddingService.test.ts b/packages/api/src/services/__tests__/embeddingService.test.ts
index 576a83d86a..d412a73bb5 100644
--- a/packages/api/src/services/__tests__/embeddingService.test.ts
+++ b/packages/api/src/services/__tests__/embeddingService.test.ts
@@ -120,6 +120,22 @@ describe('embeddingService', () => {
cloudflareAiBinding: expect.anything(),
});
});
+
+ it('returns deterministic embeddings for e2e stub keys', async () => {
+ const first = await generateEmbedding({
+ ...baseParams,
+ openAiApiKey: 'sk-e2e-stub-placeholder',
+ value: 'test\ntext',
+ });
+ const second = await generateEmbedding({
+ ...baseParams,
+ openAiApiKey: 'sk-e2e-stub-placeholder',
+ value: 'test text',
+ });
+
+ expect(first).toHaveLength(1536);
+ expect(first).toEqual(second);
+ });
});
describe('generateManyEmbeddings', () => {
@@ -220,5 +236,18 @@ describe('embeddingService', () => {
values: ['text with spaces'],
});
});
+
+ it('returns deterministic embeddings for e2e stub keys', async () => {
+ const result = await generateManyEmbeddings({
+ ...baseParams,
+ openAiApiKey: 'sk-e2e-stub-placeholder',
+ values: ['first\nitem', '', 'second item'],
+ });
+
+ expect(result).toHaveLength(2);
+ expect(result[0]).toHaveLength(1536);
+ expect(result[1]).toHaveLength(1536);
+ expect(result[0]).not.toEqual(result[1]);
+ });
});
});
diff --git a/packages/api/src/services/embeddingService.ts b/packages/api/src/services/embeddingService.ts
index 8119178663..d0b11b8ac5 100644
--- a/packages/api/src/services/embeddingService.ts
+++ b/packages/api/src/services/embeddingService.ts
@@ -5,6 +5,7 @@ import { embed, embedMany } from 'ai';
// ── Embedding text normalization ──────────────────────────────────────
const NEWLINE = /\n/g;
+const E2E_EMBEDDING_DIMENSIONS = 1536;
type GenerateEmbeddingBaseParams = {
openAiApiKey?: string;
@@ -19,6 +20,22 @@ type GenerateEmbeddingParams = GenerateEmbeddingBaseParams & {
value: string;
};
+const isE2EStubKey = (key?: string) => key?.startsWith('sk-e2e-stub-') === true;
+
+const deterministicEmbedding = (value: string): number[] => {
+ let hash = 2166136261;
+ for (let i = 0; i < value.length; i++) {
+ hash ^= value.charCodeAt(i);
+ hash = Math.imul(hash, 16777619);
+ }
+
+ return Array.from({ length: E2E_EMBEDDING_DIMENSIONS }, (_, i) => {
+ hash ^= i;
+ hash = Math.imul(hash, 16777619);
+ return (hash >>> 0) / 0xffffffff;
+ });
+};
+
export const generateEmbedding = async (
params: GenerateEmbeddingParams,
): Promise => {
@@ -29,11 +46,15 @@ export const generateEmbedding = async (
return null;
}
- const aiProvider = createAIProvider(providerConfig);
-
// OpenAI recommends replacing newlines with spaces for best results
const input = value.replace(NEWLINE, ' ');
+ if (isE2EStubKey(providerConfig.openAiApiKey)) {
+ return deterministicEmbedding(input);
+ }
+
+ const aiProvider = createAIProvider(providerConfig);
+
const { embedding } = await embed({
model: aiProvider.embedding(DEFAULT_MODELS.OPENAI_EMBEDDING),
value: input,
@@ -57,6 +78,10 @@ export const generateManyEmbeddings = async (
return [];
}
+ if (isE2EStubKey(providerConfig.openAiApiKey)) {
+ return cleanValues.map(deterministicEmbedding);
+ }
+
const aiProvider = createAIProvider(providerConfig);
const { embeddings } = await embedMany({
diff --git a/packages/api/src/utils/env-validation.ts b/packages/api/src/utils/env-validation.ts
index 9ebb664c14..06f8ac9c34 100644
--- a/packages/api/src/utils/env-validation.ts
+++ b/packages/api/src/utils/env-validation.ts
@@ -16,6 +16,10 @@ export const apiEnvObjectSchema = z.object({
// Optional: trail routes return 503 when absent. For Cloudflare Workers,
// set to env.OSM_HYPERDRIVE.connectionString (Hyperdrive binding).
OSM_DATABASE_URL: z.string().url().optional(),
+ // Local-only override for the host port of the local-neon-http-proxy
+ // (docker-compose.test.yml). Worker entry routes the neon driver to this
+ // port when NEON_DATABASE_URL points at db.localtest.me. Defaults to 4444.
+ NEON_LOCAL_PROXY_PORT: z.string().regex(/^\d+$/).optional(),
// Better Auth
BETTER_AUTH_SECRET: z.string().min(32),
diff --git a/packages/env/src/node.ts b/packages/env/src/node.ts
index e7b92e8f40..a95221438d 100644
--- a/packages/env/src/node.ts
+++ b/packages/env/src/node.ts
@@ -79,6 +79,9 @@ export const nodeEnvSchema = z.object({
// ── E2E test credentials ──────────────────────────────────────────
E2E_TEST_EMAIL: z.string().email().optional(),
E2E_TEST_PASSWORD: z.string().min(1).optional(),
+
+ // ── OpenAI (packages/api/src/db/seed-e2e-catalog.ts) ──────────────
+ OPENAI_API_KEY: z.string().min(1).optional(),
E2E_API_URL: z.string().url().optional(),
E2E_DB_URL: z.string().url().optional(),
E2E_DB_PORT: z.string().regex(/^\d+$/, 'E2E_DB_PORT must be a numeric string').optional(),
@@ -137,6 +140,7 @@ export const nodeEnv = nodeEnvSchema.parse({
DEBUG: process.env.DEBUG,
E2E_TEST_EMAIL: process.env.E2E_TEST_EMAIL,
E2E_TEST_PASSWORD: process.env.E2E_TEST_PASSWORD,
+ OPENAI_API_KEY: process.env.OPENAI_API_KEY,
E2E_API_URL: process.env.E2E_API_URL,
E2E_DB_URL: process.env.E2E_DB_URL,
E2E_DB_PORT: process.env.E2E_DB_PORT,
diff --git a/patches/@packrat-ai%2Fnativewindui@2.0.3.patch b/patches/@packrat-ai%2Fnativewindui@2.0.3.patch
deleted file mode 100644
index ffcfa0e9a6..0000000000
--- a/patches/@packrat-ai%2Fnativewindui@2.0.3.patch
+++ /dev/null
@@ -1,106 +0,0 @@
-diff --git a/node_modules/@packrat-ai/nativewindui/.bun-tag-85507b58e8a01901 b/.bun-tag-85507b58e8a01901
-new file mode 100644
-index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391
-diff --git a/package.json b/package.json
-index d8cbd357556aa4e99da8aeb1e80290cb8edad082..a88f91e07aeab9facef841075b4aeb9f49beb879 100644
---- a/package.json
-+++ b/package.json
-@@ -1,6 +1,6 @@
- {
- "name": "@packrat-ai/nativewindui",
-- "version": "2.0.3",
-+ "version": "2.0.3-2",
- "entry": "src/index.ts",
- "main": "src/index.ts",
- "types": "src/index.ts",
-@@ -43,7 +43,7 @@
- "expo-image": "~3.0.11",
- "expo-linear-gradient": "~15.0.8",
- "expo-navigation-bar": "~5.0.10",
-- "expo-router": "~6.0.23",
-+ "expo-router": ">=6.0.23",
- "expo-symbols": "~1.0.8",
- "nativewind": "^4.2.3",
- "react": ">=19.0.0",
-diff --git a/src/components/AdaptiveSearchHeader/AdaptiveSearchHeader.tsx b/src/components/AdaptiveSearchHeader/AdaptiveSearchHeader.tsx
-index 86091777372a04b76c9e866d2951bcfde9f34e05..deac88175ef8d3b14a4d7ff6a476ab6b7d3a3e71 100644
---- a/src/components/AdaptiveSearchHeader/AdaptiveSearchHeader.tsx
-+++ b/src/components/AdaptiveSearchHeader/AdaptiveSearchHeader.tsx
-@@ -186,7 +186,12 @@ export function AdaptiveSearchHeader(props: AdaptiveSearchHeaderProps) {
- onFocus={props.searchBar?.onFocus}
- value={searchValue}
- onChangeText={onChangeText}
-- autoCapitalize={props.searchBar?.autoCapitalize}
-+ autoCapitalize={
-+ props.searchBar?.autoCapitalize === undefined ||
-+ props.searchBar?.autoCapitalize === 'systemDefault'
-+ ? 'none'
-+ : props.searchBar?.autoCapitalize
-+ }
- keyboardType={searchBarInputTypeToKeyboardType(props.searchBar?.inputType)}
- returnKeyType="search"
- blurOnSubmit={props.searchBar?.materialBlurOnSubmit}
-diff --git a/src/components/Icon/types.ts b/src/components/Icon/types.ts
-index 786b62c4a216c360beb193b96092186319a634cb..56b991f34884160c9b5c484415c94c5172b0f25c 100644
---- a/src/components/Icon/types.ts
-+++ b/src/components/Icon/types.ts
-@@ -1,16 +1,18 @@
- import type MaterialCommunityIcons from '@expo/vector-icons/MaterialCommunityIcons';
- import type MaterialIcons from '@expo/vector-icons/MaterialIcons';
--import type { SymbolViewProps } from 'expo-symbols';
-+import type { SymbolViewProps as ExpoSymbolViewProps } from 'expo-symbols';
- import type { IconMapper } from 'rn-icon-mapper';
-
- type MaterialCommunityIconsProps = React.ComponentProps;
- type MaterialIconsProps = React.ComponentProps;
-
--type Style = SymbolViewProps['style'] &
-+type SymbolViewPropsWithStringName = Omit & { name: string };
-+
-+type Style = SymbolViewPropsWithStringName['style'] &
- MaterialIconsProps['style'] &
- MaterialCommunityIconsProps['style'];
-
--type IconProps = IconMapper & {
-+type IconProps = IconMapper & {
- style?: Style;
- className?: string;
- };
-diff --git a/src/components/LargeTitleHeader/LargeTitleHeader.tsx b/src/components/LargeTitleHeader/LargeTitleHeader.tsx
-index 95c66bbdece307542084c2e510c847543134f63e..d6cf24dbaf209ea0086cfb05d9203c81585578a2 100644
---- a/src/components/LargeTitleHeader/LargeTitleHeader.tsx
-+++ b/src/components/LargeTitleHeader/LargeTitleHeader.tsx
-@@ -157,6 +157,7 @@ export function LargeTitleHeader(props: LargeTitleHeaderProps) {
-
- {!!props.searchBar && (
-