From 7d99192dbe49e9e46fc14992c327b061b7fa73db Mon Sep 17 00:00:00 2001 From: jjbrunton Date: Mon, 21 Sep 2026 22:09:03 +0100 Subject: [PATCH] fix(ota): guard RevenueCat production exports --- apps/mobile/scripts/bin/sauci-eoas-npx | 30 ++++++ apps/mobile/scripts/ota-publish.mjs | 112 +++++++++++++++++++++-- apps/mobile/scripts/ota-publish.test.mjs | 89 +++++++++++++++++- docs/mobile-ota.md | 15 ++- docs/subscription-system.md | 5 + 5 files changed, 237 insertions(+), 14 deletions(-) create mode 100755 apps/mobile/scripts/bin/sauci-eoas-npx diff --git a/apps/mobile/scripts/bin/sauci-eoas-npx b/apps/mobile/scripts/bin/sauci-eoas-npx new file mode 100755 index 0000000..a9e1a5a --- /dev/null +++ b/apps/mobile/scripts/bin/sauci-eoas-npx @@ -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); diff --git a/apps/mobile/scripts/ota-publish.mjs b/apps/mobile/scripts/ota-publish.mjs index b2817b8..9d89c98 100644 --- a/apps/mobile/scripts/ota-publish.mjs +++ b/apps/mobile/scripts/ota-publish.mjs @@ -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 diff --git a/apps/mobile/scripts/ota-publish.test.mjs b/apps/mobile/scripts/ota-publish.test.mjs index 79df484..bd2ef9e 100644 --- a/apps/mobile/scripts/ota-publish.test.mjs +++ b/apps/mobile/scripts/ota-publish.test.mjs @@ -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', diff --git a/docs/mobile-ota.md b/docs/mobile-ota.md index d1b9f63..fe00d9f 100644 --- a/docs/mobile-ota.md +++ b/docs/mobile-ota.md @@ -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: " \ -H "expo-channel-name: production" \ - -H "expo-runtime-version: 1.0.7" \ + -H "expo-runtime-version: 1.0.8" \ -H "expo-platform: ios" ``` diff --git a/docs/subscription-system.md b/docs/subscription-system.md index f39ad9b..05286f4 100644 --- a/docs/subscription-system.md +++ b/docs/subscription-system.md @@ -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