Compare commits

...
2 Commits
6 changed files with 250 additions and 14 deletions

No files matched your search

+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',
+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/);
});