fix(acquisition): close invite privacy gaps

This commit is contained in:
jjbrunton committed 2026-09-06 13:18:11 +01:00
1 parent 1c7084c033
commit 4a57f31615
15 files changed
+213 -70

No files matched your search

+13 -6
View File
@@ -14,24 +14,31 @@ const bucket = (n: number) => n === 0 ? "0" : n <= 2 ? "1-2" : n <= 5 ? "3-5" :
export default function PairedScreen() { export default function PairedScreen() {
const { user, couple, partner } = useAuthStore(); const { user, couple, partner } = useAuthStore();
const { newMatchesCount, fetchMatches, error } = useMatchStore(); const { newMatchesCount, fetchMatches } = useMatchStore();
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
const [loadedSuccessfully, setLoadedSuccessfully] = useState(false);
const { role: rawRole } = useLocalSearchParams<{ role?: "inviter" | "joiner" }>(); const { role: rawRole } = useLocalSearchParams<{ role?: "inviter" | "joiner" }>();
const heading = useRef<Text>(null); const heading = useRef<Text>(null);
const role = rawRole === "inviter" ? "inviter" : "joiner"; const role = rawRole === "inviter" ? "inviter" : "joiner";
useEffect(() => { useEffect(() => {
void fetchMatches(true).finally(() => setReady(true)); let cancelled = false;
void fetchMatches(true).then((success) => {
if (cancelled) return;
setLoadedSuccessfully(success);
setReady(true);
});
return () => { cancelled = true; };
}, [fetchMatches]); }, [fetchMatches]);
useEffect(() => { useEffect(() => {
if (!ready || error || !user || !couple) return; if (!ready || !loadedSuccessfully || !user || !couple) return;
void markPairedUnlockSeen(user.id, couple.id); void markPairedUnlockSeen(user.id, couple.id);
Events.pairingUnlockViewed(role, bucket(newMatchesCount)); Events.pairingUnlockViewed(role, bucket(newMatchesCount));
const timer = setTimeout(() => { const timer = setTimeout(() => {
if (heading.current) AccessibilityInfo.setAccessibilityFocus(heading.current as never); if (heading.current) AccessibilityInfo.setAccessibilityFocus(heading.current as never);
}, 250); }, 250);
return () => clearTimeout(timer); return () => clearTimeout(timer);
}, [ready, error, user, couple, newMatchesCount]); }, [ready, loadedSuccessfully, user, couple, newMatchesCount]);
const partnerName = partner?.name?.split(" ")[0] || "your partner"; const partnerName = partner?.name?.split(" ")[0] || "your partner";
const viewMatches = () => { Events.pairingUnlockCtaTapped(role, "view_matches"); router.replace("/(app)/matches"); }; const viewMatches = () => { Events.pairingUnlockCtaTapped(role, "view_matches"); router.replace("/(app)/matches"); };
@@ -40,8 +47,8 @@ export default function PairedScreen() {
if (!ready) { if (!ready) {
return <GradientBackground><View style={styles.container}><ActivityIndicator size="large" color={colors.primary} /></View></GradientBackground>; return <GradientBackground><View style={styles.container}><ActivityIndicator size="large" color={colors.primary} /></View></GradientBackground>;
} }
if (error) { if (!loadedSuccessfully) {
return <GradientBackground><View style={styles.container}><Text style={styles.title}>We couldn't load your shared discoveries</Text><Text style={styles.body}>Check your connection and try again.</Text><GlassButton fullWidth onPress={() => { setReady(false); void fetchMatches(true).finally(() => setReady(true)); }} testID="paired-retry">Try again</GlassButton></View></GradientBackground>; return <GradientBackground><View style={styles.container}><Text style={styles.title}>We couldn't load your shared discoveries</Text><Text style={styles.body}>Check your connection and try again.</Text><GlassButton fullWidth onPress={() => { setReady(false); void fetchMatches(true).then((success) => { setLoadedSuccessfully(success); setReady(true); }); }} testID="paired-retry">Try again</GlassButton></View></GradientBackground>;
} }
return <GradientBackground><View style={styles.container}> return <GradientBackground><View style={styles.container}>
<LinearGradient colors={gradients.primary as [string, string]} style={styles.icon}><Ionicons name="heart" size={42} color={colors.text} /></LinearGradient> <LinearGradient colors={gradients.primary as [string, string]} style={styles.icon}><Ionicons name="heart" size={42} color={colors.text} /></LinearGradient>
+11 -11
View File
@@ -52,7 +52,7 @@ export default function PairingScreen() {
let cancelled = false; let cancelled = false;
void (async () => { void (async () => {
if (typeof params.incomingCode === "string") { if (typeof params.incomingCode === "string") {
await clearPendingInviteCode(); await clearPendingInviteCode(user.id);
if (!cancelled) { if (!cancelled) {
Alert.alert("You're already paired", "That invite is for a different account. You're already paired with your partner.", [{ text: "OK", onPress: () => router.replace("/(app)") }]); Alert.alert("You're already paired", "That invite is for a different account. You're already paired with your partner.", [{ text: "OK", onPress: () => router.replace("/(app)") }]);
} }
@@ -86,15 +86,15 @@ export default function PairingScreen() {
const routeCode = typeof params.code === "string" ? normalizeInviteCode(params.code) : ""; const routeCode = typeof params.code === "string" ? normalizeInviteCode(params.code) : "";
if (isValidInviteCode(routeCode)) { if (isValidInviteCode(routeCode)) {
await applyPrefill(routeCode); await applyPrefill(routeCode);
await clearPendingInviteCode(); await clearPendingInviteCode(user?.id);
if (!cancelled) setPrefillAttempted(true); if (!cancelled) setPrefillAttempted(true);
return; return;
} }
const stashedCode = await getPendingInviteCode(); const stashedCode = user?.id ? await getPendingInviteCode(user.id) : null;
if (stashedCode) { if (stashedCode) {
await applyPrefill(stashedCode); await applyPrefill(stashedCode);
await clearPendingInviteCode(); await clearPendingInviteCode(user?.id);
if (!cancelled) setPrefillAttempted(true); if (!cancelled) setPrefillAttempted(true);
return; return;
} }
@@ -109,7 +109,7 @@ export default function PairingScreen() {
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [couple, params.code, prefillAttempted]); }, [couple, params.code, prefillAttempted, user?.id]);
const acceptClipboardOffer = () => { const acceptClipboardOffer = () => {
if (!clipboardOfferCode) return; if (!clipboardOfferCode) return;
@@ -312,7 +312,7 @@ export default function PairingScreen() {
"Cancel this invite?", "Cancel this invite?",
"Your code will stop working, and the answers you've given since you created it will be deleted. Answers you gave before you created the code are kept.", "Your code will stop working, and the answers you've given since you created it will be deleted. Answers you gave before you created the code are kept.",
[ [
{ text: "Keep my invite", style: "cancel", onPress: () => void clearPendingInviteCode() }, { text: "Keep my invite", style: "cancel", onPress: () => void clearPendingInviteCode(user?.id) },
{ {
text: "Cancel mine and join", style: "destructive", onPress: async () => { text: "Cancel mine and join", style: "destructive", onPress: async () => {
setIsSubmitting(true); setIsSubmitting(true);
@@ -320,7 +320,7 @@ export default function PairingScreen() {
await cancelInvite(async () => { await cancelInvite(async () => {
setInviteCode(incomingCode); setInviteCode(incomingCode);
setWasPrefilled(true); setWasPrefilled(true);
await clearPendingInviteCode(); await clearPendingInviteCode(user?.id);
}); });
} finally { } finally {
setIsSubmitting(false); setIsSubmitting(false);
@@ -369,7 +369,7 @@ export default function PairingScreen() {
<GlassButton onPress={handleCancelOwnAndPrefill} variant="danger" fullWidth disabled={isSubmitting} testID="pairing-conflict-cancel-and-join"> <GlassButton onPress={handleCancelOwnAndPrefill} variant="danger" fullWidth disabled={isSubmitting} testID="pairing-conflict-cancel-and-join">
Cancel mine and join Cancel mine and join
</GlassButton> </GlassButton>
<TouchableOpacity onPress={() => { setIsIncomingConflictDismissed(true); void clearPendingInviteCode(); }} style={styles.cancelButton} testID="pairing-conflict-keep-own"> <TouchableOpacity onPress={() => { setIsIncomingConflictDismissed(true); void clearPendingInviteCode(user?.id); }} style={styles.cancelButton} testID="pairing-conflict-keep-own">
<Text style={styles.cancelButtonText}>Keep my invite</Text> <Text style={styles.cancelButtonText}>Keep my invite</Text>
</TouchableOpacity> </TouchableOpacity>
</GlassCard> </GlassCard>
@@ -400,7 +400,7 @@ export default function PairingScreen() {
> >
<GlassCard variant="elevated"> <GlassCard variant="elevated">
<Text style={styles.sealedCountText} testID="pairing-sealed-count"> <Text style={styles.sealedCountText} testID="pairing-sealed-count">
You've answered at least {sealedCount} question{sealedCount === 1 ? "" : "s"} about you two. When you both answer, shared discoveries are visible to you both. You have saved at least {sealedCount} private question{sealedCount === 1 ? "" : "s"}. Vote-style questions show shared outcomes; open text, audio, photo, and who-likely responses can be visible to you both after you each answer.
</Text> </Text>
</GlassCard> </GlassCard>
</Animated.View> </Animated.View>
+2 -2
View File
@@ -134,12 +134,12 @@ export default function RootLayout() {
const { isAuthenticated, user } = useAuthStore.getState(); const { isAuthenticated, user } = useAuthStore.getState();
const onboardingPending = needsOnboarding(user?.onboarding_completed, user?.onboarding_version); const onboardingPending = needsOnboarding(user?.onboarding_completed, user?.onboarding_version);
if (user?.couple_id) { if (user?.couple_id) {
await import("../src/lib/pendingInviteCode").then(({ clearPendingInviteCode }) => clearPendingInviteCode()); await import("../src/lib/pendingInviteCode").then(({ clearPendingInviteCode }) => clearPendingInviteCode(user.id));
if (user.couple_id) { if (user.couple_id) {
router.push({ pathname: "/(app)/pairing", params: { incomingCode: inviteLink.code } }); router.push({ pathname: "/(app)/pairing", params: { incomingCode: inviteLink.code } });
} }
} else { } else {
await stashPendingInviteCode(inviteLink.code); await stashPendingInviteCode(inviteLink.code, user?.id ?? null);
if (isAuthenticated && !onboardingPending) { if (isAuthenticated && !onboardingPending) {
router.push({ pathname: "/(app)/pairing", params: { code: inviteLink.code } }); router.push({ pathname: "/(app)/pairing", params: { code: inviteLink.code } });
} }
+8 -8
View File
@@ -7,8 +7,7 @@ import { getPendingInviteCode } from "../src/lib/pendingInviteCode";
export default function Index() { export default function Index() {
const { isAuthenticated, isLoading, user } = useAuthStore(); const { isAuthenticated, isLoading, user } = useAuthStore();
const [pendingCodeChecked, setPendingCodeChecked] = useState(false); const [pendingInviteCheck, setPendingInviteCheck] = useState<{ userId: string; hasInvite: boolean } | null>(null);
const [hasPendingInvite, setHasPendingInvite] = useState(false);
// Check once, after auth resolves, whether an invite code is waiting to be // Check once, after auth resolves, whether an invite code is waiting to be
// applied (e.g. the user tapped a join link before signing in). // applied (e.g. the user tapped a join link before signing in).
@@ -18,16 +17,17 @@ export default function Index() {
} }
let cancelled = false; let cancelled = false;
getPendingInviteCode().then((code) => { if (!user?.id) return;
setPendingInviteCheck(null);
getPendingInviteCode(user.id).then((code) => {
if (cancelled) return; if (cancelled) return;
setHasPendingInvite(!!code); setPendingInviteCheck({ userId: user.id, hasInvite: !!code });
setPendingCodeChecked(true);
}); });
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [isLoading, isAuthenticated, user?.couple_id]); }, [isLoading, isAuthenticated, user?.couple_id, user?.id]);
// Show loading state while auth is being determined // Show loading state while auth is being determined
if (isLoading) { if (isLoading) {
@@ -56,7 +56,7 @@ export default function Index() {
// Wait for the one-time pending invite code check before deciding the // Wait for the one-time pending invite code check before deciding the
// route, so a stashed code from a join link is applied without an extra // route, so a stashed code from a join link is applied without an extra
// trip through the home screen. // trip through the home screen.
if (!pendingCodeChecked) { if (pendingInviteCheck?.userId !== user?.id) {
return ( return (
<View style={styles.container}> <View style={styles.container}>
<ActivityIndicator size="large" color="#e94560" /> <ActivityIndicator size="large" color="#e94560" />
@@ -64,7 +64,7 @@ export default function Index() {
); );
} }
if (hasPendingInvite) { if (pendingInviteCheck?.hasInvite) {
return <Redirect href="/(app)/pairing" />; return <Redirect href="/(app)/pairing" />;
} }
@@ -5,7 +5,8 @@ describe("buildInviteShareMessage", () => {
const message = buildInviteShareMessage("AB12CD34", 3); const message = buildInviteShareMessage("AB12CD34", 3);
expect(message).toContain("https://sauci.app/join/AB12CD34"); expect(message).toContain("https://sauci.app/join/AB12CD34");
expect(message).toContain("enter code AB12CD34 in Sauci"); expect(message).toContain("enter code AB12CD34 in Sauci");
expect(message).toContain("compare our answers together"); expect(message).toContain("Vote-style questions show shared outcomes");
expect(message).toContain("open answers can be visible to us both after we each answer");
}); });
it("does not claim a sealed outcome before answers exist", () => { it("does not claim a sealed outcome before answers exist", () => {
@@ -10,20 +10,40 @@ describe("pendingInviteCode", () => {
await AsyncStorage.clear(); await AsyncStorage.clear();
}); });
it("stashes, retrieves, and clears a valid code", async () => { it("stashes, retrieves, and clears a valid code for its owner", async () => {
await stashPendingInviteCode("abcd1234"); await stashPendingInviteCode("abcd1234", "user-a");
await expect(getPendingInviteCode()).resolves.toBe("ABCD1234"); await expect(getPendingInviteCode("user-a")).resolves.toBe("ABCD1234");
await clearPendingInviteCode(); await clearPendingInviteCode("user-a");
await expect(getPendingInviteCode()).resolves.toBeNull(); await expect(getPendingInviteCode("user-a")).resolves.toBeNull();
}); });
it("returns null when nothing has been stashed", async () => { it("returns null when nothing has been stashed", async () => {
await expect(getPendingInviteCode()).resolves.toBeNull(); await expect(getPendingInviteCode("user-a")).resolves.toBeNull();
}); });
it("ignores a stashed value that no longer matches the invite code shape", async () => { it("ignores a stashed value that no longer matches the invite code shape", async () => {
await AsyncStorage.setItem("pending_invite_code", "not-a-code"); await AsyncStorage.setItem("pending_invite_code", "not-a-code");
await expect(getPendingInviteCode()).resolves.toBeNull(); await expect(getPendingInviteCode("user-a")).resolves.toBeNull();
});
it("does not leak a claimed invite to a later account", async () => {
await stashPendingInviteCode("abcd1234");
await expect(getPendingInviteCode("user-a")).resolves.toBe("ABCD1234");
await expect(getPendingInviteCode("user-b")).resolves.toBeNull();
await clearPendingInviteCode("user-b");
await expect(getPendingInviteCode("user-a")).resolves.toBe("ABCD1234");
});
it("lets only one concurrently arriving account claim a pre-auth invite", async () => {
await stashPendingInviteCode("abcd1234");
const [first, second] = await Promise.all([
getPendingInviteCode("user-a"),
getPendingInviteCode("user-b"),
]);
expect([first, second].filter(Boolean)).toHaveLength(1);
}); });
}); });
@@ -50,10 +50,11 @@ describe('matchStore API behavior', () => {
let release: (page: { matches: unknown[]; totalCount: number }) => void = () => undefined; let release: (page: { matches: unknown[]; totalCount: number }) => void = () => undefined;
(apiClient.get as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; })); (apiClient.get as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
const inFlight = useMatchStore.getState().fetchMatches(true); const inFlight = useMatchStore.getState().fetchMatches(true);
await useMatchStore.getState().fetchMatches(true, { silent: true }); const overlapping = useMatchStore.getState().fetchMatches(true, { silent: true });
expect(apiClient.get).toHaveBeenCalledTimes(1); expect(apiClient.get).toHaveBeenCalledTimes(1);
release({ matches: [], totalCount: 0 }); release({ matches: [], totalCount: 0 });
await inFlight; await inFlight;
await overlapping;
(apiClient.get as jest.Mock).mockClear(); (apiClient.get as jest.Mock).mockClear();
useMatchStore.setState({ hasMore: false }); useMatchStore.setState({ hasMore: false });
@@ -61,6 +62,21 @@ describe('matchStore API behavior', () => {
expect(apiClient.get).not.toHaveBeenCalled(); expect(apiClient.get).not.toHaveBeenCalled();
}); });
it('makes overlapping callers await the same failed refresh', async () => {
jest.spyOn(console, 'error').mockImplementation(() => undefined);
let reject!: (error: Error) => void;
(apiClient.get as jest.Mock).mockImplementationOnce(() => new Promise((_, rejectRequest) => { reject = rejectRequest; }));
const first = useMatchStore.getState().fetchMatches(true);
const second = useMatchStore.getState().fetchMatches(true, { silent: true });
expect(apiClient.get).toHaveBeenCalledTimes(1);
reject(new Error('network'));
await expect(first).resolves.toBe(false);
await expect(second).resolves.toBe(false);
expect(useMatchStore.getState().error).toBe('Failed to load matches');
});
it('appends and deduplicates later pages', async () => { it('appends and deduplicates later pages', async () => {
useMatchStore.setState({ matches: [match('existing')], page: 1, hasMore: true, newMatchesCount: 1, totalCount: 25 }); useMatchStore.setState({ matches: [match('existing')], page: 1, hasMore: true, newMatchesCount: 1, totalCount: 25 });
const page = [match('existing'), ...Array.from({ length: 19 }, (_, index) => match(`m${index}`, false))]; const page = [match('existing'), ...Array.from({ length: 19 }, (_, index) => match(`m${index}`, false))];
+1 -1
View File
@@ -8,7 +8,7 @@ export function buildInviteShareMessage(inviteCode: string, sealedCount: number)
const link = `https://sauci.app/join/${inviteCode}`; const link = `https://sauci.app/join/${inviteCode}`;
if (sealedCount > 0) { if (sealedCount > 0) {
const noun = sealedCount === 1 ? "question" : "questions"; const noun = sealedCount === 1 ? "question" : "questions";
return `Join me on Sauci! I've answered ${sealedCount} ${noun} about us. Join to compare our answers together. ${link} If the link does not open the app, enter code ${inviteCode} in Sauci.`; return `Join me on Sauci! I've saved ${sealedCount} ${noun} for us to compare. Vote-style questions show shared outcomes; open answers can be visible to us both after we each answer. ${link} If the link does not open the app, enter code ${inviteCode} in Sauci.`;
} }
return `Join me on Sauci! ${link} If the link does not open the app, enter code ${inviteCode} in Sauci.`; return `Join me on Sauci! ${link} If the link does not open the app, enter code ${inviteCode} in Sauci.`;
} }
+52 -10
View File
@@ -6,31 +6,73 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
import { isValidInviteCode, normalizeInviteCode } from "./inviteLink"; import { isValidInviteCode, normalizeInviteCode } from "./inviteLink";
const STORAGE_KEY = "pending_invite_code"; const STORAGE_KEY = "pending_invite_code";
let storageOperation = Promise.resolve();
function withStorageLock<T>(operation: () => Promise<T>): Promise<T> {
const next = storageOperation.then(operation, operation);
storageOperation = next.then(() => undefined, () => undefined);
return next;
}
interface PendingInviteCode {
code: string;
/** The account that received or first claimed this deferred invite. */
userId: string | null;
}
function parsePendingInviteCode(value: string | null): PendingInviteCode | null {
if (!value) return null;
// Treat legacy, unscoped values as a pre-auth hand-off. The first account
// that intentionally enters the pairing flow claims it below.
if (isValidInviteCode(value)) return { code: normalizeInviteCode(value), userId: null };
export async function stashPendingInviteCode(code: string): Promise<void> {
try { try {
await AsyncStorage.setItem(STORAGE_KEY, normalizeInviteCode(code)); const parsed = JSON.parse(value) as Partial<PendingInviteCode>;
if (typeof parsed.code !== "string" || !isValidInviteCode(parsed.code)) return null;
if (parsed.userId !== null && typeof parsed.userId !== "string") return null;
return { code: normalizeInviteCode(parsed.code), userId: parsed.userId ?? null };
} catch {
return null;
}
}
export async function stashPendingInviteCode(code: string, userId: string | null = null): Promise<void> {
try {
await withStorageLock(() => AsyncStorage.setItem(STORAGE_KEY, JSON.stringify({ code: normalizeInviteCode(code), userId } satisfies PendingInviteCode)));
} catch (error) { } catch (error) {
console.error("Error stashing pending invite code:", error); console.error("Error stashing pending invite code:", error);
} }
} }
export async function getPendingInviteCode(): Promise<string | null> { export async function getPendingInviteCode(userId: string): Promise<string | null> {
try { try {
const value = await AsyncStorage.getItem(STORAGE_KEY); return await withStorageLock(async () => {
if (value && isValidInviteCode(value)) { const pending = parsePendingInviteCode(await AsyncStorage.getItem(STORAGE_KEY));
return normalizeInviteCode(value); if (!pending || (pending.userId && pending.userId !== userId)) return null;
}
return null; // A hand-off made before sign-in becomes owned by the account that
// reaches pairing. Later account switches cannot consume it.
if (!pending.userId) {
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify({ ...pending, userId }));
}
return pending.code;
});
} catch (error) { } catch (error) {
console.error("Error reading pending invite code:", error); console.error("Error reading pending invite code:", error);
return null; return null;
} }
} }
export async function clearPendingInviteCode(): Promise<void> { export async function clearPendingInviteCode(userId?: string): Promise<void> {
try { try {
await AsyncStorage.removeItem(STORAGE_KEY); await withStorageLock(async () => {
if (userId) {
const pending = parsePendingInviteCode(await AsyncStorage.getItem(STORAGE_KEY));
if (pending?.userId && pending.userId !== userId) return;
}
await AsyncStorage.removeItem(STORAGE_KEY);
});
} catch (error) { } catch (error) {
console.error("Error clearing pending invite code:", error); console.error("Error clearing pending invite code:", error);
} }
+2 -2
View File
@@ -210,7 +210,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
if (unlockUserId) { if (unlockUserId) {
await clearPairedUnlockSeen(unlockUserId, unlockCoupleId); await clearPairedUnlockSeen(unlockUserId, unlockCoupleId);
} }
await clearPendingInviteCode(); await clearPendingInviteCode(unlockUserId);
// Clear local state FIRST to ensure UI updates even if Supabase call fails // Clear local state FIRST to ensure UI updates even if Supabase call fails
set({ set({
@@ -259,7 +259,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
}); });
// Clear other stores when user signs out // Clear other stores when user signs out
if (user === null) { if (user === null) {
void clearPendingInviteCode(); void clearPendingInviteCode(priorUserId);
if (priorUserId) { if (priorUserId) {
void clearPairedUnlockSeen(priorUserId, priorCoupleId); void clearPairedUnlockSeen(priorUserId, priorCoupleId);
} }
+20 -11
View File
@@ -28,6 +28,7 @@ export interface FetchOptions { silent?: boolean }
// account's request for the same key, and a stale generation's `finally` must not // account's request for the same key, and a stale generation's `finally` must not
// clear an entry a newer generation just claimed. // clear an entry a newer generation just claimed.
const inFlight = new Map<string, number>(); const inFlight = new Map<string, number>();
const activeInFlight = new Map<string, { generation: number; promise: Promise<boolean> }>();
// Rate limit: 12 hours in milliseconds // Rate limit: 12 hours in milliseconds
const NUDGE_COOLDOWN_MS = 12 * 60 * 60 * 1000; const NUDGE_COOLDOWN_MS = 12 * 60 * 60 * 1000;
@@ -80,7 +81,7 @@ interface MatchState {
*/ */
generation: number; generation: number;
// Methods // Methods
fetchMatches: (refresh?: boolean, options?: FetchOptions) => Promise<void>; fetchMatches: (refresh?: boolean, options?: FetchOptions) => Promise<boolean>;
markAsSeen: (matchId: string) => Promise<void>; markAsSeen: (matchId: string) => Promise<void>;
markAllAsSeen: () => Promise<void>; markAllAsSeen: () => Promise<void>;
addMatch: (match: Match) => void; addMatch: (match: Match) => void;
@@ -130,18 +131,20 @@ export const useMatchStore = create<MatchState>((set, get) => ({
isNudging: false, isNudging: false,
generation: 0, generation: 0,
fetchMatches: async (refresh = false, options = {}) => { fetchMatches: (refresh = false, options = {}) => {
const coupleId = useAuthStore.getState().user?.couple_id; const coupleId = useAuthStore.getState().user?.couple_id;
const myGeneration = get().generation; const myGeneration = get().generation;
// Early return if no couple - user isn't paired yet // Early return if no couple - user isn't paired yet
if (!coupleId) { if (!coupleId) {
set({ matches: [], newMatchesCount: 0, totalCount: 0, isLoading: false, isRefreshing: false }); set({ matches: [], newMatchesCount: 0, totalCount: 0, isLoading: false, isRefreshing: false });
return; return Promise.resolve(false);
} }
const state = get(); const state = get();
if (inFlight.get('active') === myGeneration || (state.isLoadingMore && !refresh)) return; const activeRequest = activeInFlight.get('active');
if (activeRequest?.generation === myGeneration) return activeRequest.promise;
if (state.isLoadingMore && !refresh) return Promise.resolve(false);
// Nothing on screen yet is the only case that warrants blanking the list // Nothing on screen yet is the only case that warrants blanking the list
// for a spinner; every other refresh leaves the cached rows in place. // for a spinner; every other refresh leaves the cached rows in place.
@@ -149,17 +152,17 @@ export const useMatchStore = create<MatchState>((set, get) => ({
if (refresh) { if (refresh) {
set({ isLoading: initial, isRefreshing: !initial && !options.silent, error: null, page: 0, hasMore: true }); set({ isLoading: initial, isRefreshing: !initial && !options.silent, error: null, page: 0, hasMore: true });
} else { } else {
if (!state.hasMore) return; if (!state.hasMore) return Promise.resolve(false);
set({ isLoadingMore: true }); set({ isLoadingMore: true });
} }
inFlight.set('active', myGeneration); const request = (async (): Promise<boolean> => {
try { try {
const currentPage = refresh ? 0 : state.page; const currentPage = refresh ? 0 : state.page;
const result = await apiClient.get<{ matches: Match[]; totalCount: number | null; hasMore?: boolean }>(`/v1/matches?page=${currentPage}&limit=${BATCH_SIZE}`); const result = await apiClient.get<{ matches: Match[]; totalCount: number | null; hasMore?: boolean }>(`/v1/matches?page=${currentPage}&limit=${BATCH_SIZE}`);
// A sign-out/account switch that happened while this request was in // A sign-out/account switch that happened while this request was in
// flight must not let its response populate the next account's store. // flight must not let its response populate the next account's store.
if (get().generation !== myGeneration) return; if (get().generation !== myGeneration) return false;
const nonArchivedMatches = result.matches; const nonArchivedMatches = result.matches;
const totalCount = result.totalCount; const totalCount = result.totalCount;
const archivedMatchIds = state.archivedMatchIds; const archivedMatchIds = state.archivedMatchIds;
@@ -174,7 +177,7 @@ export const useMatchStore = create<MatchState>((set, get) => ({
} else { } else {
set({ isLoadingMore: false, hasMore: false }); set({ isLoadingMore: false, hasMore: false });
} }
return; return true;
} }
const sortedData = nonArchivedMatches; const sortedData = nonArchivedMatches;
@@ -208,14 +211,19 @@ export const useMatchStore = create<MatchState>((set, get) => ({
loadedAt, loadedAt,
}; };
}); });
return true;
} catch (err) { } catch (err) {
if (get().generation !== myGeneration) return; if (get().generation !== myGeneration) return false;
console.error("Error fetching matches:", err); console.error("Error fetching matches:", err);
set({ error: "Failed to load matches", isLoading: false, isRefreshing: false, isLoadingMore: false }); set({ error: "Failed to load matches", isLoading: false, isRefreshing: false, isLoadingMore: false });
return false;
} finally { } finally {
// An old generation's cleanup must not delete a newer generation's guard. // An old generation's cleanup must not delete a newer generation's guard.
if (inFlight.get('active') === myGeneration) inFlight.delete('active'); if (activeInFlight.get('active')?.generation === myGeneration) activeInFlight.delete('active');
} }
})();
activeInFlight.set('active', { generation: myGeneration, promise: request });
return request;
}, },
markAsSeen: async (matchId) => { markAsSeen: async (matchId) => {
@@ -296,6 +304,7 @@ clearMatches: () => {
// A request belonging to the signed-out user must not suppress the next // A request belonging to the signed-out user must not suppress the next
// one: the guard is a duplicate-request check, not a lock on the data. // one: the guard is a duplicate-request check, not a lock on the data.
inFlight.clear(); inFlight.clear();
activeInFlight.clear();
}, },
// Archive methods // Archive methods
+10 -2
View File
@@ -65,13 +65,21 @@ describe('/join/[code] JoinClient', () => {
expect(source).not.toContain('glass-light') expect(source).not.toContain('glass-light')
}) })
it('describes open-answer visibility truthfully', async () => { it('describes comparison and open-answer visibility truthfully', async () => {
const source = await readFile(componentPath, 'utf8') const source = await readFile(componentPath, 'utf8')
expect(source).toContain('For open-answer questions, each response can be visible after you have both answered.') expect(source).toContain('Vote-style questions reveal shared outcomes')
expect(source).toContain('open text, audio, photo, and who-likely responses can be visible to you both after you have each answered.')
expect(source).not.toContain('Neither of you sees the other’s individual answers.') expect(source).not.toContain('Neither of you sees the other’s individual answers.')
}) })
it('marks the invite-code surface out of PostHog DOM capture and replay', async () => {
const source = await readFile(componentPath, 'utf8')
expect(source).toContain('data-ph-no-capture')
expect(source).toContain('data-ph-mask')
})
it('shows a clear fallback message for a malformed invite code', async () => { it('shows a clear fallback message for a malformed invite code', async () => {
const source = await readFile(componentPath, 'utf8') const source = await readFile(componentPath, 'utf8')
+3 -3
View File
@@ -124,10 +124,10 @@ export function JoinClient({ code }: { code: string }) {
const storeUnavailableTitle = isOnline ? undefined : 'Reconnect to open the app store.' const storeUnavailableTitle = isOnline ? undefined : 'Reconnect to open the app store.'
const desktopCopy = platform === 'desktop' const desktopCopy = platform === 'desktop'
? 'Sauci is a phone app. Copy your code, then open this link on your phone or install from the store there.' ? 'Sauci is a phone app. Copy your code, then open this link on your phone or install from the store there.'
: 'They have already answered questions about you two. Join them to discover what you have in common. For open-answer questions, each response can be visible after you have both answered.' : 'Join to compare answers together. Vote-style questions reveal shared outcomes; open text, audio, photo, and who-likely responses can be visible to you both after you have each answered.'
return ( return (
<main className="min-h-screen bg-gradient-to-b from-background-light to-background"> <main className="min-h-screen bg-gradient-to-b from-background-light to-background" data-ph-no-capture data-ph-mask>
<header className="py-6 px-6 border-b border-white/5"> <header className="py-6 px-6 border-b border-white/5">
<div className="max-w-4xl mx-auto flex items-center gap-4"> <div className="max-w-4xl mx-auto flex items-center gap-4">
<Link href="/" className="flex items-center gap-2 text-white/70 hover:text-white transition-colors"> <Link href="/" className="flex items-center gap-2 text-white/70 hover:text-white transition-colors">
@@ -150,7 +150,7 @@ export function JoinClient({ code }: { code: string }) {
<h1 className="text-3xl sm:text-4xl font-bold mb-3">You&apos;ve been invited to compare answers</h1> <h1 className="text-3xl sm:text-4xl font-bold mb-3">You&apos;ve been invited to compare answers</h1>
<p className="text-white/70 mb-10">{desktopCopy}</p> <p className="text-white/70 mb-10">{desktopCopy}</p>
<section className="surface-solid p-6 sm:p-8 mb-6" aria-labelledby="invite-code-heading"> <section className="surface-solid p-6 sm:p-8 mb-6" aria-labelledby="invite-code-heading" data-ph-no-capture data-ph-mask>
<h2 id="invite-code-heading" className="text-white/70 text-sm font-medium mb-3">Your invite code</h2> <h2 id="invite-code-heading" className="text-white/70 text-sm font-medium mb-3">Your invite code</h2>
<p <p
className="text-4xl sm:text-5xl font-bold tracking-[0.3em] text-primary mb-6" className="text-4xl sm:text-5xl font-bold tracking-[0.3em] text-primary mb-6"
+16
View File
@@ -0,0 +1,16 @@
import { readFile } from 'node:fs/promises'
import { describe, expect, it } from 'vitest'
const providerPath = new URL('./providers.tsx', import.meta.url)
describe('PostHogProvider invite privacy', () => {
it('disables DOM autocapture and session recording on invite routes', async () => {
const source = await readFile(providerPath, 'utf8')
expect(source).toContain("pathname?.startsWith('/join/')")
expect(source).toContain("window.location.pathname.startsWith('/join/')")
expect(source).toContain('autocapture: !isInvitePage')
expect(source).toContain('disable_session_recording: isInvitePage')
expect(source).toContain('mask_all_text: isInvitePage')
})
})
+29 -5
View File
@@ -4,6 +4,7 @@ import posthog from 'posthog-js'
import { PostHogProvider as PHProvider } from 'posthog-js/react' import { PostHogProvider as PHProvider } from 'posthog-js/react'
import type { CaptureResult } from 'posthog-js' import type { CaptureResult } from 'posthog-js'
import { useEffect } from 'react' import { useEffect } from 'react'
import { usePathname } from 'next/navigation'
const INVITE_PATH_PATTERN = /\/join\/[^/?#\s]+/g const INVITE_PATH_PATTERN = /\/join\/[^/?#\s]+/g
@@ -38,18 +39,41 @@ export function redactInvitePathsBeforeSend(event: CaptureResult | null): Captur
} }
export function PostHogProvider({ children }: { children: React.ReactNode }) { export function PostHogProvider({ children }: { children: React.ReactNode }) {
useEffect(() => { const pathname = usePathname()
if (typeof window !== 'undefined' && process.env.NEXT_PUBLIC_POSTHOG_KEY) {
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, { useEffect(() => {
if (typeof window !== 'undefined' && process.env.NEXT_PUBLIC_POSTHOG_KEY) {
const isInvitePage = window.location.pathname.startsWith('/join/')
posthog.init(process.env.NEXT_PUBLIC_POSTHOG_KEY, {
api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://eu.i.posthog.com', api_host: process.env.NEXT_PUBLIC_POSTHOG_HOST || 'https://eu.i.posthog.com',
person_profiles: 'identified_only', person_profiles: 'identified_only',
capture_pageview: false, capture_pageview: false,
capture_pageleave: false, capture_pageleave: false,
get_current_url: redactInviteUrl, get_current_url: redactInviteUrl,
before_send: redactInvitePathsBeforeSend, before_send: redactInvitePathsBeforeSend,
}) // This must be part of initialization, not only the navigation effect
// below, so an initially loaded invite page is protected before its DOM
// can be considered for capture or recording.
autocapture: !isInvitePage,
disable_session_recording: isInvitePage,
mask_all_text: isInvitePage,
})
} }
}, []) }, [])
useEffect(() => {
if (!process.env.NEXT_PUBLIC_POSTHOG_KEY) return
// Invite codes are credentials. Do not let their page enter PostHog's DOM
// autocapture or session-recording pipeline, even though event properties
// and URLs are separately redacted below.
const isInvitePage = pathname?.startsWith('/join/')
posthog.set_config({
autocapture: !isInvitePage,
disable_session_recording: isInvitePage,
mask_all_text: isInvitePage,
})
}, [pathname])
if (!process.env.NEXT_PUBLIC_POSTHOG_KEY) { if (!process.env.NEXT_PUBLIC_POSTHOG_KEY) {
return <>{children}</> return <>{children}</>