diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index cc76a5ebb4..a92b17c951 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -256,7 +256,7 @@ jobs: path: | ~/Library/Caches/CocoaPods ~/.cocoapods - key: cocoapods-${{ runner.os }}-${{ hashFiles('apps/expo/package.json', 'apps/expo/app.config.ts', 'bun.lock') }} + key: cocoapods-${{ runner.os }}-${{ hashFiles('apps/expo/package.json', 'apps/expo/app.config.ts') }} restore-keys: | cocoapods-${{ runner.os }}- diff --git a/.maestro/flows/auth/logout-flow.yaml b/.maestro/flows/auth/logout-flow.yaml index a744399462..0e26a36bc0 100644 --- a/.maestro/flows/auth/logout-flow.yaml +++ b/.maestro/flows/auth/logout-flow.yaml @@ -43,5 +43,7 @@ appId: ${APP_ID} - waitForAnimationToEnd # Assert we are back on the auth screen -- assertVisible: - text: "Sign In" +- extendedWaitUntil: + visible: + text: "Sign In" + timeout: 15000 diff --git a/.maestro/flows/packs/create-pack-flow.yaml b/.maestro/flows/packs/create-pack-flow.yaml index 1ee14b6df8..c01d143489 100644 --- a/.maestro/flows/packs/create-pack-flow.yaml +++ b/.maestro/flows/packs/create-pack-flow.yaml @@ -20,12 +20,16 @@ appId: ${APP_ID} - waitForAnimationToEnd # Fill in Pack Name +# iOS: tap the container Pressable (pack-name-container) which is the +# accessibility leaf and focuses the inner TextInput on press. +# Android: match by placeholder text (no container wrapper needed). - runFlow: when: platform: iOS commands: - tapOn: - text: "move, Pack Name" + id: "pack-name-container" + - waitForAnimationToEnd - inputText: ${PACK_NAME} - runFlow: when: @@ -70,11 +74,12 @@ appId: ${APP_ID} # Also confirm we are on the packs list by checking for the create button - assertVisible: id: "create-pack-button" +- waitForAnimationToEnd - scrollUntilVisible: element: id: "packs:list-item-${PACK_NAME}" direction: DOWN - timeout: 60000 - speed: 99 + timeout: 30000 + speed: 40 - assertVisible: id: "packs:list-item-${PACK_NAME}" diff --git a/.maestro/flows/trips/create-trip-flow.yaml b/.maestro/flows/trips/create-trip-flow.yaml index 6f49a92970..27f6a97c8b 100644 --- a/.maestro/flows/trips/create-trip-flow.yaml +++ b/.maestro/flows/trips/create-trip-flow.yaml @@ -49,7 +49,7 @@ appId: ${APP_ID} # --- Start Date --- - tapOn: - id: "trips:start-date-input" + id: "trips:start-date-btn" - waitForAnimationToEnd - runFlow: when: @@ -80,7 +80,7 @@ appId: ${APP_ID} # --- End Date --- - tapOn: - id: "trips:end-date-input" + id: "trips:end-date-btn" - waitForAnimationToEnd - runFlow: when: diff --git a/CLAUDE.md b/CLAUDE.md index 901dbecaf3..be81d88ee7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -259,3 +259,7 @@ Defined in root `tsconfig.json`: - **Next.js build failures**: `apps/guides` and `apps/landing` may fail without internet (fetches remote data) - **Type errors after NativeWindUI update**: Check for renamed refs — v2.0.0 renamed `AlertRef` → `AlertMethods`, `LargeTitleSearchBarRef` → `LargeTitleSearchBarMethods` - **Bun install hangs**: Normal — takes 120+ seconds. Never cancel mid-install. + +## Documented Solutions + +`docs/solutions/` — documented solutions to past problems (bugs, best practices, workflow patterns), organized by category with YAML frontmatter (`module`, `tags`, `problem_type`). Relevant when implementing or debugging in documented areas. diff --git a/apps/expo/.gitignore b/apps/expo/.gitignore index 742824b0a3..cc034b29d1 100644 --- a/apps/expo/.gitignore +++ b/apps/expo/.gitignore @@ -38,8 +38,10 @@ android .env.* !.env.example -# Playwright E2E — cached auth tokens (written by globalSetup, contain real credentials) +# Playwright E2E — cached auth state (written by globalSetup, contains real credentials) +playwright/.auth/ playwright/.auth-tokens.json +playwright/.auth-state.json playwright/playwright-report/ playwright/test-results/ playwright-report/ diff --git a/apps/expo/app/(app)/_layout.tsx b/apps/expo/app/(app)/_layout.tsx index 4d65336c86..9233da5d8e 100644 --- a/apps/expo/app/(app)/_layout.tsx +++ b/apps/expo/app/(app)/_layout.tsx @@ -17,7 +17,7 @@ import { getTripDetailOptions } from 'expo-app/features/trips/utils/getTripDetai import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import type { TranslationFunction } from 'expo-app/lib/i18n/types'; import { testIds } from 'expo-app/lib/testIds'; -import 'expo-dev-client'; +import 'expo-app/lib/devClient'; import { type Href, router, Stack, useRouter } from 'expo-router'; import { useAtomValue } from 'jotai'; import { useEffect, useRef } from 'react'; diff --git a/apps/expo/app/(app)/recent-packs.tsx b/apps/expo/app/(app)/recent-packs.tsx index 46811d70eb..f58da2a2fa 100644 --- a/apps/expo/app/(app)/recent-packs.tsx +++ b/apps/expo/app/(app)/recent-packs.tsx @@ -5,7 +5,7 @@ import { useRecentPacks } from 'expo-app/features/packs/hooks/useRecentPacks'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import { getRelativeTime } from 'expo-app/lib/utils/getRelativeTime'; -import { Image, ScrollView, View } from 'react-native'; +import { Image, Platform, ScrollView, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; function RecentPackCard({ pack }: { pack: Pack }) { @@ -15,7 +15,12 @@ function RecentPackCard({ pack }: { pack: Pack }) { return ( {pack.image && ( - + )} diff --git a/apps/expo/app/_layout.web.tsx b/apps/expo/app/_layout.web.tsx index eaec58d62c..b49c8863ea 100644 --- a/apps/expo/app/_layout.web.tsx +++ b/apps/expo/app/_layout.web.tsx @@ -9,7 +9,7 @@ import { Alert, type AlertMethods } from '@packrat-ai/nativewindui'; import { useColorScheme, useInitialAndroidBarSync } from 'expo-app/lib/hooks/useColorScheme'; import { Providers } from 'expo-app/providers'; import { NAV_THEME } from 'expo-app/theme'; -import { type RefObject, useRef } from 'react'; +import { type RefObject, useEffect, useRef } from 'react'; /** * Web version of the root layout. @@ -30,6 +30,13 @@ function RootLayout() { const { colorScheme, isDarkColorScheme } = useColorScheme(); + // Sync NativeWind dark mode class to on web (darkMode: 'class' requires it) + useEffect(() => { + if (typeof document !== 'undefined') { + document.documentElement.classList.toggle('dark', isDarkColorScheme); + } + }, [isDarkColorScheme]); + return ( {Platform.select({ @@ -113,7 +114,7 @@ export default function LoginScreen() {
- + {(field) => ( { - if (focusedTextField === 'email') { + if (Platform.OS !== 'web' && focusedTextField === 'email') { KeyboardController.setFocusTo('next'); return; } diff --git a/apps/expo/app/auth/index.tsx b/apps/expo/app/auth/index.tsx index 82dbff5f14..dafa9dca02 100644 --- a/apps/expo/app/auth/index.tsx +++ b/apps/expo/app/auth/index.tsx @@ -2,7 +2,11 @@ import type { AlertMethods } from '@packrat/ui/nativewindui'; import { ActivityIndicator, AlertAnchor, Button, Text } from '@packrat/ui/nativewindui'; import AsyncStorage from '@react-native-async-storage/async-storage'; import { featureFlags } from 'expo-app/config'; -import { needsReauthAtom, redirectToAtom } from 'expo-app/features/auth/atoms/authAtoms'; +import { + isLoadingAtom, + needsReauthAtom, + redirectToAtom, +} from 'expo-app/features/auth/atoms/authAtoms'; import { useAuth } from 'expo-app/features/auth/hooks/useAuth'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import { testIds } from 'expo-app/lib/testIds'; @@ -41,11 +45,20 @@ export default function AuthIndexScreen() { }; const setRedirectTo = useSetAtom(redirectToAtom); + const setIsLoadingGlobal = useSetAtom(isLoadingAtom); React.useEffect(() => { setRedirectTo(redirectTo as string); }, [redirectTo, setRedirectTo]); + // After a sign-out via "Sign-in again", isLoadingAtom is left true by the + // profile screen so AppLayout can detect the unauthenticated state and + // navigate here. Once this screen mounts, loading is done — clear the atom + // so the Sign In button renders instead of the spinner. + React.useEffect(() => { + setIsLoadingGlobal(false); + }, [setIsLoadingGlobal]); + if (isLoading) { return ( @@ -62,6 +75,7 @@ export default function AuthIndexScreen() { diff --git a/apps/expo/app/auth/one-time-password.tsx b/apps/expo/app/auth/one-time-password.tsx index d1b2b4ed65..342d611953 100644 --- a/apps/expo/app/auth/one-time-password.tsx +++ b/apps/expo/app/auth/one-time-password.tsx @@ -158,7 +158,12 @@ export default function OneTimePasswordScreen() { > - + diff --git a/apps/expo/atoms/atomWithSecureStorage.ts b/apps/expo/atoms/atomWithSecureStorage.ts index b76543d317..da3c3ddcfe 100644 --- a/apps/expo/atoms/atomWithSecureStorage.ts +++ b/apps/expo/atoms/atomWithSecureStorage.ts @@ -1,5 +1,5 @@ import { isFunction } from '@packrat/guards'; -import * as SecureStore from 'expo-secure-store'; +import * as SecureStore from 'expo-app/lib/secureStore'; import { atom } from 'jotai'; export const atomWithSecureStorage = ({ diff --git a/apps/expo/components/initial/UserAvatar.tsx b/apps/expo/components/initial/UserAvatar.tsx index 3991d18e16..b05a1629cf 100644 --- a/apps/expo/components/initial/UserAvatar.tsx +++ b/apps/expo/components/initial/UserAvatar.tsx @@ -1,5 +1,5 @@ import type { MockUser } from 'expo-app/data/mockData'; -import { Image, Text, View } from 'react-native'; +import { Image, Platform, Text, View } from 'react-native'; type UserAvatarProps = { user: Pick; @@ -26,7 +26,12 @@ export function UserAvatar({ user, size = 'md', showName = false }: UserAvatarPr {avatarUri ? ( - + ) : ( {user.name.substring(0, 2).toUpperCase()} diff --git a/apps/expo/features/auth/hooks/useAuthActions.ts b/apps/expo/features/auth/hooks/useAuthActions.ts index d51be92553..6c95b74d2f 100644 --- a/apps/expo/features/auth/hooks/useAuthActions.ts +++ b/apps/expo/features/auth/hooks/useAuthActions.ts @@ -9,14 +9,14 @@ import * as Sentry from '@sentry/react-native'; import { AuthClientError, toAuthError } from 'expo-app/features/auth/lib/authErrors'; import { userStore } from 'expo-app/features/auth/store'; import type { User } from 'expo-app/features/profile/types'; +import * as AppleAuthentication from 'expo-app/lib/appleAuthentication'; import { authClient } from 'expo-app/lib/auth-client'; import { t } from 'expo-app/lib/i18n'; +import * as Updates from 'expo-app/lib/updates'; import ImageCacheManager from 'expo-app/lib/utils/ImageCacheManager'; import { queryClient } from 'expo-app/providers/TanstackProvider'; -import * as AppleAuthentication from 'expo-apple-authentication'; import { type Href, router } from 'expo-router'; import Storage from 'expo-sqlite/kv-store'; -import * as Updates from 'expo-updates'; import { useAtomValue, useSetAtom } from 'jotai'; import { isLoadingAtom, diff --git a/apps/expo/features/auth/hooks/useAuthInit.ts b/apps/expo/features/auth/hooks/useAuthInit.ts index 2fb32a5431..92d988f4a2 100644 --- a/apps/expo/features/auth/hooks/useAuthInit.ts +++ b/apps/expo/features/auth/hooks/useAuthInit.ts @@ -67,6 +67,16 @@ export function useAuthInit() { }, []); useEffect(() => { + const navigate = (target: Parameters[0]) => { + // On web, expo-router's navigationRef may not be ready yet during + // Strict Mode double-mount — defer by one event loop tick. + if (Platform.OS === 'web') { + setTimeout(() => router.replace(target), 0); + } else { + router.replace(target); + } + }; + const initializeAuth = async () => { await runVersionGateMigration(); @@ -140,14 +150,14 @@ export function useAuthInit() { return; } - router.replace({ + navigate({ pathname: '/auth', params: { showSkipLoginBtn: 'true', redirectTo: '/' }, }); } catch (error) { Sentry.captureException(error, { tags: { auth_action: 'init' } }); console.error('Failed to initialize auth:', error); - router.replace('/auth'); + navigate('/auth'); } finally { setIsLoading(false); } diff --git a/apps/expo/features/catalog/components/ItemReviews.tsx b/apps/expo/features/catalog/components/ItemReviews.tsx index 382f88ef49..aa8fb4d5a4 100644 --- a/apps/expo/features/catalog/components/ItemReviews.tsx +++ b/apps/expo/features/catalog/components/ItemReviews.tsx @@ -5,7 +5,7 @@ import { Icon } from 'expo-app/components/Icon'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import { useState } from 'react'; -import { Image, TouchableOpacity, View } from 'react-native'; +import { Image, Platform, TouchableOpacity, View } from 'react-native'; import type { CatalogItem } from '../types'; type ItemReviewsProps = { @@ -54,7 +54,11 @@ export function ItemReviews({ reviews }: ItemReviewsProps) { {review.user_avatar ? ( - + ) : ( diff --git a/apps/expo/features/catalog/screens/CatalogItemsScreen.tsx b/apps/expo/features/catalog/screens/CatalogItemsScreen.tsx index f99c76c66c..768253ee6a 100644 --- a/apps/expo/features/catalog/screens/CatalogItemsScreen.tsx +++ b/apps/expo/features/catalog/screens/CatalogItemsScreen.tsx @@ -8,6 +8,7 @@ import { LargeTitleHeaderSearchContentContainer } from 'expo-app/components/Larg import { withAuthWall } from 'expo-app/features/auth/hocs'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; +import { testIds } from 'expo-app/lib/testIds'; import { asNonNullableRef } from 'expo-app/lib/utils/asNonNullableRef'; import { useRouter } from 'expo-router'; import { useAtom } from 'jotai'; @@ -118,7 +119,9 @@ function CatalogItemsScreen() { - {totalItemsText} + + {totalItemsText} + {paginatedItems.length > 0 && ( @@ -143,74 +146,77 @@ function CatalogItemsScreen() { - {isSearching ? ( - isVectorLoading || !isQueryReady ? ( - - - - ) : ( - - - {searchResults.length > 0 && ( - - {searchResults.length} {t('catalog.results')} - - )} + // safe-cast: testID exists in runtime 2.0.5 implementation but absent from published types; fixed in nativewindui PR + searchBar={ + { + iosHideWhenScrolling: false, + onChangeText: setSearchValue, + ref: asNonNullableRef(searchBarRef), + testID: testIds.catalog.searchBtn, + placeholder: t('catalog.searchPlaceholder'), + content: ( + + {isSearching ? ( + isVectorLoading || !isQueryReady ? ( + + - - {searchResults.map((item: CatalogItem) => ( - - handleItemPress(item)} /> - - ))} - - {searchResults.length === 0 && ( - - {vectorError ? ( - <> - - - - - {t('catalog.searchError')} - - - {t('catalog.unableToSearch')} - - - ) : ( - <> - - - - - {t('catalog.noResults')} - - - {t('catalog.tryAdjustingFilters')} - - + ) : ( + + + {searchResults.length > 0 && ( + + {searchResults.length} {t('catalog.results')} + )} - )} - - ) - ) : ( - - {t('catalog.searchCatalog')} - - )} - - ), - }} + + {searchResults.map((item: CatalogItem) => ( + + handleItemPress(item)} /> + + ))} + + {searchResults.length === 0 && ( + + {vectorError ? ( + <> + + + + + {t('catalog.searchError')} + + + {t('catalog.unableToSearch')} + + + ) : ( + <> + + + + + {t('catalog.noResults')} + + + {t('catalog.tryAdjustingFilters')} + + + )} + + )} + + ) + ) : ( + + {t('catalog.searchCatalog')} + + )} + + ), + } as React.ComponentProps['searchBar'] + } /> - + {t('packTemplates.appTemplate')} ); diff --git a/apps/expo/features/pack-templates/components/FeaturedPacksSection.tsx b/apps/expo/features/pack-templates/components/FeaturedPacksSection.tsx index 62186ebd5f..71bce69524 100644 --- a/apps/expo/features/pack-templates/components/FeaturedPacksSection.tsx +++ b/apps/expo/features/pack-templates/components/FeaturedPacksSection.tsx @@ -4,7 +4,7 @@ import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import { useRouter } from 'expo-router'; import { isArray } from 'radash'; import { useMemo } from 'react'; -import { Image, Pressable, ScrollView, View } from 'react-native'; +import { Image, Platform, Pressable, ScrollView, View } from 'react-native'; import { usePackTemplates } from '../hooks'; import { usePackTemplateSummaries } from '../hooks/usePackTemplateSummary'; import type { PackTemplate, PackTemplateInStore } from '../types'; @@ -39,7 +39,12 @@ function FeaturedPackCard({ style={({ pressed }) => ({ opacity: pressed ? 0.85 : 1 })} > {template.image ? ( - + ) : ( 🎒 diff --git a/apps/expo/features/pack-templates/components/PackTemplateCard.tsx b/apps/expo/features/pack-templates/components/PackTemplateCard.tsx index 1c59445b2e..8280966fd1 100644 --- a/apps/expo/features/pack-templates/components/PackTemplateCard.tsx +++ b/apps/expo/features/pack-templates/components/PackTemplateCard.tsx @@ -7,7 +7,7 @@ import { WeightBadge } from 'expo-app/components/initial/WeightBadge'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import { router } from 'expo-router'; -import { Image, Pressable, View } from 'react-native'; +import { Image, Platform, Pressable, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useDeletePackTemplate, usePackTemplateDetails } from '../hooks'; import { useWritePermissionCheck } from '../hooks/useWritePermissionCheck'; @@ -90,7 +90,12 @@ export function PackTemplateCard({ templateId, onPress }: PackTemplateCard) { onPress={() => onPress(template)} > {template.image && ( - + )} diff --git a/apps/expo/features/pack-templates/components/PackTemplateItemCard.tsx b/apps/expo/features/pack-templates/components/PackTemplateItemCard.tsx index e039899494..539f44802a 100644 --- a/apps/expo/features/pack-templates/components/PackTemplateItemCard.tsx +++ b/apps/expo/features/pack-templates/components/PackTemplateItemCard.tsx @@ -7,7 +7,7 @@ import { cn } from 'expo-app/lib/cn'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import { useRouter } from 'expo-router'; -import { Pressable, TouchableWithoutFeedback, View } from 'react-native'; +import { Platform, Pressable, TouchableWithoutFeedback, View } from 'react-native'; import { useSafeAreaInsets } from 'react-native-safe-area-context'; import { useDeletePackTemplateItem } from '../hooks'; import type { PackTemplateItem } from '../types'; @@ -101,7 +101,12 @@ export function PackTemplateItemCard({ onPress(item)}> {/* Image */} - + {/* Content */} diff --git a/apps/expo/features/pack-templates/screens/PackTemplateDetailScreen.tsx b/apps/expo/features/pack-templates/screens/PackTemplateDetailScreen.tsx index 4dc0b0c52f..51ae6febbd 100644 --- a/apps/expo/features/pack-templates/screens/PackTemplateDetailScreen.tsx +++ b/apps/expo/features/pack-templates/screens/PackTemplateDetailScreen.tsx @@ -6,7 +6,7 @@ import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import { NotFoundScreen } from 'expo-app/screens/NotFoundScreen'; import { useLocalSearchParams, useRouter } from 'expo-router'; import { useCallback, useState } from 'react'; -import { Image, ScrollView, TouchableOpacity, View } from 'react-native'; +import { Image, Platform, ScrollView, TouchableOpacity, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import AddPackTemplateItemActions from '../components/AddPackTemplateItemActions'; import { AppTemplateBadge } from '../components/AppTemplateBadge'; @@ -79,7 +79,12 @@ export function PackTemplateDetailScreen() { {packTemplate.image && ( - + )} {/* Header */} diff --git a/apps/expo/features/pack-templates/screens/PackTemplateItemDetailScreen.tsx b/apps/expo/features/pack-templates/screens/PackTemplateItemDetailScreen.tsx index f1dc1baa9f..dd225b460a 100644 --- a/apps/expo/features/pack-templates/screens/PackTemplateItemDetailScreen.tsx +++ b/apps/expo/features/pack-templates/screens/PackTemplateItemDetailScreen.tsx @@ -14,7 +14,7 @@ import { shouldShowQuantity, } from 'expo-app/lib/utils/itemCalculations'; import { router, useLocalSearchParams } from 'expo-router'; -import { ScrollView, View } from 'react-native'; +import { Platform, ScrollView, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { PackTemplateItemImage } from '../components/PackTemplateItemImage'; import { usePackTemplateItem } from '../hooks/usePackTemplateItem'; @@ -52,7 +52,12 @@ export function PackTemplateItemDetailScreen() { return ( - + {item.name} diff --git a/apps/expo/features/packs/components/HorizontalCatalogItemCard.tsx b/apps/expo/features/packs/components/HorizontalCatalogItemCard.tsx index ba6f3f57ed..f09abbd641 100644 --- a/apps/expo/features/packs/components/HorizontalCatalogItemCard.tsx +++ b/apps/expo/features/packs/components/HorizontalCatalogItemCard.tsx @@ -3,6 +3,7 @@ import { Icon } from 'expo-app/components/Icon'; import { CatalogItemImage } from 'expo-app/features/catalog/components/CatalogItemImage'; import type { CatalogItem } from 'expo-app/features/catalog/types'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; +import { testIds } from 'expo-app/lib/testIds'; import { TouchableWithoutFeedback, View } from 'react-native'; type HorizontalCatalogItemCardProps = { @@ -36,7 +37,7 @@ export function HorizontalCatalogItemCard({ item, ...restProps }: HorizontalCata onPress={isSelectable ? () => restProps.onSelect(item) : restProps.onPress} > onPress?.(pack)} > {pack.image && ( - + )} diff --git a/apps/expo/features/packs/components/PackForm.tsx b/apps/expo/features/packs/components/PackForm.tsx index f1f8c34966..e1e2e94976 100644 --- a/apps/expo/features/packs/components/PackForm.tsx +++ b/apps/expo/features/packs/components/PackForm.tsx @@ -150,6 +150,7 @@ export const PackForm = ({ pack }: { pack?: Pack }) => { deleteItem(item.id) }, - ]); + if (Platform.OS === 'web') { + if (window.confirm('Are you sure you want to delete this item?')) { + deleteItem(item.id); + } + } else { + Alert.alert('Delete item?', 'Are you sure you want to delete this item?', [ + { text: 'Cancel', style: 'cancel' }, + { text: 'OK', style: 'destructive', onPress: () => deleteItem(item.id) }, + ]); + } break; } }, @@ -121,7 +127,12 @@ export function PackItemCard({ }`} > {/* Image */} - + {/* Content */} diff --git a/apps/expo/features/packs/components/TemplateItemsSection.tsx b/apps/expo/features/packs/components/TemplateItemsSection.tsx index 115ed62c35..9b021a2284 100644 --- a/apps/expo/features/packs/components/TemplateItemsSection.tsx +++ b/apps/expo/features/packs/components/TemplateItemsSection.tsx @@ -4,7 +4,7 @@ import { cn } from 'expo-app/lib/cn'; import { useColorScheme } from 'expo-app/lib/hooks/useColorScheme'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import { buildPackTemplateItemImageUrl } from 'expo-app/lib/utils/buildPackTemplateItemImageUrl'; -import { Image, ScrollView, Text, View } from 'react-native'; +import { Image, Platform, ScrollView, Text, View } from 'react-native'; export interface PackTemplateItem { id: string; @@ -52,7 +52,12 @@ const TemplateItemCard = ({ item }: { item: PackTemplateItem }) => { {imageUrl ? ( - + ) : ( {t('packs.noImage')} diff --git a/apps/expo/features/packs/hooks/usePacks.ts b/apps/expo/features/packs/hooks/usePacks.ts index f241d5c9f5..d52f9f56c9 100644 --- a/apps/expo/features/packs/hooks/usePacks.ts +++ b/apps/expo/features/packs/hooks/usePacks.ts @@ -1,14 +1,21 @@ import { use$ } from '@legendapp/state/react'; import { packsStore } from 'expo-app/features/packs/store'; +import type { PackInStore } from '../types'; + +const getPackTime = (pack: PackInStore) => { + if (pack.localCreatedAt) return new Date(pack.localCreatedAt).getTime(); + if (pack.createdAt) return new Date(pack.createdAt).getTime(); + return 0; +}; export function usePacks() { const packs = use$(() => { const packsArray = Object.values(packsStore.get()); - const filteredPacks = packsArray.filter((pack) => pack.deleted === false); - - return filteredPacks; + return packsArray + .filter((pack) => pack.deleted === false) + .sort((a, b) => getPackTime(b) - getPackTime(a)); }); return packs; diff --git a/apps/expo/features/packs/screens/PackDetailScreen.tsx b/apps/expo/features/packs/screens/PackDetailScreen.tsx index 1f2f955ce0..d1c48ab177 100644 --- a/apps/expo/features/packs/screens/PackDetailScreen.tsx +++ b/apps/expo/features/packs/screens/PackDetailScreen.tsx @@ -20,7 +20,7 @@ import { obs } from 'expo-app/lib/store'; import { testIds } from 'expo-app/lib/testIds'; import { useLocalSearchParams, useRouter } from 'expo-router'; import { useMemo, useState } from 'react'; -import { Image, ScrollView, Share, TouchableOpacity, View } from 'react-native'; +import { Image, Platform, ScrollView, Share, TouchableOpacity, View } from 'react-native'; import { SafeAreaView, useSafeAreaInsets } from 'react-native-safe-area-context'; import AddPackItemActions from '../components/AddPackItemActions'; import { usePackDetailsFromApi, usePackDetailsFromStore, usePackGapAnalysis } from '../hooks'; @@ -440,7 +440,12 @@ export function PackDetailScreen() { {pack.image && ( - + )} {/* Header */} diff --git a/apps/expo/features/packs/screens/PackItemDetailScreen.tsx b/apps/expo/features/packs/screens/PackItemDetailScreen.tsx index df3914cf3b..103bbfb672 100644 --- a/apps/expo/features/packs/screens/PackItemDetailScreen.tsx +++ b/apps/expo/features/packs/screens/PackItemDetailScreen.tsx @@ -14,7 +14,7 @@ import { shouldShowQuantity, } from 'expo-app/lib/utils/itemCalculations'; import { router, useLocalSearchParams } from 'expo-router'; -import { ScrollView, View } from 'react-native'; +import { Platform, ScrollView, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; import { PackItemImage } from '../components/PackItemImage'; import { SimilarItemsForPackItem } from '../components/SimilarItemsForPackItem'; @@ -119,7 +119,12 @@ export function ItemDetailScreen() { return ( - + {item.name} diff --git a/apps/expo/features/packs/utils/uploadImage.ts b/apps/expo/features/packs/utils/uploadImage.ts index a14792a708..b50a181ac6 100644 --- a/apps/expo/features/packs/utils/uploadImage.ts +++ b/apps/expo/features/packs/utils/uploadImage.ts @@ -15,9 +15,11 @@ export const uploadImage = async ({ } try { + const userId = userStore.id.peek(); + if (!userId) throw new Error('Cannot upload: user not authenticated'); const fileExtension = fileName.split('.').pop()?.toLowerCase() || 'jpg'; const type = `image/${fileExtension === 'jpg' ? 'jpeg' : fileExtension}`; - const remoteFileName = `${userStore.id.peek()}-${fileName}`; + const remoteFileName = `${userId}-${fileName}`; const { url: presignedUrl } = await getPresignedUrl({ fileName: remoteFileName, contentType: type, diff --git a/apps/expo/features/trips/components/TripForm.tsx b/apps/expo/features/trips/components/TripForm.tsx index 33ad5847d7..026a7d5792 100644 --- a/apps/expo/features/trips/components/TripForm.tsx +++ b/apps/expo/features/trips/components/TripForm.tsx @@ -304,7 +304,7 @@ export const TripForm = ({ trip }: { trip?: Trip }) => { return ( setShowStartPicker(true)} className={`flex-row items-center justify-between border rounded-lg p-3 bg-card ${ field.state.meta.errors.length > 0 ? 'border-destructive' : 'border-border' @@ -323,6 +323,7 @@ export const TripForm = ({ trip }: { trip?: Trip }) => { {showStartPicker && ( { return ( setShowEndPicker(true)} - testID={testIds.trips.endDateInput} className={`flex-row items-center justify-between border rounded-lg p-3 bg-card ${ field.state.meta.errors.length > 0 ? 'border-destructive' : 'border-border' }`} @@ -366,6 +367,7 @@ export const TripForm = ({ trip }: { trip?: Trip }) => { {showEndPicker && ( { - // Soft delete by setting deleted flag - const tripObs = obs({ store: tripsStore, id: id }); + const deleteTrip = useCallback(async (id: string) => { + // Optimistically mark as deleted in the local store so the UI updates 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 + await apiClient.trips({ tripId: id }).delete(); }, []); return deleteTrip; diff --git a/apps/expo/features/trips/hooks/useTrips.ts b/apps/expo/features/trips/hooks/useTrips.ts index 20b69c3257..556eebadb0 100644 --- a/apps/expo/features/trips/hooks/useTrips.ts +++ b/apps/expo/features/trips/hooks/useTrips.ts @@ -5,8 +5,13 @@ export function useTrips() { const trips = use$(() => { const tripsArray = Object.values(tripsStore.get()); - // Only include trips that are not deleted - return tripsArray.filter((trip) => trip.deleted === false); + return tripsArray + .filter((trip) => trip.deleted === false) + .sort((a, b) => { + const aTime = a.createdAt ? new Date(a.createdAt).getTime() : 0; + const bTime = b.createdAt ? new Date(b.createdAt).getTime() : 0; + return bTime - aTime; + }); }); return trips; diff --git a/apps/expo/features/trips/store/trips.ts b/apps/expo/features/trips/store/trips.ts index d14d5c0bc6..719db7dc2a 100644 --- a/apps/expo/features/trips/store/trips.ts +++ b/apps/expo/features/trips/store/trips.ts @@ -14,13 +14,10 @@ const listTrips = async () => { }; const createTrip = async (tripData: TripInStore) => { - if (!tripData.location) { - throw new Error('Trip location is required before sync'); - } const { data, error } = await apiClient.trips.post({ id: tripData.id, name: tripData.name, - location: tripData.location, + location: tripData.location ?? null, description: tripData.description ?? null, notes: tripData.notes ?? null, packId: tripData.packId ?? null, @@ -43,6 +40,7 @@ const updateTrip = async ({ id, ...data }: Partial) => { ...(data.startDate !== undefined ? { startDate: data.startDate ?? null } : {}), ...(data.endDate !== undefined ? { endDate: data.endDate ?? null } : {}), ...(data.localUpdatedAt ? { localUpdatedAt: data.localUpdatedAt } : {}), + ...(data.deleted !== undefined ? { deleted: data.deleted } : {}), }); if (error) throw new Error(`Failed to update trip: ${error.value}`); return TripSchema.parse(result); diff --git a/apps/expo/features/weather/components/WeatherAuthWall.tsx b/apps/expo/features/weather/components/WeatherAuthWall.tsx index 5df04b2bd5..5423905d31 100644 --- a/apps/expo/features/weather/components/WeatherAuthWall.tsx +++ b/apps/expo/features/weather/components/WeatherAuthWall.tsx @@ -2,7 +2,7 @@ import { Button, Text } from '@packrat/ui/nativewindui'; import { Icon, type MaterialIconName } from 'expo-app/components/Icon'; import { useTranslation } from 'expo-app/lib/hooks/useTranslation'; import { Stack, usePathname, useRouter } from 'expo-router'; -import { Image, View } from 'react-native'; +import { Image, Platform, View } from 'react-native'; import { SafeAreaView } from 'react-native-safe-area-context'; const LOGO_SOURCE = require('expo-app/assets/packrat-app-icon-gradient.png'); @@ -18,7 +18,12 @@ export function WeatherAuthWall() { - + {t('weather.featuresRequireSignIn')} diff --git a/apps/expo/lib/api/packrat.ts b/apps/expo/lib/api/packrat.ts index cf85382b93..09d318ce2c 100644 --- a/apps/expo/lib/api/packrat.ts +++ b/apps/expo/lib/api/packrat.ts @@ -4,7 +4,7 @@ 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 } from 'expo-app/lib/auth-client'; -import * as SecureStore from 'expo-secure-store'; +import * as SecureStore from 'expo-app/lib/secureStore'; import { z } from 'zod'; // The expoClient plugin serialises all cookies into SecureStore under this key. @@ -29,7 +29,9 @@ function parseSessionToken(cookieJson: string | null): string | null { export const apiClient = createApiClient({ baseUrl: getApiBaseUrl(), auth: { - // Read the token from SecureStore — no network call on every API request. + // Read the token from secure storage — no network call on every API request. + // On web there is no bearer token (the session is an HttpOnly cookie sent via + // credentials:'include'); the secureStore web shim returns null here. getAccessToken: async () => { const cookieStr = await SecureStore.getItemAsync(COOKIE_STORE_KEY); return parseSessionToken(cookieStr); diff --git a/apps/expo/lib/auth-client.ts b/apps/expo/lib/auth-client.ts index f2556aee28..f5d096db12 100644 --- a/apps/expo/lib/auth-client.ts +++ b/apps/expo/lib/auth-client.ts @@ -1,18 +1,22 @@ import { expoClient } from '@better-auth/expo/client'; import { createAuthClient } from 'better-auth/react'; import { getApiBaseUrl } from 'expo-app/lib/api/getBaseUrl'; -import * as SecureStore from 'expo-secure-store'; +import * as SecureStore from 'expo-app/lib/secureStore'; export const authClient = createAuthClient({ baseURL: getApiBaseUrl(), + // Send the Better Auth session cookie on cross-origin requests (the web app is + // served from a different origin than the API in dev/e2e). Without this, + // getSession() omits the cookie, returns null, and the app never becomes + // authenticated on web. Harmless on native (bearer token via expoClient). + fetchOptions: { credentials: 'include' }, plugins: [ expoClient({ scheme: 'packrat', storagePrefix: 'packrat', - storage: { - setItem: (key: string, value: string) => SecureStore.setItem(key, value), - getItem: (key: string) => SecureStore.getItem(key), - }, + // secureStore wraps expo-secure-store; its web variant backs to + // localStorage so the auth client works in the browser. + storage: { setItem: SecureStore.setItem, getItem: SecureStore.getItem }, }), ], }); diff --git a/apps/expo/lib/secureStore.ts b/apps/expo/lib/secureStore.ts new file mode 100644 index 0000000000..5f6b87ade9 --- /dev/null +++ b/apps/expo/lib/secureStore.ts @@ -0,0 +1,11 @@ +// Wrapper around expo-secure-store. Import secure storage from here, never from +// `expo-secure-store` directly — the `.web` variant backs it with localStorage +// so callers don't need platform branches (expo-secure-store ships an empty stub +// on web that throws when called). Enforced by scripts/lint/no-direct-wrapped-imports.ts. +export { + deleteItemAsync, + getItem, + getItemAsync, + setItem, + setItemAsync, +} from 'expo-secure-store'; diff --git a/apps/expo/lib/secureStore.web.ts b/apps/expo/lib/secureStore.web.ts new file mode 100644 index 0000000000..9869b2b4fa --- /dev/null +++ b/apps/expo/lib/secureStore.web.ts @@ -0,0 +1,27 @@ +// Web implementation of the secure-store wrapper. expo-secure-store ships an +// empty stub on web (calling it throws), so back it with localStorage. The +// Better Auth session lives in an HttpOnly cookie rather than here, so reads of +// the cookie key simply return null — callers fall back to cookie auth. +function ls(): Storage | null { + return typeof window !== 'undefined' ? window.localStorage : null; +} + +export function getItem(key: string): string | null { + return ls()?.getItem(key) ?? null; +} + +export function setItem(key: string, value: string): void { + ls()?.setItem(key, value); +} + +export async function getItemAsync(key: string): Promise { + return ls()?.getItem(key) ?? null; +} + +export async function setItemAsync(key: string, value: string): Promise { + ls()?.setItem(key, value); +} + +export async function deleteItemAsync(key: string): Promise { + ls()?.removeItem(key); +} diff --git a/apps/expo/lib/testIds.ts b/apps/expo/lib/testIds.ts index c359db4a50..ff04d3420a 100644 --- a/apps/expo/lib/testIds.ts +++ b/apps/expo/lib/testIds.ts @@ -33,8 +33,8 @@ export const testIds = Object.freeze({ packs: Object.freeze({ createBtn: 'create-pack-button', // keep Maestro value cancelBtn: 'cancel-pack-form-button', // keep Maestro value - nameInput: 'packs:name-input', - descriptionInput: 'packs:description-input', + nameInput: 'pack-name-input', // keep Playwright + Maestro value + descriptionInput: 'pack-description-input', // keep Playwright + Maestro value submitBtn: 'submit-pack-button', // keep Maestro value deleteBtn: 'packs:delete', editBtn: 'packs:edit', @@ -74,15 +74,19 @@ export const testIds = Object.freeze({ submitBtn: 'submit-trip-button', // keep Maestro value deleteBtn: 'trips:delete', editBtn: 'trips:edit', - listItem: (id: string | number) => `trips:list-item-${id}`, + datesSection: 'trips:dates-section', + startDateBtn: 'trips:start-date-btn', + endDateBtn: 'trips:end-date-btn', startDateInput: 'trips:start-date-input', endDateInput: 'trips:end-date-input', + listItem: (id: string | number) => `trips:list-item-${id}`, }), // ── Catalog ─────────────────────────────────────────────────────────────── catalog: Object.freeze({ searchBtn: 'catalog:search-btn', searchInput: 'catalog:search-input', + totalItemsCount: 'catalog:total-items-count', addToPackBtn: 'add-to-pack-button', // keep Maestro value viewRetailerBtn: 'view-retailer-button', // keep Maestro value item: (id: string | number) => `catalog:item-${id}`, diff --git a/apps/expo/lib/utils/getRelativeTime.ts b/apps/expo/lib/utils/getRelativeTime.ts index 6ed2002e1b..b817cb7716 100644 --- a/apps/expo/lib/utils/getRelativeTime.ts +++ b/apps/expo/lib/utils/getRelativeTime.ts @@ -7,7 +7,7 @@ const UNITS: Array<{ key: string; seconds: number }> = [ { key: 'days', seconds: 86400 }, { key: 'hours', seconds: 3600 }, { key: 'minutes', seconds: 60 }, -]; +] as const; function toDate(value: Date | string | null | undefined): Date | null { if (!value) return null; diff --git a/apps/expo/playwright/playwright.config.ts b/apps/expo/playwright/playwright.config.ts index 30049dc98e..cf245a60d9 100644 --- a/apps/expo/playwright/playwright.config.ts +++ b/apps/expo/playwright/playwright.config.ts @@ -23,7 +23,10 @@ export default defineConfig({ projects: [ { name: 'chromium', - use: { ...devices['Desktop Chrome'] }, + // PW_CHANNEL=chrome uses the system browser where no Playwright-bundled + // Chromium is available (e.g. Ubuntu 26.04 dev boxes). Unset in CI, which + // installs Chromium via `playwright install`. + use: { ...devices['Desktop Chrome'], channel: process.env.PW_CHANNEL || undefined }, }, ], }); diff --git a/apps/expo/playwright/tests/core.spec.ts b/apps/expo/playwright/tests/core.spec.ts index a458f48774..92ad42b4c4 100644 --- a/apps/expo/playwright/tests/core.spec.ts +++ b/apps/expo/playwright/tests/core.spec.ts @@ -32,7 +32,7 @@ test('create a pack end-to-end', async ({ authedPage: page }) => { page.waitForResponse((r) => r.url().includes('/api/packs') && r.request().method() === 'POST'), (async () => { await page.goto(`${BASE_URL}/pack/new`); - await page.getByRole('textbox', { name: /Pack Name/i }).fill(packName); + await page.getByTestId('pack-name-input').fill(packName); await page.getByTestId('submit-pack-button').click(); })(), ]); @@ -54,7 +54,7 @@ test('add item manually to a pack', async ({ authedPage: page }) => { page.waitForResponse((r) => r.url().includes('/api/packs') && r.request().method() === 'POST'), (async () => { await page.goto(`${BASE_URL}/pack/new`); - await page.getByTestId('packs:name-input').fill(packName); + await page.getByTestId('pack-name-input').fill(packName); await page.getByTestId('submit-pack-button').click(); })(), ]); @@ -95,7 +95,7 @@ test('add item from catalog to a pack', async ({ authedPage: page }) => { page.waitForResponse((r) => r.url().includes('/api/packs') && r.request().method() === 'POST'), (async () => { await page.goto(`${BASE_URL}/pack/new`); - await page.getByRole('textbox', { name: /Pack Name/i }).fill(packName); + await page.getByTestId('pack-name-input').fill(packName); await page.getByTestId('submit-pack-button').click(); })(), ]); @@ -232,7 +232,7 @@ test('AI chat sends message and gets response', async ({ authedPage: page }) => page.waitForResponse((r) => r.url().includes('/api/packs') && r.request().method() === 'POST'), (async () => { await page.goto(`${BASE_URL}/pack/new`); - await page.getByRole('textbox', { name: /Pack Name/i }).fill(packName); + await page.getByTestId('pack-name-input').fill(packName); await page.getByTestId('submit-pack-button').click(); })(), ]); diff --git a/apps/expo/playwright/tests/fixtures.ts b/apps/expo/playwright/tests/fixtures.ts index edde2a6f35..73dde01168 100644 --- a/apps/expo/playwright/tests/fixtures.ts +++ b/apps/expo/playwright/tests/fixtures.ts @@ -1,52 +1,17 @@ -import * as fs from 'node:fs'; -import * as path from 'node:path'; import { type Browser, type BrowserContext, test as base, type Page } from '@playwright/test'; +import { STORAGE_STATE } from './globalSetup'; -const BASE_URL = process.env.BASE_URL ?? 'http://localhost:8081'; +const BASE_URL = process.env.BASE_URL ?? 'http://localhost:8098'; export const API_URL = process.env.API_URL ?? 'http://localhost:8787'; -const TOKENS_FILE = path.join(__dirname, '../.auth-tokens.json'); - -interface CachedAuth { - accessToken: string; - refreshToken: string; - user: Record | null; -} - -function loadCachedAuth(): CachedAuth { - if (!fs.existsSync(TOKENS_FILE)) { - throw new Error(`Auth tokens file not found at ${TOKENS_FILE}. Did globalSetup run?`); - } - // safe-cast: JSON.parse result is validated implicitly by the known file format written by globalSetup - return JSON.parse(fs.readFileSync(TOKENS_FILE, 'utf-8')) as CachedAuth; -} - /** - * Creates a browser context with auth pre-seeded in localStorage: - * - access_token / refresh_token → read by expo-sqlite kv-store stub + tokenAtom - * - user → read by ObservablePersistLocalStorage to hydrate userStore - * (isAuthed is computed from userStore !== null) - * - * Using storageState guarantees the values are present before ANY page JS runs. + * Creates a browser context pre-authenticated from the storage state saved by + * globalSetup (the Better Auth session cookie + hydrated user store). On web + * the session lives in the cookie, so the api client authenticates via + * credentials: 'include' — there is no bearer token to seed. */ async function createAuthedContext(browser: Browser): Promise { - const { accessToken, refreshToken, user } = loadCachedAuth(); - - const localStorage = [ - { name: 'access_token', value: accessToken }, - { name: 'refresh_token', value: refreshToken }, - ]; - - if (user) { - localStorage.push({ name: 'user', value: JSON.stringify(user) }); - } - - return browser.newContext({ - storageState: { - cookies: [], - origins: [{ origin: BASE_URL, localStorage }], - }, - }); + return browser.newContext({ storageState: STORAGE_STATE }); } export type AuthFixtures = { authedPage: Page }; diff --git a/apps/expo/playwright/tests/globalSetup.ts b/apps/expo/playwright/tests/globalSetup.ts index cebd7341b7..672b0beafc 100644 --- a/apps/expo/playwright/tests/globalSetup.ts +++ b/apps/expo/playwright/tests/globalSetup.ts @@ -1,114 +1,86 @@ /** * Playwright global setup — runs once before all tests. * - * Priority order for obtaining auth tokens: - * 1. TEST_ACCESS_TOKEN + TEST_REFRESH_TOKEN — used directly (no API call) - * 2. TEST_EMAIL + TEST_PASSWORD — logs in against the API (matches the - * iOS/Android Maestro pattern: seed the user, then log in with credentials) - * 3. Fallback — registers a fresh ephemeral user, reads the OTP from the DB, - * and verifies email to obtain tokens (useful for local development) + * Signs the seeded e2e user in through Better Auth and saves the resulting + * browser storage state (the `better-auth.session_token` cookie plus the + * hydrated user store) to `.auth-state.json`. The `authedPage` fixture loads + * that state so every test starts authenticated without logging in again. * - * The resulting tokens are written to .auth-tokens.json so the authedPage - * fixture can seed localStorage without hitting auth on every test. + * The sign-in request is issued from the page context (not Node) so the + * browser persists the Set-Cookie session token exactly as a real login would. + * On web the bearer token lives in an HttpOnly cookie that JS can't read, so + * cookie-based auth (credentials: 'include') is the only path — there is no + * access/refresh token to cache. */ -import * as fs from 'node:fs'; import * as path from 'node:path'; -import { neon } from '@neondatabase/serverless'; +import { chromium } from '@playwright/test'; -const API_URL = process.env.API_URL ?? 'http://localhost:8787'; -const DB_URL = process.env.NEON_DATABASE_URL ?? '***REDACTED_DB_URL***'; +const BASE_URL = process.env.BASE_URL ?? 'http://localhost:8098'; +const API_URL = process.env.API_URL ?? 'http://localhost:8798'; +const EMAIL = process.env.TEST_EMAIL ?? 'e2e@packrattest.local'; +const PASSWORD = process.env.TEST_PASSWORD ?? 'E2eTestPass123!'; -export const TOKENS_FILE = path.join(__dirname, '../.auth-tokens.json'); +// Local Ubuntu has no Playwright-bundled Chromium; set PW_CHANNEL=chrome to use +// the system browser. CI installs Chromium normally and leaves PW_CHANNEL unset. +const CHANNEL = process.env.PW_CHANNEL || undefined; -async function setup() { - // Priority 1: pre-minted tokens provided directly - if (process.env.TEST_ACCESS_TOKEN && process.env.TEST_REFRESH_TOKEN) { - const meRes = await fetch(`${API_URL}/api/auth/me`, { - headers: { Authorization: `Bearer ${process.env.TEST_ACCESS_TOKEN}` }, - }); - const user = meRes.ok ? ((await meRes.json()) as { user: Record }).user : null; - fs.writeFileSync( - TOKENS_FILE, - JSON.stringify({ - accessToken: process.env.TEST_ACCESS_TOKEN, - refreshToken: process.env.TEST_REFRESH_TOKEN, - user, - }), - ); - console.log('[globalSetup] Using tokens from TEST_ACCESS_TOKEN env var'); - return; - } +export const STORAGE_STATE = path.join(__dirname, '../.auth-state.json'); - // Priority 2: log in with the seeded E2E user (CI path, matches iOS/Android pattern) - if (process.env.TEST_EMAIL && process.env.TEST_PASSWORD) { - const loginRes = await fetch(`${API_URL}/api/auth/login`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email: process.env.TEST_EMAIL, password: process.env.TEST_PASSWORD }), - }); - if (!loginRes.ok) { - const body = await loginRes.text(); - throw new Error(`Login failed ${loginRes.status}: ${body}`); - } - const { accessToken, refreshToken, user } = (await loginRes.json()) as { - accessToken: string; - refreshToken: string; - user: Record; - }; - fs.writeFileSync(TOKENS_FILE, JSON.stringify({ accessToken, refreshToken, user })); - console.log(`[globalSetup] Logged in as ${process.env.TEST_EMAIL}`); - return; - } +async function setup() { + const browser = await chromium.launch({ channel: CHANNEL }); + try { + const context = await browser.newContext(); + const page = await context.newPage(); - // Priority 3: register a fresh ephemeral user (local dev fallback) - const email = `e2e-${Date.now()}@packrat.test`; - const password = 'E2eTest1!'; + // Establish the web origin so the session cookie is scoped to it. + await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' }); - // 1. Register - const registerRes = await fetch(`${API_URL}/api/auth/register`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, password, firstName: 'E2E', lastName: 'User' }), - }); - if (!registerRes.ok) { - const body = await registerRes.text(); - throw new Error(`Register failed ${registerRes.status}: ${body}`); - } - console.log(`[globalSetup] Registered ${email}`); - - // 2. Fetch OTP directly from the database - const sql = neon(DB_URL); - const rows = await sql` - SELECT otp.code - FROM one_time_passwords otp - JOIN users u ON u.id = otp.user_id - WHERE u.email = ${email} - ORDER BY otp.expires_at DESC - LIMIT 1 - `; + // Sign in from the page context so the browser stores the session cookie. + // Retry transient 5xx — a local wrangler dev worker talking to a raw + // Postgres (no Hyperdrive) occasionally drops a pooled connection. + let result = { status: 0, body: '' }; + for (let attempt = 1; attempt <= 8; attempt++) { + result = await page.evaluate( + async ({ api, email, password }) => { + try { + const res = await fetch(`${api}/api/auth/sign-in/email`, { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ email, password }), + }); + return { status: res.status, body: await res.text() }; + } catch (err) { + // A 5xx from the worker may arrive without CORS headers, surfacing + // as a thrown "Failed to fetch" — treat as a retryable transient. + return { status: 0, body: String(err) }; + } + }, + { api: API_URL, email: EMAIL, password: PASSWORD }, + ); + if (result.status === 200) break; + // 429 (rate limit) and 5xx/0 (transient DB/proxy blips on a local stack) + // are retryable; other 4xx are real auth failures. + const retryable = result.status === 429 || result.status === 0 || result.status >= 500; + if (!retryable) break; + await page.waitForTimeout(result.status === 429 ? 4000 : 2000); + } + if (result.status !== 200) { + throw new Error(`Better Auth sign-in failed ${result.status}: ${result.body}`); + } - const code = (rows[0] as { code: string } | undefined)?.code; - if (!code) throw new Error(`No OTP found in DB for ${email}`); - console.log(`[globalSetup] Got OTP from DB`); + const cookies = await context.cookies(); + if (!cookies.some((c) => c.name === 'better-auth.session_token')) { + throw new Error('Sign-in succeeded but no better-auth.session_token cookie was set'); + } - // 3. Verify email - const verifyRes = await fetch(`${API_URL}/api/auth/verify-email`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ email, code }), - }); - if (!verifyRes.ok) { - const body = await verifyRes.text(); - throw new Error(`Verify failed ${verifyRes.status}: ${body}`); + // The session cookie is all the app needs — on load it calls get-session + // with the cookie and hydrates its user store / isAuthed itself. Save it. + await context.storageState({ path: STORAGE_STATE }); + console.log(`[globalSetup] Signed in as ${EMAIL}; storage state saved`); + } finally { + await browser.close(); } - const { accessToken, refreshToken, user } = (await verifyRes.json()) as { - accessToken: string; - refreshToken: string; - user: Record; - }; - console.log('[globalSetup] Email verified, tokens obtained'); - - fs.writeFileSync(TOKENS_FILE, JSON.stringify({ accessToken, refreshToken, user })); } export default setup; diff --git a/apps/expo/playwright/tests/packs.spec.ts b/apps/expo/playwright/tests/packs.spec.ts index 8b2421fa7f..e8d24f9e93 100644 --- a/apps/expo/playwright/tests/packs.spec.ts +++ b/apps/expo/playwright/tests/packs.spec.ts @@ -24,7 +24,7 @@ async function createPackViaForm( page.waitForResponse((r) => r.url().includes('/api/packs') && r.request().method() === 'POST'), (async () => { await page.goto(`${BASE_URL}/pack/new`); - await page.getByTestId('packs:name-input').fill(packName); + await page.getByTestId('pack-name-input').fill(packName); await page.getByTestId('submit-pack-button').click(); })(), ]); @@ -85,7 +85,7 @@ test.describe('Pack CRUD', () => { await page.waitForLoadState('networkidle'); await page.getByTestId('packs:edit').click(); - const nameInput = page.getByTestId('packs:name-input'); + const nameInput = page.getByTestId('pack-name-input'); await nameInput.waitFor({ timeout: 10_000 }); await nameInput.clear(); await nameInput.fill(updatedName); diff --git a/apps/expo/polyfills.js b/apps/expo/polyfills.js deleted file mode 100644 index 96a7b042d5..0000000000 --- a/apps/expo/polyfills.js +++ /dev/null @@ -1,22 +0,0 @@ -import 'react-native-get-random-values'; -import structuredClone from '@ungap/structured-clone'; -import { Platform } from 'react-native'; - -if (Platform.OS !== 'web') { - const setupPolyfills = async () => { - const { polyfillGlobal } = await import('react-native/Libraries/Utilities/PolyfillFunctions'); - - const { TextEncoderStream, TextDecoderStream } = await import( - '@stardazed/streams-text-encoding' - ); - - if (!('structuredClone' in global)) { - polyfillGlobal('structuredClone', () => structuredClone); - } - - polyfillGlobal('TextEncoderStream', () => TextEncoderStream); - polyfillGlobal('TextDecoderStream', () => TextDecoderStream); - }; - - setupPolyfills(); -} diff --git a/apps/expo/polyfills.ts b/apps/expo/polyfills.ts new file mode 100644 index 0000000000..0932b107d9 --- /dev/null +++ b/apps/expo/polyfills.ts @@ -0,0 +1,43 @@ +import 'react-native-get-random-values'; +import structuredClone from '@ungap/structured-clone'; +import { BackHandler, Platform } from 'react-native'; + +// RNW's BackHandler stub logs an error on every addEventListener call. +// expo-router calls it internally on every screen mount, flooding the console. +// Patch it to a silent no-op on web so the error never surfaces. +if (Platform.OS === 'web') { + const noop = () => ({ remove: () => {} }); + BackHandler.addEventListener = noop; + // removeEventListener exists on the RNW stub but was removed from RN typings + (BackHandler as unknown as { removeEventListener: () => void }).removeEventListener = () => {}; + BackHandler.exitApp = () => {}; + + // In dev mode RNW's View child validator fires for transient empty-string nodes + // produced by FlashList and nativewindui during reconciliation. No visual impact. + if (__DEV__) { + const _origError = console.error.bind(console); + console.error = (...args: unknown[]) => { + if (typeof args[0] === 'string' && args[0].includes('Unexpected text node')) return; + _origError(...args); + }; + } +} + +if (Platform.OS !== 'web') { + const setupPolyfills = async () => { + const { polyfillGlobal } = await import('react-native/Libraries/Utilities/PolyfillFunctions'); + + const { TextEncoderStream, TextDecoderStream } = await import( + '@stardazed/streams-text-encoding' + ); + + if (!('structuredClone' in global)) { + polyfillGlobal('structuredClone', () => structuredClone); + } + + polyfillGlobal('TextEncoderStream', () => TextEncoderStream); + polyfillGlobal('TextDecoderStream', () => TextDecoderStream); + }; + + setupPolyfills(); +} diff --git a/bun.lock b/bun.lock index bb7acb55b5..8293ed2345 100644 --- a/bun.lock +++ b/bun.lock @@ -711,7 +711,7 @@ "name": "@packrat/ui", "version": "2.0.27", "dependencies": { - "@packrat-ai/nativewindui": "2.0.3-2", + "@packrat-ai/nativewindui": "^2.0.6", }, }, "packages/units": { diff --git a/docs/solutions/developer-experience/local-neon-http-proxy-for-workers-e2e-2026-06-01.md b/docs/solutions/developer-experience/local-neon-http-proxy-for-workers-e2e-2026-06-01.md new file mode 100644 index 0000000000..2758eef439 --- /dev/null +++ b/docs/solutions/developer-experience/local-neon-http-proxy-for-workers-e2e-2026-06-01.md @@ -0,0 +1,104 @@ +--- +title: "Reliable local DB for Cloudflare Workers + Neon: use the local Neon HTTP proxy, not raw node-postgres in workerd" +date: 2026-06-01 +category: docs/solutions/developer-experience/ +module: "packages/api (local dev / e2e database)" +problem_type: developer_experience +component: development_workflow +severity: high +applies_when: + - "Running wrangler dev / Playwright web-e2e locally against a Postgres DB" + - "The API normally talks to Neon via @neondatabase/serverless in production" + - "Local DB queries intermittently time out or drop under any real load" +symptoms: + - "Postgres logs show: unexpected EOF on client connection with an open transaction" + - "~50% of DB-backed requests fail with timeout exceeded when trying to connect (pg-pool)" + - "Reliability looks fine sequentially but collapses under concurrent suite load" +root_cause: incomplete_setup +resolution_type: environment_setup +tags: + - cloudflare-workers + - neon + - wrangler-dev + - node-postgres + - hyperdrive + - local-neon-http-proxy + - e2e + - db-localtest-me +--- + +# Reliable local DB for Cloudflare Workers + Neon: use the local Neon HTTP proxy, not raw node-postgres in workerd + +## Context + +Standing up a local stack to run the web-e2e suite (wrangler dev API + a local Postgres) produced an unreliable database connection: roughly half of all DB-backed requests timed out under any real load. Sequential probes looked fine (e.g. 25/25), but the moment the app's stores synced concurrently or the Playwright suite ran sustained traffic, requests failed with 10s connection-acquire timeouts and Postgres logged `unexpected EOF on client connection`. Two reinvented workarounds — `maxUses: 1` on the pg pool, then a local Hyperdrive binding — never made it reliable. + +The root issue: **the API connects to Postgres with `node-postgres` (`pg.Pool`) over a raw TCP socket, and the Cloudflare Workers local runtime (workerd/miniflare) silently drops pooled TCP sockets between requests.** The next request acquires a dead socket and waits out the full `connectionTimeoutMillis`. In production this never happens because the API talks to Neon via the `@neondatabase/serverless` HTTP/WebSocket driver (no long-lived TCP socket), and OSM/Hyperdrive front their connections. + +The correct local setup already existed on a sibling branch (`feat/web-e2e-fix`) — it just wasn't on `development` yet, so it got reinvented instead of reused. See [[check-existing-infra-before-building]]. + +## Guidance + +Run the **official local Neon HTTP proxy** so local Postgres speaks Neon's HTTP/WS wire format, and point `@neondatabase/serverless` at it. The app then uses the **exact same driver path as production** — no raw `pg.Pool` TCP sockets, so the workerd socket-drop problem cannot occur. + +**1. Compose the proxy stack** (`packages/api/docker-compose.test.yml`): a Postgres container plus `ghcr.io/timowilhelm/local-neon-http-proxy` (HTTP `/sql` + WS `/v2` on port 4444) and `ghcr.io/neondatabase/wsproxy`. + +```bash +NEON_PROXY_HOST_PORT=4444 POSTGRES_TEST_HOST_PORT=5457 \ + docker compose -p packrat-e2e -f packages/api/docker-compose.test.yml up -d +``` + +**2. Point `NEON_DATABASE_URL` at the proxy host** (`db.localtest.me` resolves to localhost via public wildcard DNS): + +``` +NEON_DATABASE_URL=postgres://test_user:test_password@db.localtest.me/packrat_test +``` + +**3. Route the driver to the local proxy** when the host is `db.localtest.me` (`packages/api/src/index.ts`): + +```ts +function maybeConfigureLocalNeon(databaseUrl: string | undefined): void { + if (neonLocalConfigured || !databaseUrl) return; + if (new URL(databaseUrl).hostname.toLowerCase() !== 'db.localtest.me') return; + neonConfig.fetchEndpoint = (h) => + h === 'db.localtest.me' ? `http://${h}:4444/sql` : `https://${h}/sql`; + neonConfig.wsProxy = (h) => (h === 'db.localtest.me' ? `${h}:4444/v2` : `${h}/v2`); + neonConfig.useSecureWebSocket = false; +} +``` + +**4. Exclude `db.localtest.me` from the raw-Postgres path** so it uses the neon driver, not `pg.Pool` (`packages/api/src/db/index.ts`): + +```ts +const isLocalNeonProxy = host === 'db.localtest.me'; +return (u.protocol === 'postgres:' || u.protocol === 'postgresql:') + && !isNeonTech && !isNeonCom && !isLocalNeonProxy; +``` + +## Why This Matters + +- **Reliability:** raw `node-postgres` in workerd was ~50% under load; through the proxy it was rock-solid — 20/20 sequential, 10/10 concurrent, zero timeouts. The web-e2e suite went from effectively unrunnable to 9/14 passing (the remaining 5 need real OpenAI keys / catalog data, validated in CI). +- **Fidelity:** local exercises the same `@neondatabase/serverless` code path as production, so local behavior actually predicts prod behavior. +- **Cost of the wrong path:** chasing pool tuning (`maxUses`, `keepAlive`) and a local Hyperdrive binding (which itself spins up a Docker proxy container and adds a flaky hop) burned significant time for no reliable result. The proxy is the supported answer (https://neon.com/guides/local-development-with-neon). + +## When to Apply + +- Any time you run `wrangler dev` or local e2e against Postgres for an API that uses `@neondatabase/serverless` in prod. +- Before writing custom pool-tuning or a Hyperdrive-local binding to "fix" local DB flakiness — reach for the proxy first. + +## Examples + +Before (raw pg in workerd — flaky): +``` +NEON_DATABASE_URL=postgres://user:pass@localhost:5455/db # pg.Pool → workerd drops sockets → ~50% timeouts +``` + +After (neon driver → local proxy — reliable): +``` +NEON_DATABASE_URL=postgres://test_user:test_password@db.localtest.me/packrat_test # neon HTTP/WS → proxy → Postgres +``` + +## Related + +- `docs/solutions/integration-issues/web-auth-cross-origin-cors-credentials-secure-store-stub-2026-06-01.md` — the web-auth fixes this reliable local DB was built to validate. +- Process lesson: search sibling branches/worktrees for existing infra before reinventing it (this proxy setup already existed on `feat/web-e2e-fix`). diff --git a/docs/solutions/integration-issues/web-auth-cross-origin-cors-credentials-secure-store-stub-2026-06-01.md b/docs/solutions/integration-issues/web-auth-cross-origin-cors-credentials-secure-store-stub-2026-06-01.md new file mode 100644 index 0000000000..e3c628d582 --- /dev/null +++ b/docs/solutions/integration-issues/web-auth-cross-origin-cors-credentials-secure-store-stub-2026-06-01.md @@ -0,0 +1,125 @@ +--- +title: "Cross-origin web auth silently fails: CORS routing order, missing credentials, and a stubbed expo-secure-store" +date: 2026-06-01 +category: docs/solutions/integration-issues/ +module: "packages/api, apps/expo (web authentication)" +problem_type: integration_issue +component: authentication +symptoms: + - "Web users cannot stay logged in; the session never persists across reloads" + - "All authenticated data calls and Legend-State syncs silently no-op with zero network traffic" + - "Cross-origin browser blocks auth requests: no Access-Control-Allow-Origin on /api/auth/** routes" + - "Better Auth getSession() returns null because the HttpOnly session cookie is never sent" + - "Playwright web-e2e fails at globalSetup with a 404 (POST /api/auth/login)" +root_cause: config_error +resolution_type: code_fix +severity: high +related_components: + - testing_framework + - tooling +tags: + - cross-origin + - cors + - better-auth + - expo-secure-store + - credentials-include + - rn-web + - cloudflare-workers + - elysia +--- + +# Cross-origin web auth silently fails: CORS routing order, missing credentials, and a stubbed expo-secure-store + +## Problem + +The PackRat web app (React Native Web / Expo Router) could not authenticate against the Cloudflare Workers + Elysia + Better Auth API. It rendered but stayed permanently signed-out: every authenticated request and every Legend-State data sync (gated on `isAuthed`) silently failed, and on cross-origin dev/e2e the browser blocked the auth calls outright with a CORS error. + +## Symptoms + +- web-e2e `globalSetup`: `Better Auth sign-in failed 404` / `Login failed 404` on `POST /api/auth/login`. +- Browser console: `Access to fetch at '.../api/auth/sign-in/email' ... blocked by CORS policy: Response to preflight ... No 'Access-Control-Allow-Origin' header`, followed by `TypeError: Failed to fetch`. +- App renders but never authenticates: `authClient.getSession()` returns `null`, `isAuthed` is never true. +- **Zero** `/api/packs` or `/api/trips` requests fire at all — not even failed ones (mutations are gated on `isAuthed` via `syncedCrud` `waitForSet`). +- Underlying runtime error on web: `ExpoSecureStore.getValueWithKeyAsync is not a function`. + +## What Didn't Work + +- **Dismissing the failing web-e2e as "e2e noise."** The 404 was real signal — `globalSetup` was POSTing to a `/api/auth/login` route that no longer existed after the Better Auth migration. The harness was surfacing genuine web-auth bugs. +- **Hand-rolling CORS headers (`withAuthCors`) on the pre-Elysia auth dispatch.** Functional but a smell: it duplicated the policy the Elysia `cors` plugin already owned. Replaced by routing auth *through* Elysia so one plugin owns CORS for every route. +- **Seeding `localStorage.user` to force `isAuthed` true.** Racy, and it didn't make data calls fire — because the calls weren't failing at the `isAuthed` gate, they were throwing earlier inside `getAccessToken`. Wrong layer. +- **Inline `Platform.OS === 'web'` guards in `getAccessToken`.** Works, but scatters platform branches. The team convention is a single `lib/` wrapper with a `.web` variant, enforced by lint. + +## Solution + +Four compounding fixes, all on `feat/web-support-mvp`. + +**1. Route `/api/auth/**` through Elysia** (`packages/api/src/index.ts`) so the credentialed `cors` plugin and OPTIONS preflight apply. `auth` is built per-request from Cloudflare env bindings, so a per-request `.all` is used instead of `.mount(auth.handler)`; `parse: 'none'` stops Elysia from consuming the body Better Auth needs. + +```ts +.all( + '/api/auth/*', + async ({ request }) => { + const auth = await getAuth(getEnv()); + return auth.handler(request); + }, + { parse: 'none', detail: { hide: true } }, +) +``` + +**2. Trust localhost origins for CSRF in dev** (`packages/api/src/auth/index.ts`) so the web app on a different localhost port passes Better Auth's CSRF check: + +```ts +trustedOrigins: [ + env.BETTER_AUTH_URL, + 'packrat://', + ...(env.ENVIRONMENT === 'development' ? ['http://localhost:*'] : []), +], +``` + +**3. Send the session cookie cross-origin on both clients.** Auth client (`apps/expo/lib/auth-client.ts`): + +```ts +createAuthClient({ baseURL: getApiBaseUrl(), fetchOptions: { credentials: 'include' }, /* … */ }); +``` + +API client (`packages/api-client/src/index.ts`) — on the no-token (web) path, send the cookie instead of an `Authorization` header: + +```ts +// no bearer token on web — the session is an HttpOnly cookie +if (!token) return [base, { ...init, credentials: 'include' }]; +``` + +**4. A non-throwing `secureStore` wrapper with a web variant.** `apps/expo/lib/secureStore.web.ts` backs to `localStorage` and returns `null` for the cookie key (the real session is the HttpOnly cookie): + +```ts +function ls(): Storage | null { + return typeof window !== 'undefined' ? window.localStorage : null; +} +export async function getItemAsync(key: string): Promise { + return ls()?.getItem(key) ?? null; +} +// setItemAsync / deleteItemAsync / getItem / setItem follow the same pattern +``` + +`packrat.ts`, `auth-client.ts`, and `atomWithSecureStorage.ts` all import from `expo-app/lib/secureStore` instead of `expo-secure-store` directly. + +**5. e2e harness rewrite** (`apps/expo/playwright/tests/globalSetup.ts` + `fixtures.ts`): sign in via Better Auth `/api/auth/sign-in/email` in a browser context, save the session-cookie storage state, and have fixtures load it (replacing the dead `/api/auth/login` POST). + +## Why This Works + +- **expo-secure-store is an empty stub on web** (`ExpoSecureStore.web.js` = `export default {}`). `getItemAsync` ends up calling `undefined(...)` → a `TypeError` thrown *before any fetch*. Since the api client computes `Authorization: Bearer ` on every request, that throw killed every `/api/*` call and every `syncedCrud` create/list — which is why there was *zero* API traffic, not failed traffic. The web shim never throws, so the request proceeds. +- **On web there is no JS-readable bearer token.** The Better Auth session lives in an HttpOnly cookie that JavaScript cannot read, so `getAccessToken` legitimately returns `null` on web. Authentication has to ride on the cookie, which only travels cross-origin when the request sets `credentials: 'include'` — hence both clients needed it. +- **The cors plugin was bypassed.** Dispatching `/api/auth/**` to Better Auth's handler *before* Elysia ran meant the `cors` plugin (and its OPTIONS preflight) never touched auth routes, so the cross-origin browser got no `Access-Control-Allow-Origin` and blocked the request. Routing through Elysia puts auth under the same credentialed-CORS policy as everything else. +- **CSRF needs the web origin trusted.** Better Auth rejects cross-origin requests whose `Origin` isn't in `trustedOrigins`. The web app runs on a different localhost port than the API in dev/e2e, so `http://localhost:*` must be trusted (dev only — never production). + +## Prevention + +- **One wrapper, enforced.** Wrap any module with divergent web behavior in `apps/expo/lib/.ts` + `.web.ts`, and import only from the wrapper. `scripts/lint/no-direct-wrapped-imports.ts` (wired into `lint:custom`) fails the build if a wrapped module (`expo-secure-store`, `expo-apple-authentication`, `expo-updates`) is imported directly outside its wrapper. Add new wrapped modules to its `WRAPPED` map. +- **Route auth through Elysia, never before it.** Any handler mounted ahead of the Elysia app silently loses the shared `cors`, error, and OpenAPI plugins. Keep per-request auth as an `.all('/api/auth/*', …, { parse: 'none' })` route so one plugin owns CORS. +- **Remember: web auth = cookie, not token.** When wiring a new client or fetch path, default to `credentials: 'include'` on the no-token path and expect `getAccessToken` to return `null` on web — don't treat a null token as "unauthenticated." +- **Treat failing e2e as signal, not noise.** The 404 `globalSetup` failure was the first symptom of the whole chain; chasing it (rather than muting it) surfaced the real bugs. + +## Related Issues + +- `docs/solutions/developer-experience/better-auth-cli-cloudflare-worker-factory-2026-05-02.md` — same Better Auth + Cloudflare Workers subsystem (per-request factory auth instance), but a build-tooling concern (CLI schema generation) rather than runtime cross-origin auth. Complementary, not overlapping. +- Related learning from the same work (the local e2e DB layer that let these web-auth fixes be validated): the local Neon HTTP proxy (`db.localtest.me` via `packages/api/docker-compose.test.yml` + `maybeConfigureLocalNeon`) replaced raw node-postgres in workerd, which silently drops sockets. Worth a separate solution doc. diff --git a/package.json b/package.json index 08329ae600..132fc184ed 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,7 @@ "ios": "cd apps/expo && bun ios", "lefthook": "lefthook install", "lint": "biome check --write", - "lint:custom": "bun run scripts/lint/no-raw-typeof.ts && bun run scripts/lint/no-raw-regex.ts && bun run scripts/lint/no-owned-max-params.ts && bun run packages/env/scripts/no-raw-process-env.ts && bun run scripts/lint/no-duplicate-guards.ts && bun run scripts/lint/no-unauth-routes.ts && bun run scripts/lint/check-drizzle-migrations.ts", + "lint:custom": "bun run scripts/lint/no-raw-typeof.ts && bun run scripts/lint/no-raw-regex.ts && bun run scripts/lint/no-owned-max-params.ts && bun run packages/env/scripts/no-raw-process-env.ts && bun run scripts/lint/no-duplicate-guards.ts && bun run scripts/lint/no-unauth-routes.ts && bun run scripts/lint/check-drizzle-migrations.ts && bun run scripts/lint/no-direct-wrapped-imports.ts", "lint:strict": "biome check && bun run lint:custom", "lint-unsafe": "biome check --write --unsafe", "mcp": "bun run --cwd packages/mcp dev", diff --git a/packages/api-client/src/index.ts b/packages/api-client/src/index.ts index 516ef5d64c..f60304f530 100644 --- a/packages/api-client/src/index.ts +++ b/packages/api-client/src/index.ts @@ -105,7 +105,10 @@ export function createApiClient(config: ApiClientConfig) { token: string | null; base: RequestInfo | URL; }): [RequestInfo | URL, RequestInit | undefined] => { - if (!token) return [base, init]; + // credentials:'include' lets the browser send the Better Auth session + // cookie on the web build, where the bearer token lives in an HttpOnly + // cookie that JS can't read. Harmless on native (bearer header is used). + if (!token) return [base, { ...init, credentials: 'include' }]; const headers = new Headers(); const existing = init?.headers; if (existing instanceof Headers) { diff --git a/packages/api/.dev.vars.e2e.example b/packages/api/.dev.vars.e2e.example index c5d4d3f347..72f0971454 100644 --- a/packages/api/.dev.vars.e2e.example +++ b/packages/api/.dev.vars.e2e.example @@ -3,8 +3,8 @@ # All other keys should match your main packages/api/.dev.vars. # ── Database (local Docker, port 5435) ───────────────────────────────────── -NEON_DATABASE_URL=postgres://e2e_user:e2e_pass@localhost:5435/packrat_e2e -NEON_DATABASE_URL_READONLY=postgres://e2e_user:e2e_pass@localhost:5435/packrat_e2e +NEON_DATABASE_URL=postgres://test_user:test_password@db.localtest.me/packrat_test +NEON_DATABASE_URL_READONLY=postgres://test_user:test_password@db.localtest.me/packrat_test # ── API & Auth URLs (wrangler dev on localhost) ───────────────────────────── EXPO_PUBLIC_API_URL=http://localhost:8787 diff --git a/packages/api/docker-compose.test.yml b/packages/api/docker-compose.test.yml index dad926c705..3e7fc32e07 100644 --- a/packages/api/docker-compose.test.yml +++ b/packages/api/docker-compose.test.yml @@ -9,7 +9,7 @@ services: POSTGRES_PASSWORD: test_password POSTGRES_HOST_AUTH_METHOD: trust ports: - - "5433:5432" + - "${POSTGRES_TEST_HOST_PORT:-5433}:5432" volumes: - postgres_test_data:/var/lib/postgresql/data healthcheck: @@ -23,8 +23,8 @@ services: -c log_duration=on -c max_connections=100 - # Neon-compatible WebSocket proxy so tests use the same @neondatabase/serverless - # driver as production — eliminates pg-cloudflare / cloudflare:sockets workarounds. + # Neon-compatible WebSocket proxy used by the vitest integration suite + # (`packages/api/test/setup.ts`) — speaks WebSocket only. wsproxy: image: ghcr.io/neondatabase/wsproxy:latest environment: @@ -32,7 +32,23 @@ services: ALLOW_ADDR_REGEX: ".*" LOG_CONN_INFO: "true" ports: - - "5434:80" + - "${WSPROXY_HOST_PORT:-5434}:80" + depends_on: + postgres-test: + condition: service_healthy + + # Official Neon HTTP+WS local proxy + # (https://neon.com/guides/local-development-with-neon). Lets the + # `@neondatabase/serverless` HTTP driver (neon(url)) and WebSocket Pool + # talk to local Postgres on a single port (4444), so wrangler dev hits the + # exact same code paths as prod — no per-request WebSocket lifetime issues. + # Used by the Playwright E2E stack. + neon-proxy: + image: ghcr.io/timowilhelm/local-neon-http-proxy:main + environment: + PG_CONNECTION_STRING: postgres://test_user:test_password@postgres-test:5432/packrat_test + ports: + - "${NEON_PROXY_HOST_PORT:-4444}:4444" depends_on: postgres-test: condition: service_healthy diff --git a/packages/api/src/auth/index.ts b/packages/api/src/auth/index.ts index 90ff6c7869..95751c1c18 100644 --- a/packages/api/src/auth/index.ts +++ b/packages/api/src/auth/index.ts @@ -197,7 +197,15 @@ async function buildAuth(env: ValidatedEnv): Promise { storage: 'secondary-storage', }, - trustedOrigins: [env.BETTER_AUTH_URL, 'packrat://'], + // The web app is served from a different origin than the API (e.g. the + // Playwright e2e harness serves the static export on a separate port), so + // its origin must be trusted for the cross-origin CSRF/CORS check. Only + // trust localhost in development — never in production. + trustedOrigins: [ + env.BETTER_AUTH_URL, + 'packrat://', + ...(env.ENVIRONMENT === 'development' ? ['http://localhost:*'] : []), + ], }); return auth; diff --git a/packages/api/src/db/index.ts b/packages/api/src/db/index.ts index 377c40a64b..4c63bbea58 100644 --- a/packages/api/src/db/index.ts +++ b/packages/api/src/db/index.ts @@ -13,8 +13,17 @@ const isStandardPostgresUrl = (url: string) => { const host = u.hostname.toLowerCase(); const isNeonTech = host === 'neon.tech' || host.endsWith('.neon.tech'); const isNeonCom = host === 'neon.com' || host.endsWith('.neon.com'); + // `db.localtest.me` is the host the local Neon HTTP proxy uses (see + // packages/api/docker-compose.test.yml). The URL looks like raw Postgres but + // the proxy speaks Neon's HTTP/WS wire format, so route it through the neon + // driver — the same code path as prod, with no node-postgres TCP sockets + // (which workerd silently drops between requests). + const isLocalNeonProxy = host === 'db.localtest.me'; return ( - (u.protocol === 'postgres:' || u.protocol === 'postgresql:') && !isNeonTech && !isNeonCom + (u.protocol === 'postgres:' || u.protocol === 'postgresql:') && + !isNeonTech && + !isNeonCom && + !isLocalNeonProxy ); } catch { return false; diff --git a/packages/api/src/index.ts b/packages/api/src/index.ts index 7a8a951aba..bd3776a81d 100644 --- a/packages/api/src/index.ts +++ b/packages/api/src/index.ts @@ -8,6 +8,7 @@ import type { MessageBatch } from '@cloudflare/workers-types'; import { cors } from '@elysiajs/cors'; +import { neonConfig } from '@neondatabase/serverless'; import { getAuth } from '@packrat/api/auth'; import { AppContainer } from '@packrat/api/containers'; import { routes } from '@packrat/api/routes'; @@ -22,26 +23,27 @@ import { Elysia } from 'elysia'; import { CloudflareAdapter } from 'elysia/adapter/cloudflare-worker'; import type { CatalogETLMessage } from './services/etl/types'; +// Origins allowed to make cross-origin (credentialed) requests to the API. +const ALLOWED_ORIGIN_PATTERNS = [ + /^https:\/\/(www\.)?packrat\.world$/, + /^https:\/\/[\w-]+\.packrat\.world$/, + /^https:\/\/[\w-]+\.packratai\.com$/, + /^https?:\/\/[\w-]+\.workers\.dev$/, + /^http:\/\/localhost:\d+$/, + /^exp:\/\//, +]; + +function isAllowedOrigin(origin: string | null): origin is string { + return !!origin && ALLOWED_ORIGIN_PATTERNS.some((re) => re.test(origin)); +} + export const app = new Elysia({ adapter: CloudflareAdapter }) .use( cors({ // Better Auth uses cookies — credentials must be true and origins must // be explicit (not wildcard) so the browser sends cookies cross-origin. credentials: true, - origin: (request) => { - const origin = request.headers.get('Origin'); - if (!origin) return false; - // Allow the API base URL and any subdomain of packrat.world - const allowed = [ - /^https:\/\/(www\.)?packrat\.world$/, - /^https:\/\/[\w-]+\.packrat\.world$/, - /^https:\/\/[\w-]+\.packratai\.com$/, - /^https?:\/\/[\w-]+\.workers\.dev$/, - /^http:\/\/localhost:\d+$/, - /^exp:\/\//, - ]; - return allowed.some((re) => re.test(origin)); - }, + origin: (request) => isAllowedOrigin(request.headers.get('Origin')), allowedHeaders: ['Content-Type', 'Authorization', 'X-API-Key'], methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], }), @@ -85,6 +87,19 @@ export const app = new Elysia({ adapter: CloudflareAdapter }) .get('/health', () => ({ status: 'ok' as const }), { detail: { summary: 'Health status', tags: ['Meta'] }, }) + // Better Auth handles all /api/auth/** requests. Routing it through Elysia + // (rather than dispatching before Elysia) means the `cors` plugin above + // applies its credentialed-CORS policy and OPTIONS preflight to auth routes + // too. `auth` is resolved per-request because it depends on the Cloudflare + // env bindings, which are only available at request time. + .all( + '/api/auth/*', + async ({ request }) => { + const auth = await getAuth(getEnv()); + return auth.handler(request); + }, + { parse: 'none', detail: { hide: true } }, + ) .use(routes) .compile(); @@ -105,19 +120,37 @@ function enrichEnv(env: Env): Env { return env; } +// Local-dev hook: route `@neondatabase/serverless` through Neon's official local +// proxy (`ghcr.io/timowilhelm/local-neon-http-proxy`, see docker-compose.test.yml +// and https://neon.com/guides/local-development-with-neon) when NEON_DATABASE_URL +// points at `db.localtest.me`. The proxy serves the HTTP /sql API (neon-http, +// used by auth) and the WebSocket /v2 endpoint (neon-serverless Pool), so local +// 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 { + if (neonLocalConfigured || !databaseUrl) return; + try { + const host = new URL(databaseUrl).hostname.toLowerCase(); + if (host !== 'db.localtest.me') return; + const proxyPort = '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`); + neonConfig.useSecureWebSocket = false; + } catch { + // not a valid URL — leave neon defaults in place + } finally { + neonLocalConfigured = true; + } +} + const workerHandler = { async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise { const e = enrichEnv(env); + maybeConfigureLocalNeon(e.NEON_DATABASE_URL); setWorkerEnv(e as unknown as Record); // safe-cast: setWorkerEnv accepts Record; ValidatedEnv has no index signature by design - // Route /api/auth/** to Better Auth before Elysia sees it. - const url = new URL(request.url); - if (url.pathname.startsWith('/api/auth')) { - const validatedEnv = getEnv(); - const auth = await getAuth(validatedEnv); - return auth.handler(request); - } - 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/checks/src/check-type-casts.ts b/packages/checks/src/check-type-casts.ts index 2a08f90cad..4dfb1abb7c 100644 --- a/packages/checks/src/check-type-casts.ts +++ b/packages/checks/src/check-type-casts.ts @@ -30,6 +30,7 @@ const EXCLUDED_FILE_PATTERNS = [ /\/__tests__\//, // any file inside a __tests__ directory /\/test\//, // any file inside a test directory /\/playwright\//, // Playwright web E2E test infrastructure + /apps\/guides\/lib\/content\.ts$/, // generated file with large prose blobs containing "as X" in plain text ]; // Safe casts that TypeScript requires and cannot be replaced with guards diff --git a/packages/env/src/expo-client.ts b/packages/env/src/expo-client.ts index 4f8f1955aa..f819055607 100644 --- a/packages/env/src/expo-client.ts +++ b/packages/env/src/expo-client.ts @@ -18,7 +18,7 @@ export const clientEnvSchema = z.object({ EXPO_PUBLIC_API_URL: z.string().url(), EXPO_PUBLIC_R2_PUBLIC_URL: z.string().url(), EXPO_PUBLIC_GOOGLE_WEB_CLIENT_ID: z.string(), - EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID: z.string(), + EXPO_PUBLIC_GOOGLE_IOS_CLIENT_ID: z.string().optional(), EXPO_PUBLIC_SENTRY_DSN: z.string().optional(), EXPO_PUBLIC_GOOGLE_MAPS_API_KEY: z.string().optional(), }); diff --git a/packages/ui/package.json b/packages/ui/package.json index 66e7e3d18e..ca99ec9d3e 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -3,6 +3,6 @@ "version": "2.0.26", "private": true, "dependencies": { - "@packrat-ai/nativewindui": "2.0.3-2" + "@packrat-ai/nativewindui": "^2.0.6" } } diff --git a/patches/@packrat-ai%2Fnativewindui@2.0.3.patch b/patches/@packrat-ai%2Fnativewindui@2.0.3.patch index d4837e9502..ffcfa0e9a6 100644 --- a/patches/@packrat-ai%2Fnativewindui@2.0.3.patch +++ b/patches/@packrat-ai%2Fnativewindui@2.0.3.patch @@ -1,27 +1,106 @@ +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..b4e2b4903d8a5356fe3f2eba46b2e3cb1c691ba7 100644 +index 86091777372a04b76c9e866d2951bcfde9f34e05..deac88175ef8d3b14a4d7ff6a476ab6b7d3a3e71 100644 --- a/src/components/AdaptiveSearchHeader/AdaptiveSearchHeader.tsx +++ b/src/components/AdaptiveSearchHeader/AdaptiveSearchHeader.tsx -@@ -1,3 +1,4 @@ -+// @ts-nocheck - import { useAugmentedRef } from '@rn-primitives/hooks'; - import { Portal } from '@rn-primitives/portal'; - import { Stack, useNavigation } from 'expo-router'; +@@ -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..741aefba1ddf080c103cfca27b14b10c95cd5cdd 100644 +index 786b62c4a216c360beb193b96092186319a634cb..56b991f34884160c9b5c484415c94c5172b0f25c 100644 --- a/src/components/Icon/types.ts +++ b/src/components/Icon/types.ts -@@ -1,3 +1,4 @@ -+// @ts-nocheck +@@ -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 } 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..d9bf7337f45a9b49f69863a16a24672363a3b8af 100644 +index 95c66bbdece307542084c2e510c847543134f63e..d6cf24dbaf209ea0086cfb05d9203c81585578a2 100644 --- a/src/components/LargeTitleHeader/LargeTitleHeader.tsx +++ b/src/components/LargeTitleHeader/LargeTitleHeader.tsx -@@ -1,3 +1,4 @@ -+// @ts-nocheck - import { useRoute } from '@react-navigation/native'; - import { useAugmentedRef } from '@rn-primitives/hooks'; - import { Portal } from '@rn-primitives/portal'; +@@ -157,6 +157,7 @@ export function LargeTitleHeader(props: LargeTitleHeaderProps) { + + {!!props.searchBar && ( +