diff --git a/.github/workflows/verify.yml b/.github/workflows/verify.yml index 505f794..26e77dc 100644 --- a/.github/workflows/verify.yml +++ b/.github/workflows/verify.yml @@ -20,7 +20,7 @@ jobs: - uses: actions/setup-node@v4 with: - node-version: 20 + node-version: 22 cache: npm - run: npm ci diff --git a/__tests__/share-waitlist.test.js b/__tests__/share-waitlist.test.js index eff521d..7e4dd30 100644 --- a/__tests__/share-waitlist.test.js +++ b/__tests__/share-waitlist.test.js @@ -7,23 +7,33 @@ const screenSource = readFileSync( "utf8", ); -test("renders the waitlist capture in both former CTA locations", () => { - assert.equal((screenSource.match(/ { + assert.doesNotMatch(screenSource, / { - assert.match(screenSource, /joinWaitlist\(email\)/); - assert.match(screenSource, /inputMode="email"/); - assert.match(screenSource, /keyboardType="email-address"/); - assert.match(screenSource, /textContentType="emailAddress"/); - assert.match( +// These are source-text checks with known blind spots: they will not catch a +// semantically equivalent reintroduction under different JSX or import syntax. +// Real rendered-output assertions would require adding @testing-library/react-native +// or react-test-renderer as a new dependency in a separate approved task. +test("does not collect anonymous visitor email addresses", () => { + assert.doesNotMatch(screenSource, /joinWaitlist/); + assert.doesNotMatch(screenSource, /TextInput/); + assert.doesNotMatch(screenSource, /inputMode="email"/); + assert.doesNotMatch(screenSource, /keyboardType="email-address"/); + assert.doesNotMatch(screenSource, /textContentType="emailAddress"/); + assert.doesNotMatch(screenSource, /Join the App Store waitlist/); + assert.doesNotMatch( screenSource, /You're on the list — we'll email you when BestList is live\./, ); - assert.match( + assert.doesNotMatch( screenSource, /You're already on the list — we'll email you when BestList is live\./, ); diff --git a/app.json b/app.json index 6c2ff02..20fb5f9 100644 --- a/app.json +++ b/app.json @@ -47,6 +47,7 @@ "backgroundColor": "#F5F0E8" } ], + "expo-secure-store", [ "expo-build-properties", { diff --git a/app/_layout.tsx b/app/_layout.tsx index 96cb059..bce0f9d 100644 --- a/app/_layout.tsx +++ b/app/_layout.tsx @@ -26,7 +26,7 @@ Sentry.init({ // Configure Session Replay replaysSessionSampleRate: 0.1, replaysOnErrorSampleRate: 1, - integrations: [Sentry.mobileReplayIntegration(), Sentry.feedbackIntegration()], + integrations: [Sentry.mobileReplayIntegration()], // uncomment the line below to enable Spotlight (https://spotlightjs.com) // spotlight: __DEV__, diff --git a/app/share/[id].tsx b/app/share/[id].tsx index 017e710..198c4ea 100644 --- a/app/share/[id].tsx +++ b/app/share/[id].tsx @@ -9,7 +9,6 @@ import { ScrollView, Share, Text, - TextInput, View, type ImageSourcePropType, } from "react-native"; @@ -20,7 +19,6 @@ import { getPublicCategoryByShareId, getPublicCategoryOwnerUsername, getPublicEntries, - joinWaitlist, } from "@/lib/api"; import { getCategoryShareUrl } from "@/lib/category-sharing"; import { calculateOverallScore, sortEntries } from "@/lib/entry-score"; @@ -166,11 +164,6 @@ export default function ShareListScreen() { This shared BestList is not available. - - @@ -235,10 +228,6 @@ export default function ShareListScreen() { BestList - @@ -266,112 +255,6 @@ function SharedListLogo() { ); } -type WaitlistCaptureProps = { - buttonClassName: string; - labelClassName: string; -}; - -type WaitlistState = "idle" | "form" | "joined" | "already_joined"; - -function WaitlistCapture({ - buttonClassName, - labelClassName, -}: WaitlistCaptureProps) { - const [email, setEmail] = useState(""); - const [state, setState] = useState("idle"); - const [isSubmitting, setIsSubmitting] = useState(false); - const [errorMessage, setErrorMessage] = useState(null); - - async function submitWaitlist() { - const normalizedEmail = email.trim().toLowerCase(); - const isValidEmail = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(normalizedEmail); - - if (!isValidEmail) { - setErrorMessage("Enter a valid email address."); - return; - } - - setErrorMessage(null); - setIsSubmitting(true); - - try { - const result = await joinWaitlist(email); - setState(result); - } catch (error: unknown) { - console.error( - "Failed to join waitlist:", - error instanceof Error ? error.message : String(error), - ); - setErrorMessage("We couldn't save your email. Please try again."); - } finally { - setIsSubmitting(false); - } - } - - if (state === "joined") { - return ( - - {"You're on the list — we'll email you when BestList is live."} - - ); - } - - if (state === "already_joined") { - return ( - - {"You're already on the list — we'll email you when BestList is live."} - - ); - } - - if (state === "idle") { - return ( - setState("form")} - > - Join the App Store waitlist - - ); - } - - return ( - - void submitWaitlist()} - placeholder="you@example.com" - placeholderTextColor="#78716C" - returnKeyType="done" - textContentType="emailAddress" - value={email} - /> - {errorMessage ? ( - - {errorMessage} - - ) : null} - void submitWaitlist()} - > - - {isSubmitting ? "Joining…" : "Join waitlist"} - - - - ); -} - type SharedEntryCardProps = { entry: Entry; rank: number; diff --git a/components/home-screen.tsx b/components/home-screen.tsx index 2a903f1..e24c2a3 100644 --- a/components/home-screen.tsx +++ b/components/home-screen.tsx @@ -53,9 +53,9 @@ export function HomeScreen() { { text: "Create account", onPress: () => { - // Temporary placeholder: plain sign-up creates a separate account today - // and does not carry over the guest's existing lists. Real - // guest-to-account linking is scope 3. + // Email sign-up links the current guest user/data via linkGuestEmail. + // Google and Apple guest linking are not built yet; AuthScreen blocks + // those guest buttons with a "use email for now" message. router.push(signUpRoute); }, }, diff --git a/lib/api.ts b/lib/api.ts index ddf3a5c..52c35bb 100644 --- a/lib/api.ts +++ b/lib/api.ts @@ -49,8 +49,6 @@ type UpdateEntryPayload = Omit< "id" | "categoryId" | "createdAt" | "overallScore" >; -type WaitlistJoinResult = "joined" | "already_joined"; - const categoryColumns = "id,name,cover_photo,tone,is_shared,share_id,created_at"; const entryColumns = @@ -328,34 +326,6 @@ export async function getPublicCategoryOwnerUsername( return data; } -/** - * Adds an email address to the public share waitlist. - * - * @param email - The email address to register. - * @returns Whether the email joined or was already registered. - */ -export async function joinWaitlist( - email: string, -): Promise { - const normalizedEmail = email.trim().toLowerCase(); - const { error } = await getPublicSupabaseClient() - .from("waitlist_signups") - .insert({ - email: normalizedEmail, - source: "public_share", - }); - - if (error?.code === "23505") { - return "already_joined"; - } - - if (error) { - throw error; - } - - return "joined"; -} - /** * Fetches entries for a publicly shared category. * diff --git a/lib/index.ts b/lib/index.ts deleted file mode 100644 index 1d2a112..0000000 --- a/lib/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export { authRedirectTo } from "@/lib/auth"; -export { calculateOverallScore } from "@/lib/entry-score"; -export { supabase } from "@/lib/supabase"; diff --git a/lib/supabase.ts b/lib/supabase.ts index b84bfc7..1011993 100644 --- a/lib/supabase.ts +++ b/lib/supabase.ts @@ -1,11 +1,83 @@ import "react-native-url-polyfill/auto"; +import { + createClient, + processLock, + type SupportedStorage, +} from "@supabase/supabase-js"; import AsyncStorage from "@react-native-async-storage/async-storage"; -import { createClient, processLock } from "@supabase/supabase-js"; +import * as SecureStore from "expo-secure-store"; import { Platform } from "react-native"; const supabaseUrl = process.env.EXPO_PUBLIC_SUPABASE_URL; const supabaseAnonKey = process.env.EXPO_PUBLIC_SUPABASE_ANON_KEY; +const secureStoreValueLimitBytes = 2048; + +/** + * Stores Supabase auth sessions in SecureStore and migrates existing + * AsyncStorage sessions into SecureStore the first time they are read. + */ +const secureStoreAdapter: SupportedStorage = { + getItem: async (key: string) => { + const secureValue = await SecureStore.getItemAsync(key); + + if (secureValue != null) { + return secureValue; + } + + const legacyValue = await AsyncStorage.getItem(key); + + if (legacyValue == null) { + return legacyValue; + } + + await SecureStore.setItemAsync(key, legacyValue); + await AsyncStorage.removeItem(key); + + return legacyValue; + }, + setItem: (key: string, value: string) => { + if (isOverSecureStoreValueLimit(value)) { + console.warn( + "Supabase auth session is larger than SecureStore's 2048-byte Android value limit.", + ); + } + + return SecureStore.setItemAsync(key, value); + }, + removeItem: (key: string) => SecureStore.deleteItemAsync(key), +}; + +function isOverSecureStoreValueLimit(value: string) { + let bytes = 0; + + for (let index = 0; index < value.length; index++) { + const codePoint = value.charCodeAt(index); + + if (codePoint >= 0xd800 && codePoint < 0xe000) { + const nextCodePoint = value.charCodeAt(index + 1); + + if ( + codePoint < 0xdc00 && + nextCodePoint >= 0xdc00 && + nextCodePoint < 0xe000 + ) { + bytes += 4; + index++; + } else { + bytes += 3; + } + } else { + bytes += codePoint < 0x80 ? 1 : codePoint < 0x800 ? 2 : 3; + } + + if (bytes > secureStoreValueLimitBytes) { + return true; + } + } + + return false; +} export const isSupabaseConfigured = !!(supabaseUrl && supabaseAnonKey); @@ -22,7 +94,7 @@ export function assertSupabaseConfigured() { export const supabase = createClient(supabaseUrl ?? "", supabaseAnonKey ?? "", { auth: { - ...(Platform.OS !== "web" ? { storage: AsyncStorage } : {}), + ...(Platform.OS !== "web" ? { storage: secureStoreAdapter } : {}), autoRefreshToken: true, persistSession: true, detectSessionInUrl: false, diff --git a/lib/waitlist.test.js b/lib/waitlist.test.js index cd79c60..65aaa32 100644 --- a/lib/waitlist.test.js +++ b/lib/waitlist.test.js @@ -1,33 +1,56 @@ import assert from "node:assert/strict"; import { readFileSync } from "node:fs"; +import { registerHooks } from "node:module"; import test from "node:test"; const apiSource = readFileSync(new URL("./api.ts", import.meta.url), "utf8"); -const joinWaitlistSource = apiSource.match( - /export async function joinWaitlist[\s\S]*?(?=\nexport async function)/, -)?.[0]; -assert.ok(joinWaitlistSource, "joinWaitlist source should exist"); +const stubModules = new Map([ + ["react-native-url-polyfill/auto", ""], + [ + "@react-native-async-storage/async-storage", + `export default { + getItem: async () => null, + setItem: async () => undefined, + removeItem: async () => undefined, + };`, + ], + [ + "expo-secure-store", + `export const getItemAsync = async () => null; + export const setItemAsync = async () => undefined; + export const deleteItemAsync = async () => undefined;`, + ], + ["react-native", `export const Platform = { OS: "web" };`], + [ + "@supabase/supabase-js", + `export const processLock = {}; + export function createClient() { + return {}; + }`, + ], +]); -test("joins the public waitlist with a normalized email and fixed source", () => { - assert.match( - joinWaitlistSource, - /export async function joinWaitlist\(\s*email: string,\s*\): Promise/, - ); - assert.match( - joinWaitlistSource, - /const normalizedEmail = email\.trim\(\)\.toLowerCase\(\)/, - ); - assert.match( - joinWaitlistSource, - /getPublicSupabaseClient\(\)[\s\S]*\.from\("waitlist_signups"\)[\s\S]*\.insert\(\{[\s\S]*email: normalizedEmail,[\s\S]*source: "public_share",[\s\S]*\}\)/, - ); - assert.match(joinWaitlistSource, /return "joined"/); - assert.doesNotMatch(joinWaitlistSource, /\.select\(/); +registerHooks({ + resolve(specifier, context, nextResolve) { + const stubSource = stubModules.get(specifier); + + if (stubSource !== undefined) { + return { + url: `data:text/javascript,${encodeURIComponent(stubSource)}`, + shortCircuit: true, + }; + } + + return nextResolve(specifier, context); + }, }); -test("maps only unique violations to the already joined result", () => { - assert.match(joinWaitlistSource, /error\?\.code === "23505"/); - assert.match(joinWaitlistSource, /return "already_joined"/); - assert.match(joinWaitlistSource, /throw error/); +const apiModule = await import("./api.ts"); + +test("does not expose an app-level public waitlist API", () => { + assert.doesNotMatch(apiSource, /type WaitlistJoinResult/); + assert.equal("joinWaitlist" in apiModule, false); + assert.doesNotMatch(apiSource, /\.from\(\s*["'`]waitlist_signups["'`]\s*\)/); + assert.doesNotMatch(apiSource, /source:\s*["'`]public_share["'`]/); }); diff --git a/package-lock.json b/package-lock.json index cfbbabb..47c876d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -31,6 +31,7 @@ "expo-image-picker": "~17.0.11", "expo-linking": "~8.0.11", "expo-router": "~6.0.24", + "expo-secure-store": "~15.0.8", "expo-splash-screen": "~31.0.13", "expo-status-bar": "~3.0.9", "expo-symbols": "~1.0.8", @@ -8328,6 +8329,15 @@ "node": ">=10" } }, + "node_modules/expo-secure-store": { + "version": "15.0.8", + "resolved": "https://registry.npmjs.org/expo-secure-store/-/expo-secure-store-15.0.8.tgz", + "integrity": "sha512-lHnzvRajBu4u+P99+0GEMijQMFCOYpWRO4dWsXSuMt77+THPIGjzNvVKrGSl6mMrLsfVaKL8BpwYZLGlgA+zAw==", + "license": "MIT", + "peerDependencies": { + "expo": "*" + } + }, "node_modules/expo-server": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/expo-server/-/expo-server-1.0.7.tgz", diff --git a/package.json b/package.json index b2b7d17..e2a5dff 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "expo-image-picker": "~17.0.11", "expo-linking": "~8.0.11", "expo-router": "~6.0.24", + "expo-secure-store": "~15.0.8", "expo-splash-screen": "~31.0.13", "expo-status-bar": "~3.0.9", "expo-symbols": "~1.0.8", diff --git a/types/index.ts b/types/index.ts deleted file mode 100644 index 340e87d..0000000 --- a/types/index.ts +++ /dev/null @@ -1,3 +0,0 @@ -export type { Category, CategoryCardTone } from "@/types/category"; -export type { Entry } from "@/types/entry"; -export type { Profile, ProfileRow } from "@/types/profile"; diff --git a/types/react-native-vector-icons.d.ts b/types/react-native-vector-icons.d.ts deleted file mode 100644 index 3e75126..0000000 --- a/types/react-native-vector-icons.d.ts +++ /dev/null @@ -1,16 +0,0 @@ -declare module "react-native-vector-icons/FontAwesome" { - import type { ComponentType } from "react"; - import type { TextProps } from "react-native"; - - type FontAwesomeName = "edit" | "lock" | "share" | "sliders" | "users"; - - type FontAwesomeProps = TextProps & { - name: FontAwesomeName; - size?: number; - color?: string; - }; - - const FontAwesome: ComponentType; - - export default FontAwesome; -}