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
+203 -60

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() {
const { user, couple, partner } = useAuthStore();
const { newMatchesCount, fetchMatches, error } = useMatchStore();
const { newMatchesCount, fetchMatches } = useMatchStore();
const [ready, setReady] = useState(false);
const [loadedSuccessfully, setLoadedSuccessfully] = useState(false);
const { role: rawRole } = useLocalSearchParams<{ role?: "inviter" | "joiner" }>();
const heading = useRef<Text>(null);
const role = rawRole === "inviter" ? "inviter" : "joiner";
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]);
useEffect(() => {
if (!ready || error || !user || !couple) return;
if (!ready || !loadedSuccessfully || !user || !couple) return;
void markPairedUnlockSeen(user.id, couple.id);
Events.pairingUnlockViewed(role, bucket(newMatchesCount));
const timer = setTimeout(() => {
if (heading.current) AccessibilityInfo.setAccessibilityFocus(heading.current as never);
}, 250);
return () => clearTimeout(timer);
}, [ready, error, user, couple, newMatchesCount]);
}, [ready, loadedSuccessfully, user, couple, newMatchesCount]);
const partnerName = partner?.name?.split(" ")[0] || "your partner";
const viewMatches = () => { Events.pairingUnlockCtaTapped(role, "view_matches"); router.replace("/(app)/matches"); };
@@ -40,8 +47,8 @@ export default function PairedScreen() {
if (!ready) {
return <GradientBackground><View style={styles.container}><ActivityIndicator size="large" color={colors.primary} /></View></GradientBackground>;
}
if (error) {
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>;
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).then((success) => { setLoadedSuccessfully(success); setReady(true); }); }} testID="paired-retry">Try again</GlassButton></View></GradientBackground>;
}
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>
+9 -9
View File
@@ -52,7 +52,7 @@ export default function PairingScreen() {
let cancelled = false;
void (async () => {
if (typeof params.incomingCode === "string") {
await clearPendingInviteCode();
await clearPendingInviteCode(user.id);
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)") }]);
}
@@ -86,15 +86,15 @@ export default function PairingScreen() {
const routeCode = typeof params.code === "string" ? normalizeInviteCode(params.code) : "";
if (isValidInviteCode(routeCode)) {
await applyPrefill(routeCode);
await clearPendingInviteCode();
await clearPendingInviteCode(user?.id);
if (!cancelled) setPrefillAttempted(true);
return;
}
const stashedCode = await getPendingInviteCode();
const stashedCode = user?.id ? await getPendingInviteCode(user.id) : null;
if (stashedCode) {
await applyPrefill(stashedCode);
await clearPendingInviteCode();
await clearPendingInviteCode(user?.id);
if (!cancelled) setPrefillAttempted(true);
return;
}
@@ -109,7 +109,7 @@ export default function PairingScreen() {
return () => {
cancelled = true;
};
}, [couple, params.code, prefillAttempted]);
}, [couple, params.code, prefillAttempted, user?.id]);
const acceptClipboardOffer = () => {
if (!clipboardOfferCode) return;
@@ -312,7 +312,7 @@ export default function PairingScreen() {
"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.",
[
{ 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 () => {
setIsSubmitting(true);
@@ -320,7 +320,7 @@ export default function PairingScreen() {
await cancelInvite(async () => {
setInviteCode(incomingCode);
setWasPrefilled(true);
await clearPendingInviteCode();
await clearPendingInviteCode(user?.id);
});
} finally {
setIsSubmitting(false);
@@ -369,7 +369,7 @@ export default function PairingScreen() {
<GlassButton onPress={handleCancelOwnAndPrefill} variant="danger" fullWidth disabled={isSubmitting} testID="pairing-conflict-cancel-and-join">
Cancel mine and join
</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>
</TouchableOpacity>
</GlassCard>
@@ -400,7 +400,7 @@ export default function PairingScreen() {
>
<GlassCard variant="elevated">
<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>
</GlassCard>
</Animated.View>
+2 -2
View File
@@ -134,12 +134,12 @@ export default function RootLayout() {
const { isAuthenticated, user } = useAuthStore.getState();
const onboardingPending = needsOnboarding(user?.onboarding_completed, user?.onboarding_version);
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) {
router.push({ pathname: "/(app)/pairing", params: { incomingCode: inviteLink.code } });
}
} else {
await stashPendingInviteCode(inviteLink.code);
await stashPendingInviteCode(inviteLink.code, user?.id ?? null);
if (isAuthenticated && !onboardingPending) {
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() {
const { isAuthenticated, isLoading, user } = useAuthStore();
const [pendingCodeChecked, setPendingCodeChecked] = useState(false);
const [hasPendingInvite, setHasPendingInvite] = useState(false);
const [pendingInviteCheck, setPendingInviteCheck] = useState<{ userId: string; hasInvite: boolean } | null>(null);
// 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).
@@ -18,16 +17,17 @@ export default function Index() {
}
let cancelled = false;
getPendingInviteCode().then((code) => {
if (!user?.id) return;
setPendingInviteCheck(null);
getPendingInviteCode(user.id).then((code) => {
if (cancelled) return;
setHasPendingInvite(!!code);
setPendingCodeChecked(true);
setPendingInviteCheck({ userId: user.id, hasInvite: !!code });
});
return () => {
cancelled = true;
};
}, [isLoading, isAuthenticated, user?.couple_id]);
}, [isLoading, isAuthenticated, user?.couple_id, user?.id]);
// Show loading state while auth is being determined
if (isLoading) {
@@ -56,7 +56,7 @@ export default function Index() {
// 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
// trip through the home screen.
if (!pendingCodeChecked) {
if (pendingInviteCheck?.userId !== user?.id) {
return (
<View style={styles.container}>
<ActivityIndicator size="large" color="#e94560" />
@@ -64,7 +64,7 @@ export default function Index() {
);
}
if (hasPendingInvite) {
if (pendingInviteCheck?.hasInvite) {
return <Redirect href="/(app)/pairing" />;
}
@@ -5,7 +5,8 @@ describe("buildInviteShareMessage", () => {
const message = buildInviteShareMessage("AB12CD34", 3);
expect(message).toContain("https://sauci.app/join/AB12CD34");
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", () => {
@@ -10,20 +10,40 @@ describe("pendingInviteCode", () => {
await AsyncStorage.clear();
});
it("stashes, retrieves, and clears a valid code", async () => {
await stashPendingInviteCode("abcd1234");
await expect(getPendingInviteCode()).resolves.toBe("ABCD1234");
it("stashes, retrieves, and clears a valid code for its owner", async () => {
await stashPendingInviteCode("abcd1234", "user-a");
await expect(getPendingInviteCode("user-a")).resolves.toBe("ABCD1234");
await clearPendingInviteCode();
await expect(getPendingInviteCode()).resolves.toBeNull();
await clearPendingInviteCode("user-a");
await expect(getPendingInviteCode("user-a")).resolves.toBeNull();
});
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 () => {
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;
(apiClient.get as jest.Mock).mockReturnValueOnce(new Promise(resolve => { release = resolve; }));
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);
release({ matches: [], totalCount: 0 });
await inFlight;
await overlapping;
(apiClient.get as jest.Mock).mockClear();
useMatchStore.setState({ hasMore: false });
@@ -61,6 +62,21 @@ describe('matchStore API behavior', () => {
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 () => {
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))];
+1 -1
View File
@@ -8,7 +8,7 @@ export function buildInviteShareMessage(inviteCode: string, sealedCount: number)
const link = `https://sauci.app/join/${inviteCode}`;
if (sealedCount > 0) {
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.`;
}
+50 -8
View File
@@ -6,31 +6,73 @@ import AsyncStorage from "@react-native-async-storage/async-storage";
import { isValidInviteCode, normalizeInviteCode } from "./inviteLink";
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 {
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) {
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 {
const value = await AsyncStorage.getItem(STORAGE_KEY);
if (value && isValidInviteCode(value)) {
return normalizeInviteCode(value);
return await withStorageLock(async () => {
const pending = parsePendingInviteCode(await AsyncStorage.getItem(STORAGE_KEY));
if (!pending || (pending.userId && pending.userId !== userId)) 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 null;
return pending.code;
});
} catch (error) {
console.error("Error reading pending invite code:", error);
return null;
}
}
export async function clearPendingInviteCode(): Promise<void> {
export async function clearPendingInviteCode(userId?: string): Promise<void> {
try {
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) {
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) {
await clearPairedUnlockSeen(unlockUserId, unlockCoupleId);
}
await clearPendingInviteCode();
await clearPendingInviteCode(unlockUserId);
// Clear local state FIRST to ensure UI updates even if Supabase call fails
set({
@@ -259,7 +259,7 @@ export const useAuthStore = create<AuthState>((set, get) => ({
});
// Clear other stores when user signs out
if (user === null) {
void clearPendingInviteCode();
void clearPendingInviteCode(priorUserId);
if (priorUserId) {
void clearPairedUnlockSeen(priorUserId, priorCoupleId);
}
+19 -10
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
// clear an entry a newer generation just claimed.
const inFlight = new Map<string, number>();
const activeInFlight = new Map<string, { generation: number; promise: Promise<boolean> }>();
// Rate limit: 12 hours in milliseconds
const NUDGE_COOLDOWN_MS = 12 * 60 * 60 * 1000;
@@ -80,7 +81,7 @@ interface MatchState {
*/
generation: number;
// Methods
fetchMatches: (refresh?: boolean, options?: FetchOptions) => Promise<void>;
fetchMatches: (refresh?: boolean, options?: FetchOptions) => Promise<boolean>;
markAsSeen: (matchId: string) => Promise<void>;
markAllAsSeen: () => Promise<void>;
addMatch: (match: Match) => void;
@@ -130,18 +131,20 @@ export const useMatchStore = create<MatchState>((set, get) => ({
isNudging: false,
generation: 0,
fetchMatches: async (refresh = false, options = {}) => {
fetchMatches: (refresh = false, options = {}) => {
const coupleId = useAuthStore.getState().user?.couple_id;
const myGeneration = get().generation;
// Early return if no couple - user isn't paired yet
if (!coupleId) {
set({ matches: [], newMatchesCount: 0, totalCount: 0, isLoading: false, isRefreshing: false });
return;
return Promise.resolve(false);
}
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
// 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) {
set({ isLoading: initial, isRefreshing: !initial && !options.silent, error: null, page: 0, hasMore: true });
} else {
if (!state.hasMore) return;
if (!state.hasMore) return Promise.resolve(false);
set({ isLoadingMore: true });
}
inFlight.set('active', myGeneration);
const request = (async (): Promise<boolean> => {
try {
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}`);
// A sign-out/account switch that happened while this request was in
// 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 totalCount = result.totalCount;
const archivedMatchIds = state.archivedMatchIds;
@@ -174,7 +177,7 @@ export const useMatchStore = create<MatchState>((set, get) => ({
} else {
set({ isLoadingMore: false, hasMore: false });
}
return;
return true;
}
const sortedData = nonArchivedMatches;
@@ -208,14 +211,19 @@ export const useMatchStore = create<MatchState>((set, get) => ({
loadedAt,
};
});
return true;
} catch (err) {
if (get().generation !== myGeneration) return;
if (get().generation !== myGeneration) return false;
console.error("Error fetching matches:", err);
set({ error: "Failed to load matches", isLoading: false, isRefreshing: false, isLoadingMore: false });
return false;
} finally {
// 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) => {
@@ -296,6 +304,7 @@ clearMatches: () => {
// 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.
inFlight.clear();
activeInFlight.clear();
},
// Archive methods
+10 -2
View File
@@ -65,13 +65,21 @@ describe('/join/[code] JoinClient', () => {
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')
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.')
})
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 () => {
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 desktopCopy = platform === 'desktop'
? '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 (
<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">
<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">
@@ -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>
<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>
<p
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')
})
})
+24
View File
@@ -4,6 +4,7 @@ import posthog from 'posthog-js'
import { PostHogProvider as PHProvider } from 'posthog-js/react'
import type { CaptureResult } from 'posthog-js'
import { useEffect } from 'react'
import { usePathname } from 'next/navigation'
const INVITE_PATH_PATTERN = /\/join\/[^/?#\s]+/g
@@ -38,8 +39,11 @@ export function redactInvitePathsBeforeSend(event: CaptureResult | null): Captur
}
export function PostHogProvider({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
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',
person_profiles: 'identified_only',
@@ -47,10 +51,30 @@ export function PostHogProvider({ children }: { children: React.ReactNode }) {
capture_pageleave: false,
get_current_url: redactInviteUrl,
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) {
return <>{children}</>
}