5 Commits
14 changed files with 484 additions and 82 deletions

No files matched your search

+4 -1
View File
@@ -179,7 +179,10 @@ describe.skipIf(!url || !local)('dares + PostgreSQL', () => {
const dir = new URL('../drizzle/', import.meta.url);
try {
await migrate(drizzle(migrationPool), { migrationsFolder: fileURLToPath(dir) });
await migrate(drizzle(migrationPool), {
migrationsFolder: fileURLToPath(dir),
migrationsSchema: migrationSchema,
});
const discovered = await migrationPool.query<{ count: string }>(
"select count(*)::text as count from dares where id between '1f5e0000-0000-4000-8000-000000000001'::uuid and '1f5e0000-0000-4000-8000-000000000150'::uuid",
);
+15 -36
View File
@@ -21,7 +21,7 @@ import {
handleNotificationResponse,
} from "../src/lib/notifications";
import React, { useEffect, useRef, useState } from "react";
import React, { useEffect, useRef } from "react";
import { Platform, View, AppState, AppStateStatus, Modal, Text, StyleSheet, Pressable, TouchableOpacity } from "react-native";
// Suppress useLayoutEffect warning on server-side rendering
@@ -51,7 +51,7 @@ import * as Linking from "expo-linking";
import { useAuthStore } from "../src/store";
import { authClient } from "../src/lib/authClient";
import { colors, spacing, radius, typography } from "../src/theme";
import { hasSeenGuestAccountWarning, markGuestAccountWarningSeen } from "../src/lib/guestAccountWarningSeen";
import { useGuestAccountWarning } from "../src/hooks/useGuestAccountWarning";
import { parseInviteLinkCode } from "../src/lib/inviteLink";
import { stashPendingInviteCode } from "../src/lib/pendingInviteCode";
import { needsOnboarding } from "../src/constants/onboarding";
@@ -59,18 +59,17 @@ import { needsOnboarding } from "../src/constants/onboarding";
const queryClient = new QueryClient();
export default function RootLayout() {
const { fetchUser, setUser, isAuthenticated, isAnonymous } = useAuthStore();
const { fetchUser, setUser, user, isAuthenticated, isAnonymous } = useAuthStore();
const pathname = usePathname();
const appState = useRef(AppState.currentState);
const analyticsInitialized = useRef(false);
const [showGuestWarning, setShowGuestWarning] = useState(false);
const [checkingGuestWarning, setCheckingGuestWarning] = useState(false);
const dismissGuestWarning = async () => {
await markGuestAccountWarningSeen();
setShowGuestWarning(false);
};
const { visible: showGuestWarning, dismiss: dismissGuestWarning } = useGuestAccountWarning({
isAuthenticated,
isAnonymous,
userId: user?.id,
pathname,
});
// Initialize analytics after native modules are ready
@@ -90,22 +89,6 @@ export default function RootLayout() {
}
}, [pathname]);
// Warn guest users (first time only) that accounts are not recoverable
useEffect(() => {
if (!isAuthenticated || !isAnonymous) return;
if (checkingGuestWarning) return;
if (pathname?.startsWith("/(auth)")) return;
setCheckingGuestWarning(true);
hasSeenGuestAccountWarning()
.then((seen) => {
if (!seen) {
setShowGuestWarning(true);
}
})
.finally(() => setCheckingGuestWarning(false));
}, [isAuthenticated, isAnonymous, pathname, checkingGuestWarning]);
// Track warm starts when app comes back from background
useEffect(() => {
const subscription = AppState.addEventListener("change", (nextAppState: AppStateStatus) => {
@@ -313,22 +296,18 @@ export default function RootLayout() {
<View style={styles.guestWarningActions}>
<TouchableOpacity
style={styles.guestWarningPrimary}
onPress={async () => {
await markGuestAccountWarningSeen();
setShowGuestWarning(false);
router.push("/(app)/settings/save-account" as any);
style={styles.guestWarningPrimary}
onPress={async () => {
await dismissGuestWarning();
router.push("/(app)/settings/save-account" as any);
}}
activeOpacity={0.8}
>
<Text style={styles.guestWarningPrimaryText}>Save account</Text>
</TouchableOpacity>
<TouchableOpacity
style={styles.guestWarningSecondary}
onPress={async () => {
await markGuestAccountWarningSeen();
setShowGuestWarning(false);
}}
style={styles.guestWarningSecondary}
onPress={dismissGuestWarning}
activeOpacity={0.8}
>
<Text style={styles.guestWarningSecondaryText}>Not now</Text>
+30
View File
@@ -0,0 +1,30 @@
#!/usr/bin/env node
import { spawnSync } from 'node:child_process';
import { isAbsolute } from 'node:path';
const requiredProductionRevenueCatConfig = [
['EXPO_PUBLIC_REVENUECAT_IOS_API_KEY', 'appl_'],
['EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY', 'goog_'],
['EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID', null],
];
function fail(message) {
console.error(`EOAS Expo export guard: ${message}`);
process.exit(1);
}
for (const [key, prefix] of requiredProductionRevenueCatConfig) {
const value = process.env[key];
const expected = process.env[`SAUCI_EOAS_EXPECTED_${key}`];
if (typeof value !== 'string' || !value.trim()) fail(`${key} is required`);
if (prefix && !value.startsWith(prefix)) fail(`${key} must be a valid RevenueCat public key`);
if (value !== expected) fail(`${key} does not match the resolved production profile`);
}
const npx = process.env.SAUCI_EOAS_NPX;
if (!npx || !isAbsolute(npx)) fail('missing resolved npx executable');
const result = spawnSync(npx, process.argv.slice(2), { env: process.env, stdio: 'inherit' });
if (result.error) throw result.error;
process.exit(result.status ?? 1);
+103 -9
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env node
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
import { accessSync, constants, mkdtempSync, readdirSync, readFileSync, rmSync, statSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join, resolve } from 'node:path';
import { delimiter, join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { spawnSync } from 'node:child_process';
@@ -27,6 +27,13 @@ const requiredPublicKeys = [
'EXPO_PUBLIC_SUPABASE_URL',
'EXPO_PUBLIC_SUPABASE_ANON_KEY',
];
const requiredProductionRevenueCatConfig = [
['EXPO_PUBLIC_REVENUECAT_IOS_API_KEY', 'appl_'],
['EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY', 'goog_'],
['EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID', null],
];
const EOAS_PACKAGE_RUNNER = 'sauci-eoas-npx';
const EOAS_RUNNER_BIN_DIR = join(projectDir, 'scripts', 'bin');
function fail(message) {
throw new Error(`OTA publish preflight: ${message}`);
@@ -67,9 +74,87 @@ export function resolvePublishEnvironment(target, environment = process.env, con
requireCanonicalHttpsRoot(env.EXPO_PUBLIC_SUPABASE_URL, profile.authOrigin, `${target} Auth URL`);
requireCanonicalHttpsRoot(env.EXPO_PUBLIC_API_URL, profile.apiOrigin, `${target} API URL`);
if (target === 'production') {
for (const [key, prefix] of requiredProductionRevenueCatConfig) {
const value = env[key];
if (typeof value !== 'string' || !value.trim()) fail(`${key} is required for production export`);
if (prefix && !value.startsWith(prefix)) {
fail(`${key} must be a valid ${prefix} RevenueCat public key`);
}
}
}
return { branch: profile.branch, env };
}
function exportFiles(outputDir) {
return readdirSync(outputDir, { withFileTypes: true }).flatMap((entry) => {
const entryPath = join(outputDir, entry.name);
return entry.isDirectory() ? exportFiles(entryPath) : [entryPath];
});
}
/**
* Expo inlines EXPO_PUBLIC_* values into the JavaScript bundle. Verify the
* isolated production export rather than trusting only the process environment:
* a missing key otherwise becomes an empty string on installed devices.
*/
export function assertProductionExportContainsRevenueCatConfig(outputDir, environment) {
const files = exportFiles(outputDir).filter((filePath) => statSync(filePath).isFile());
for (const [key] of requiredProductionRevenueCatConfig) {
const expectedValue = environment[key];
const present = files.some((filePath) => readFileSync(filePath).includes(expectedValue));
if (!present) fail(`production export does not contain ${key}`);
}
}
function resolveExecutable(command, pathValue) {
for (const directory of pathValue.split(delimiter)) {
if (!directory) continue;
const candidate = join(directory, command);
try {
accessSync(candidate, constants.X_OK);
return candidate;
} catch {
// Continue looking through PATH.
}
}
fail(`could not resolve ${command} before configuring the EOAS package runner`);
}
/**
* EOAS starts its own Expo subprocess with EXPO_NO_DOTENV=1. Pin its package
* runner to our checked-in wrapper so the actual EOAS export fails before it
* starts if its inherited RevenueCat configuration differs from this profile.
*/
export function buildEoasEnvironment(environment) {
const originalPath = environment.PATH || process.env.PATH || '';
const expected = Object.fromEntries(requiredProductionRevenueCatConfig.map(([key]) => [
`SAUCI_EOAS_EXPECTED_${key}`,
environment[key],
]));
return {
...environment,
EXPO_NO_DOTENV: '1',
EOAS_PACKAGE_RUNNER,
PATH: `${EOAS_RUNNER_BIN_DIR}${delimiter}${originalPath}`,
SAUCI_EOAS_NPX: resolveExecutable('npx', originalPath),
...expected,
};
}
export function buildEoasPublishArgs(branch, rawPublishArgs, useProductionGuard = false) {
const args = [
'eoas',
'publish',
'--branch',
branch,
...parsePublishArgs(rawPublishArgs),
];
if (useProductionGuard) args.splice(4, 0, '--packageRunner', EOAS_PACKAGE_RUNNER);
return args;
}
export function parsePublishArgs(args) {
const parsed = [];
for (let index = 0; index < args.length; index += 1) {
@@ -112,6 +197,18 @@ function run(command, args, env) {
if (result.status !== 0) process.exit(result.status ?? 1);
}
function exportAndVerify(target, environment) {
const outputDir = mkdtempSync(join(tmpdir(), `sauci-ota-${target}-`));
try {
run('npx', ['expo', 'export', '--output-dir', outputDir], environment);
if (target === 'production') {
assertProductionExportContainsRevenueCatConfig(outputDir, environment);
}
} finally {
rmSync(outputDir, { recursive: true, force: true });
}
}
export function main(args = process.argv.slice(2), environment = process.env) {
const [target, ...remaining] = args;
const preflight = remaining.includes('--preflight');
@@ -122,12 +219,7 @@ export function main(args = process.argv.slice(2), environment = process.env) {
const { branch, env } = resolvePublishEnvironment(target, environment);
const exportEnv = { ...env, EXPO_NO_DOTENV: '1' };
if (preflight) {
const outputDir = mkdtempSync(join(tmpdir(), `sauci-ota-${target}-`));
try {
run('npx', ['expo', 'export', '--output-dir', outputDir], exportEnv);
} finally {
rmSync(outputDir, { recursive: true, force: true });
}
exportAndVerify(target, exportEnv);
console.log(`OTA ${target} export preflight passed`);
return;
}
@@ -138,7 +230,9 @@ export function main(args = process.argv.slice(2), environment = process.env) {
return;
}
run('npx', ['eoas', 'publish', '--branch', branch, ...parsePublishArgs(rawPublishArgs)], exportEnv);
exportAndVerify(target, exportEnv);
const eoasEnvironment = target === 'production' ? buildEoasEnvironment(exportEnv) : exportEnv;
run('npx', buildEoasPublishArgs(branch, rawPublishArgs, target === 'production'), eoasEnvironment);
}
// Compare real filesystem paths. A URL pathname percent-encodes spaces, so
+88 -1
View File
@@ -1,6 +1,17 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { parsePublishArgs, resolvePublishEnvironment } from './ota-publish.mjs';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
assertProductionExportContainsRevenueCatConfig,
buildEoasEnvironment,
buildEoasPublishArgs,
parsePublishArgs,
resolvePublishEnvironment,
} from './ota-publish.mjs';
import { spawnSync } from 'node:child_process';
import { fileURLToPath } from 'node:url';
const config = {
build: {
@@ -9,6 +20,9 @@ const config = {
EXPO_PUBLIC_API_URL: 'https://api.sauci.app',
EXPO_PUBLIC_SUPABASE_URL: 'https://ckjcrkjpmhqhiucifukx.supabase.co',
EXPO_PUBLIC_SUPABASE_ANON_KEY: 'production-anon-key',
EXPO_PUBLIC_REVENUECAT_IOS_API_KEY: 'appl_test_ios_public_key',
EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY: 'goog_test_android_public_key',
EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID: 'Sauci Pro',
},
},
preview: {
@@ -28,6 +42,79 @@ test('production export uses the checked-in production profile', () => {
assert.equal(env.EXPO_PUBLIC_API_URL, 'https://api.sauci.app');
});
test('production export fails closed without valid RevenueCat public configuration', () => {
for (const [key, value] of [
['EXPO_PUBLIC_REVENUECAT_IOS_API_KEY', ''],
['EXPO_PUBLIC_REVENUECAT_IOS_API_KEY', 'goog_wrong_platform_key'],
['EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY', ''],
['EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY', 'appl_wrong_platform_key'],
['EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID', ''],
]) {
const productionEnv = { ...config.build.production.env, [key]: value };
assert.throws(
() => resolvePublishEnvironment('production', {}, {
build: { ...config.build, production: { env: productionEnv } },
}),
new RegExp(key),
);
}
});
test('production preflight proves the compiled bundle contains RevenueCat configuration', () => {
const outputDir = mkdtempSync(join(tmpdir(), 'sauci-ota-export-test-'));
const environment = config.build.production.env;
try {
writeFileSync(
join(outputDir, 'index.js'),
[
environment.EXPO_PUBLIC_REVENUECAT_IOS_API_KEY,
environment.EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY,
environment.EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID,
].join(','),
);
assert.doesNotThrow(() => assertProductionExportContainsRevenueCatConfig(outputDir, environment));
writeFileSync(join(outputDir, 'index.js'), environment.EXPO_PUBLIC_REVENUECAT_IOS_API_KEY);
assert.throws(
() => assertProductionExportContainsRevenueCatConfig(outputDir, environment),
/EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY/,
);
} finally {
rmSync(outputDir, { recursive: true, force: true });
}
});
test('EOAS uses a guarded package runner that receives the resolved production environment', () => {
const eoasEnvironment = buildEoasEnvironment({ ...config.build.production.env, PATH: process.env.PATH });
assert.equal(eoasEnvironment.EOAS_PACKAGE_RUNNER, 'sauci-eoas-npx');
assert.match(eoasEnvironment.PATH, /apps\/mobile\/scripts\/bin/);
assert.equal(eoasEnvironment.SAUCI_EOAS_EXPECTED_EXPO_PUBLIC_REVENUECAT_ENTITLEMENT_ID, 'Sauci Pro');
const runnerPath = fileURLToPath(new URL('./bin/sauci-eoas-npx', import.meta.url));
const successful = spawnSync(process.execPath, [runnerPath, 'expo', 'export'], {
env: { ...eoasEnvironment, SAUCI_EOAS_NPX: '/usr/bin/true' },
encoding: 'utf8',
});
assert.equal(successful.status, 0, successful.stderr);
const missingKey = spawnSync(process.execPath, [runnerPath, 'expo', 'export'], {
env: { ...eoasEnvironment, EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY: '', SAUCI_EOAS_NPX: '/usr/bin/true' },
encoding: 'utf8',
});
assert.equal(missingKey.status, 1);
assert.match(missingKey.stderr, /EXPO_PUBLIC_REVENUECAT_ANDROID_API_KEY/);
assert.doesNotMatch(missingKey.stderr, /goog_test_android_public_key/);
assert.deepEqual(
buildEoasPublishArgs('production', ['--platform', 'all'], true),
['eoas', 'publish', '--branch', 'production', '--packageRunner', 'sauci-eoas-npx', '--platform', 'all'],
);
assert.deepEqual(
buildEoasPublishArgs('staging', ['--platform', 'ios']),
['eoas', 'publish', '--branch', 'staging', '--platform', 'ios'],
);
});
test('staging export requires the designated non-production Auth configuration', () => {
const environment = {
EXPO_PUBLIC_SUPABASE_URL: 'https://itbzhrvlgvdmzbnhzhyx.supabase.co',
@@ -0,0 +1,68 @@
import { act, renderHook, waitFor } from "@testing-library/react-native";
import { hasSeenGuestAccountWarning, markGuestAccountWarningSeen } from "@/lib/guestAccountWarningSeen";
import { useGuestAccountWarning } from "@/hooks/useGuestAccountWarning";
jest.mock("@/lib/guestAccountWarningSeen", () => ({
hasSeenGuestAccountWarning: jest.fn(),
markGuestAccountWarningSeen: jest.fn(),
}));
const options = {
isAuthenticated: true,
isAnonymous: true,
userId: "guest-1",
pathname: "/(app)/pairing",
};
describe("useGuestAccountWarning", () => {
beforeEach(() => {
jest.mocked(hasSeenGuestAccountWarning).mockReset();
jest.mocked(markGuestAccountWarningSeen).mockReset();
});
it("does not reopen after a dismissal while the durable write is pending", async () => {
let finishMarking: (() => void) | undefined;
jest.mocked(hasSeenGuestAccountWarning).mockResolvedValue(false);
jest.mocked(markGuestAccountWarningSeen).mockImplementation(() => new Promise<void>((resolve) => {
finishMarking = resolve;
}));
const { result, rerender } = renderHook((props: typeof options) => useGuestAccountWarning(props), { initialProps: options });
await waitFor(() => expect(result.current.visible).toBe(true));
let dismissal: Promise<void> | undefined;
act(() => {
dismissal = result.current.dismiss();
});
expect(result.current.visible).toBe(false);
rerender({ ...options, pathname: "/(app)/settings" });
expect(result.current.visible).toBe(false);
expect(hasSeenGuestAccountWarning).toHaveBeenCalledTimes(1);
finishMarking?.();
await act(async () => {
await dismissal;
});
});
it("checks once per anonymous account and can warn a later guest account", async () => {
jest.mocked(hasSeenGuestAccountWarning).mockResolvedValue(false);
const { result, rerender } = renderHook((props: typeof options) => useGuestAccountWarning(props), { initialProps: options });
await waitFor(() => expect(result.current.visible).toBe(true));
rerender({ ...options, pathname: "/(app)/settings" });
expect(hasSeenGuestAccountWarning).toHaveBeenCalledTimes(1);
await act(async () => {
await result.current.dismiss();
});
rerender({ ...options, userId: "guest-2" });
await waitFor(() => expect(hasSeenGuestAccountWarning).toHaveBeenCalledWith("guest-2"));
await waitFor(() => expect(result.current.visible).toBe(true));
});
});
@@ -0,0 +1,20 @@
import { hasSeenGuestAccountWarning, markGuestAccountWarningSeen } from "@/lib/guestAccountWarningSeen";
import AsyncStorage from "@react-native-async-storage/async-storage";
describe("guestAccountWarningSeen", () => {
beforeEach(() => {
return AsyncStorage.clear();
});
it("persists a dismissal for that anonymous account", async () => {
await markGuestAccountWarningSeen("guest-1");
await expect(hasSeenGuestAccountWarning("guest-1")).resolves.toBe(true);
});
it("does not suppress the recovery warning for a later guest account", async () => {
await markGuestAccountWarningSeen("guest-1");
await expect(hasSeenGuestAccountWarning("guest-2")).resolves.toBe(false);
});
});
@@ -0,0 +1,68 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { hasSeenGuestAccountWarning, markGuestAccountWarningSeen } from "../lib/guestAccountWarningSeen";
type GuestAccountWarningOptions = {
isAuthenticated: boolean;
isAnonymous: boolean;
userId: string | null | undefined;
pathname: string | null;
};
/**
* Shows the account recovery warning at most once for each anonymous account
* during a mounted app session. A dismissal is applied in memory before the
* durable write completes, so a slow storage operation cannot reopen it.
*/
export function useGuestAccountWarning({
isAuthenticated,
isAnonymous,
userId,
pathname,
}: GuestAccountWarningOptions) {
const [visible, setVisible] = useState(false);
const checkedUserIds = useRef(new Set<string>());
const dismissedUserIds = useRef(new Set<string>());
const isEligible = Boolean(
isAuthenticated
&& isAnonymous
&& userId
&& !pathname?.startsWith("/(auth)"),
);
useEffect(() => {
if (!isEligible || !userId) {
return;
}
if (checkedUserIds.current.has(userId) || dismissedUserIds.current.has(userId)) {
return;
}
checkedUserIds.current.add(userId);
let isCurrent = true;
void hasSeenGuestAccountWarning(userId).then((seen) => {
if (isCurrent && !seen && !dismissedUserIds.current.has(userId)) {
setVisible(true);
}
});
return () => {
isCurrent = false;
};
}, [isEligible, userId]);
const dismiss = useCallback(async () => {
if (!userId) {
setVisible(false);
return;
}
dismissedUserIds.current.add(userId);
setVisible(false);
await markGuestAccountWarningSeen(userId);
}, [userId]);
return { visible: isEligible && visible, dismiss };
}
+13 -29
View File
@@ -1,47 +1,31 @@
import * as SecureStore from "expo-secure-store";
import { Platform } from "react-native";
import AsyncStorage from "@react-native-async-storage/async-storage";
const STORAGE_KEY = "guest_account_recovery_warning_seen";
const STORAGE_KEY_PREFIX = "guest_account_recovery_warning_seen";
async function getStorage(): Promise<boolean> {
const buildKey = (userId: string): string => `${STORAGE_KEY_PREFIX}_${userId}`;
async function getStorage(key: string): Promise<boolean> {
try {
let data: string | null = null;
if (Platform.OS === "web") {
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
data = window.localStorage.getItem(STORAGE_KEY);
}
} else {
data = await SecureStore.getItemAsync(STORAGE_KEY);
}
return data === "true";
return (await AsyncStorage.getItem(key)) === "true";
} catch (error) {
console.error("Error reading guest warning state:", error);
}
return false;
}
async function setStorage(seen: boolean): Promise<void> {
async function setStorage(key: string, seen: boolean): Promise<void> {
try {
const data = seen ? "true" : "false";
if (Platform.OS === "web") {
if (typeof window !== "undefined" && typeof window.localStorage !== "undefined") {
window.localStorage.setItem(STORAGE_KEY, data);
}
} else {
await SecureStore.setItemAsync(STORAGE_KEY, data);
}
await AsyncStorage.setItem(key, seen ? "true" : "false");
} catch (error) {
console.error("Error saving guest warning state:", error);
}
}
export async function hasSeenGuestAccountWarning(): Promise<boolean> {
return getStorage();
/** The recovery warning is shown at most once for each anonymous account. */
export async function hasSeenGuestAccountWarning(userId: string): Promise<boolean> {
return getStorage(buildKey(userId));
}
export async function markGuestAccountWarningSeen(): Promise<void> {
await setStorage(true);
export async function markGuestAccountWarningSeen(userId: string): Promise<void> {
await setStorage(buildKey(userId), true);
}
+31 -1
View File
@@ -1,6 +1,7 @@
import { readFile } from 'node:fs/promises'
import { describe, expect, it } from 'vitest'
import { redactInvitePaths } from './providers'
import type { CapturedNetworkRequest } from 'posthog-js'
import { redactInviteNetworkRequest, redactInvitePaths } from './providers'
const pageViewPath = new URL('./PostHogPageView.tsx', import.meta.url)
const providerPath = new URL('./providers.tsx', import.meta.url)
@@ -20,6 +21,7 @@ describe('PostHog invite privacy boundary', () => {
expect(source).toContain('capture_pageleave: false')
expect(source).toContain('get_current_url: redactInviteUrl')
expect(source).toContain('before_send: redactInvitePathsBeforeSend')
expect(source).toContain('maskCapturedNetworkRequestFn: redactInviteNetworkRequest')
expect(source).toContain("url.pathname.startsWith('/join/')")
expect(source).toContain('/join/[invite]')
})
@@ -43,4 +45,32 @@ describe('PostHog invite privacy boundary', () => {
expect(serialized).toContain('/join/[invite]')
expect(serialized).toContain('join_page_viewed')
})
it('redacts invite codes from replay network request URLs, headers, and bodies without mutating the capture', () => {
const inviteCode = 'A4K9BT2Q'
const request = {
name: `https://sauci.app/join/${inviteCode}`,
entryType: 'resource',
startTime: 1,
duration: 2,
requestHeaders: {
referer: `https://sauci.app/join/${inviteCode}`,
},
requestBody: JSON.stringify({ invite_path: `/join/${inviteCode}` }),
responseHeaders: {
location: `https://sauci.app/join/${inviteCode}`,
},
responseBody: JSON.stringify({ referrer: `https://sauci.app/join/${inviteCode}` }),
} as CapturedNetworkRequest
const redacted = redactInviteNetworkRequest(request)
const serialized = JSON.stringify(redacted)
expect(redacted).not.toBe(request)
expect(redacted.requestHeaders).not.toBe(request.requestHeaders)
expect(redacted.responseHeaders).not.toBe(request.responseHeaders)
expect(serialized).not.toContain(inviteCode)
expect(serialized).toContain('/join/[invite]')
expect(JSON.stringify(request)).toContain(inviteCode)
})
})
+15 -1
View File
@@ -2,7 +2,7 @@
import posthog from 'posthog-js'
import { PostHogProvider as PHProvider } from 'posthog-js/react'
import type { CaptureResult } from 'posthog-js'
import type { CapturedNetworkRequest, CaptureResult } from 'posthog-js'
import { useEffect } from 'react'
import { usePathname } from 'next/navigation'
@@ -38,6 +38,17 @@ export function redactInvitePathsBeforeSend(event: CaptureResult | null): Captur
return event ? redactInvitePaths(event) : null
}
export function redactInviteNetworkRequest(request: CapturedNetworkRequest): CapturedNetworkRequest {
return {
...request,
name: redactInvitePaths(request.name),
requestHeaders: request.requestHeaders ? redactInvitePaths({ ...request.requestHeaders }) : request.requestHeaders,
requestBody: redactInvitePaths(request.requestBody),
responseHeaders: request.responseHeaders ? redactInvitePaths({ ...request.responseHeaders }) : request.responseHeaders,
responseBody: redactInvitePaths(request.responseBody),
}
}
export function PostHogProvider({ children }: { children: React.ReactNode }) {
const pathname = usePathname()
@@ -51,6 +62,9 @@ export function PostHogProvider({ children }: { children: React.ReactNode }) {
capture_pageleave: false,
get_current_url: redactInviteUrl,
before_send: redactInvitePathsBeforeSend,
session_recording: {
maskCapturedNetworkRequestFn: redactInviteNetworkRequest,
},
// 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.
+11 -4
View File
@@ -51,7 +51,7 @@ Channels. The client cannot create them.
`apps/mobile/app.config.js` sets an explicit runtime string:
```js
runtimeVersion: '1.0.7'
runtimeVersion: '1.0.8'
```
The release version script updates this string with `expo.version` and the
@@ -91,7 +91,7 @@ Resolved configuration:
| `updates.requestHeaders['expo-channel-name']` | `process.env.RELEASE_CHANNEL`, defaulting to `development` |
| `updates.requestHeaders['expo-app-id']` | `process.env.XPREM_APP_ID` |
| `updates.requestHeaders['xprem-branch']` | `''` |
| `runtimeVersion` | Explicit public version, currently `1.0.7` |
| `runtimeVersion` | Explicit public version, currently `1.0.8` |
`expo-updates` only sends request headers that were declared at build time, so
`xprem-branch` is declared even though it is empty. Removing it would not disable
@@ -176,6 +176,8 @@ dashboard and supplied as `EOO_TOKEN`; it is a secret and must not be committed.
cd apps/mobile
# Verify the exact export environment without contacting the update server.
# Production preflight fails closed unless the compiled bundle contains both
# RevenueCat platform public keys and the entitlement identifier.
npm run ota:preflight:production
# Staging: consumed by builds made with the EAS `preview` profile.
@@ -189,7 +191,12 @@ The scripts resolve the matching EAS build profile into EOAS's export subprocess
EOAS sets `EXPO_NO_DOTENV=1`, so invoking `eoas publish` directly does not load
profile values from `eas.json`. The wrapper injects the selected profile's public
configuration, including the required API and hosted Auth values, sets the
matching `RELEASE_CHANNEL`, and fixes the target branch. It does not print
matching `RELEASE_CHANNEL`, and fixes the target branch. Before each production
publish, it makes an isolated Expo export and fails unless that compiled bundle
contains the iOS and Android RevenueCat public SDK keys plus the entitlement
identifier. EOAS is also forced through a checked-in package-runner guard, which
receives the resolved profile environment and rejects a missing, malformed, or
changed RevenueCat value before EOAS runs its own Expo export. It does not print
configuration values.
Production public API/Auth values come from the checked-in production profile.
@@ -230,7 +237,7 @@ production deploy and none of the review latency.
curl -sD - "https://ota.apps.jbrunton.co.uk/manifest" \
-H "expo-app-id: <xprem-app-id>" \
-H "expo-channel-name: production" \
-H "expo-runtime-version: 1.0.7" \
-H "expo-runtime-version: 1.0.8" \
-H "expo-platform: ios"
```
+5
View File
@@ -216,6 +216,11 @@ The `get_recommended_questions()` function also filters premium packs:
> `isProUser: false`, and paying users silently appear unsubscribed with no error.
> Confirm the variable is set on every iOS and Android build profile.
Production OTA publishes also fail before upload unless an isolated compiled
Expo bundle contains both platform public SDK keys and this entitlement
identifier. Run `npm run ota:preflight:production` from `apps/mobile`; it
validates presence only and never prints those values.
## Subscription Flow Diagrams
### Purchase Flow
+13
View File
@@ -0,0 +1,13 @@
import assert from 'node:assert/strict';
import { readFile } from 'node:fs/promises';
import test from 'node:test';
import { fileURLToPath } from 'node:url';
const launcherPath = fileURLToPath(new URL('./dev-local.sh', import.meta.url));
test('native launcher retains a UTF-8 locale after sanitizing its environment', async () => {
const launcher = await readFile(launcherPath, 'utf8');
const nativeCommand = launcher.slice(launcher.indexOf('cmd_native() {'), launcher.indexOf('\ncase "${1:-up}" in'));
assert.match(nativeCommand, /env -i[\s\S]*LANG=en_US\.UTF-8 LC_ALL=en_US\.UTF-8/);
});