mirror of
https://github.com/jjbrunton/Sauci.git
synced 2026-10-31 18:19:43 +00:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d99192dbe | ||
|
|
e417f7f79a | ||
|
|
15c7894bec | ||
|
|
57c7ecebe6 | ||
|
|
17372a9bf0 |
No files matched your search
@@ -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
@@ -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>
|
||||
|
||||
Executable
+30
@@ -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);
|
||||
@@ -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
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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"
|
||||
```
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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/);
|
||||
});
|
||||
Reference in new issue
Block a user