scan: remote config, feature flags and remote paywalls not told to review (App Review 2.3.1, Play Deceptive behavior): remote-features finding for both stores, medium, with deep links, unless the review notes mention them; check prints it as a warning, validate carries it; warn only, the templates get a commented example line; code obfuscators a low note, minify alone nothing; store-pitfalls 74

This commit is contained in:
CRTkafa 2026-10-18 01:26:32 +08:00
parent 9ba97b7e29
commit 61cb3338e8
13 changed files with 258 additions and 6 deletions

View file

@ -2,6 +2,14 @@
## Unreleased
- **Remote config and remote paywalls (App Review 2.3.1, Play Deceptive behavior).** `storebound scan` (and
`validate`; `check` as a warning) reports `remote-features` for both stores when the app ships a remote config /
feature flag SDK (Firebase Remote Config, Statsig, LaunchDarkly, GrowthBook, Optimizely, PostHog feature flags,
Unleash, ConfigCat, Amplitude Experiment, Split) or a remote paywall (RevenueCat Paywalls, Superwall, Adapty,
Qonversion, Purchasely, Apphud) and the review notes do not mention it. Warn only: storebound never writes the note;
the config templates carry a commented example line. Code obfuscators are a low note, minify alone nothing
(store-pitfalls 74).
## 0.8.11 · 2026-10-17
- **Guest-play apps rejected as a login wall (Play).** `reviewAccess.instructions` with `signInRequired: false` holds

View file

@ -24,7 +24,7 @@ import { credentialStatus, reviewDemoAccount } from './lib/credentials';
import { log, printJson, warn } from './lib/log';
import { profileIssues } from './lib/profile';
import { detectProject, grepOwn, where } from './lib/scan/project';
import { SIGNALS } from './lib/scan/rules';
import { remoteFeatureFindings, SIGNALS } from './lib/scan/rules';
import { reviewShotProblem } from './apple/iap-review';
import { allSubscriptions } from './lib/subscriptions';
import { type CopyFile, validateCopy } from './shots/lib';
@ -214,6 +214,14 @@ export async function run(ctx: Ctx): Promise<void> {
for (const i of profileIssues(config, { demoAccount: !!reviewDemoAccount(ctx.loaded.root), firstRunGate })) {
if (i.level === 'error') problems.push(i.msg); else warn('check', i.msg);
}
// remote config / feature flags / remote paywalls not told to review (2.3.1, Play Deceptive behavior): a warning only
if (config.apple || config.play) {
try {
const f = facts ?? detectProject(ctx.loaded.root);
for (const r of remoteFeatureFindings(f, config, { apple: !!config.apple, play: !!config.play }))
if (r.severity !== 'low' && !(config.scan?.ignore ?? []).some(i => i.rule === r.rule && (!i.store || i.store === r.store))) note(`${r.store}: ${r.title} (${r.guideline}; ${r.evidence.map(where).join(', ')}). ${r.fix}`);
} catch { /* no project sources here */ }
}
// Play steps without an API: the App category (Store settings) and the App content answers the owner still owes
const pc = playConsoleReminders(config);
for (const w of pc.warnings) note(w);

File diff suppressed because one or more lines are too long

View file

@ -44,7 +44,8 @@ import { itms91064, LISTED_SDKS, REQUIRED_REASON_APIS, trackingIssue, trackingPr
import { IOS_PERMISSIONS, vaguePurpose } from './permissions';
import { playRules } from './play-rules';
import { grep, grepOwn, type Hit, lineOf, type ProjectFacts } from './project';
import { versionAtLeast } from './sdks';
import { matchesSdk, OBFUSCATORS, REMOTE_CODE, REMOTE_SDKS, type RemoteSdkDef, versionAtLeast } from './sdks';
import { reviewNotes } from '../review-details';
export type Severity = 'blocker' | 'high' | 'medium' | 'low';
export type ScanStore = 'apple' | 'play';
@ -199,6 +200,54 @@ function listingHits(file: { path: string; text: string } | undefined, re: RegEx
return out;
}
/** Review notes that already tell App Review about remote config, flags, experiments or remotely changed paywalls. */
export const REMOTE_NOTE = /remote[ -]?config|feature[ -]?flags?|\bA\/B\b|paywall (tests?|experiments?|designs?)|experiments?\b|remotely/i;
/**
* App Review 2.3.1 (hidden, dormant or undocumented features) and Play Deceptive Behavior: remote config / feature
* flag SDKs and remote paywalls in the dependencies or own sources, unless the review notes (apple.info.reviewNotes,
* reviewAccess.instructions, which also go to Play App access, accounts.inApp) mention them. Warns only: storebound
* never writes the note; whether and how to document it is the developer's call. Code obfuscators get a low note
* (minify / R8 alone never). Pure.
*/
export function remoteFeatureFindings(p: Pick<ProjectFacts, 'deps' | 'sources' | 'builtDirs'>, c: ShipConfig | null, stores: { apple: boolean; play: boolean }): ScanFinding[] {
const out: ScanFinding[] = [];
const found = new Map<string, { name: string; kind: RemoteSdkDef['kind']; hits: Hit[] }>();
const put = (id: string, name: string, kind: RemoteSdkDef['kind'], hits: Hit[]) => {
if (!hits.length) return;
const e = found.get(id) ?? { name, kind, hits: [] };
e.hits.push(...hits);
found.set(id, e);
};
for (const s of REMOTE_SDKS) {
const deps = p.deps.filter(d => matchesSdk(d, s));
if (!deps.length) continue;
if (s.needsCode && !grepOwn(p, codeLine(s.needsCode), 1).hits.length) continue;
put(s.id, s.name, s.kind, deps.slice(0, 2).map(d => ({ file: d.file, line: d.line, text: `${s.name}: ${d.name}${d.version ? ` ${d.version}` : ''}` })));
}
for (const s of REMOTE_CODE) if (!found.has(s.id)) put(s.id, s.name, s.kind, grepOwn(p, codeLine(s.re), 1).hits);
if (found.size && !REMOTE_NOTE.test(c ? reviewNotes(c) : '')) {
const list = [...found.values()];
const names = list.map(x => x.name).join(', ');
const paywall = list.some(x => x.kind === 'paywall');
const what = paywall && list.some(x => x.kind === 'remote-config') ? 'remote config / feature flags and remote paywalls' : paywall ? 'remote paywalls' : 'remote config / feature flags';
const evidence = list.flatMap(x => x.hits.slice(0, 1)).slice(0, 4);
const fix = (where: string) => `Say in the review notes that paywall designs / prices shown / these flags change remotely (${names}), and document any feature that can be switched on or off remotely; a change of business model (soft -> hard paywall, features moved behind a subscription) needs a new review and a description update (paid functionality must be disclosed). Paywall tests and designs alone are fine once App Review knows. ${where}`;
if (stores.apple)
out.push({ rule: 'remote-features', guideline: '2.3.1', store: 'apple', severity: 'medium', title: `${what} (${names}) not mentioned in the App Review notes`, evidence,
fix: fix(`Notes: apple.info.reviewNotes, sent with storebound apple review-details; ${consoleLink('asc.version', '> App Review Information')}.`), url: consoleUrl('asc.version'), skill: 'review-rejections', ref: APPLE_GUIDELINES });
if (stores.play)
out.push({ rule: 'remote-features', guideline: 'Play: Deceptive behavior', store: 'play', severity: 'medium', title: `${what} (${names}) not mentioned to Play review`, evidence,
fix: fix(`Play: reviewAccess.instructions go to ${consoleLink('play.app-access')} ("Any other information"), and the listing description must match what the app does.`), url: consoleUrl('play.app-access'), skill: 'review-rejections', ref: PLAY_POLICY });
}
const obf = OBFUSCATORS.flatMap(o => p.deps.filter(d => matchesSdk(d, o)).slice(0, 1).map(d => ({ file: d.file, line: d.line, text: `${o.name}: ${d.name}` })));
if (obf.length) for (const store of (['apple', 'play'] as const).filter(s => stores[s]))
out.push({ rule: 'obfuscation', guideline: store === 'apple' ? '2.3.1' : 'Play: Deceptive behavior', store, severity: 'low', title: 'Code obfuscator in the build: fine for protecting code, never for hiding features from review', evidence: obf.slice(0, 3),
fix: 'Note only: obfuscating code is allowed, hiding behaviour with it is not (Apple 2.3.1 names obfuscation as grounds for removal). Make sure every feature the app has is visible to the reviewer and in the review notes. ProGuard / R8 minify alone is normal.',
skill: 'review-rejections', ref: store === 'apple' ? APPLE_GUIDELINES : PLAY_POLICY });
return out;
}
export function scanRules(inp: ScanInput): ScanFinding[] {
const { facts: p, config: c } = inp;
const out: ScanFinding[] = [];
@ -587,6 +636,9 @@ export function scanRules(inp: ScanInput): ScanFinding[] {
fix: 'Pick your own reverse-domain package name (it can never change after the first upload) and set it in the Gradle applicationId and play.packageName.', skill: 'play-first-release',
ref: 'https://developer.android.com/build/configure-app-module' });
// ---- remotely switchable features, remote paywalls, obfuscation (2.3.1, Play Deceptive behavior) ----
out.push(...remoteFeatureFindings(p, c, { apple, play }));
// ---- the other Google Play policies (play-rules.ts) ----
if (play)
playRules(inp, { add: f => add({ ...f, store: 'play' }), cfg, sdkHits, listingHits, creates, restorable, paySdk });

View file

@ -181,11 +181,80 @@ export const SDKS: SdkDef[] = [
match: { npm: ['@capacitor/push-notifications', 'expo-notifications', '@react-native-community/push-notification-ios', 'react-native-push-notification'], pub: ['flutter_local_notifications'], unity: ['com.unity.mobile.notifications'] } },
];
// ---- remotely switchable features and remote paywalls (App Review 2.3.1, Play Deceptive Behavior) ----
// Not in SDKS: they add no data types of their own to the declarations; scan only asks that App Review hears about
// them. Checked 2026-10-18 against each vendor's install docs.
export interface RemoteSdkDef {
id: string;
name: string;
/** remote-config: feature flags / remote config / experiments; paywall: paywall designs, offers or prices set remotely. */
kind: 'remote-config' | 'paywall';
match: Partial<Record<Eco, string[]>>;
/** Only with this in the own sources (an SDK that also does other things, e.g. PostHog analytics). */
needsCode?: RegExp;
docs: string;
}
export const REMOTE_SDKS: RemoteSdkDef[] = [
{ id: 'firebase-remote-config', name: 'Firebase Remote Config', kind: 'remote-config', docs: 'https://firebase.google.com/docs/remote-config',
match: { cocoapods: ['FirebaseRemoteConfig', 'Firebase/RemoteConfig'], spm: ['firebase-ios-sdk#FirebaseRemoteConfig'], gradle: ['com.google.firebase:firebase-config', 'com.google.firebase:firebase-config-ktx'],
npm: ['@react-native-firebase/remote-config', '@capacitor-firebase/remote-config'], pub: ['firebase_remote_config'], unity: ['com.google.firebase.remote-config', 'FirebaseRemoteConfig'] } },
{ id: 'statsig', name: 'Statsig', kind: 'remote-config', docs: 'https://docs.statsig.com/',
match: { cocoapods: ['Statsig'], spm: ['statsig-kit'], gradle: ['com.statsig:android-sdk'], npm: ['statsig-react-native', 'statsig-js', '@statsig/js-client', '@statsig/react-native-bindings', '@statsig/expo-bindings'], pub: ['statsig'] } },
{ id: 'launchdarkly', name: 'LaunchDarkly', kind: 'remote-config', docs: 'https://docs.launchdarkly.com/sdk',
match: { cocoapods: ['LaunchDarkly'], spm: ['launchdarkly/ios-client-sdk'], gradle: ['com.launchdarkly:launchdarkly-android-client-sdk'],
npm: ['@launchdarkly/react-native-client-sdk', 'launchdarkly-react-native-client-sdk', 'launchdarkly-js-client-sdk', '@launchdarkly/js-client-sdk'], pub: ['launchdarkly_flutter_client_sdk'] } },
{ id: 'growthbook', name: 'GrowthBook', kind: 'remote-config', docs: 'https://docs.growthbook.io/',
match: { cocoapods: ['GrowthBook-IOS'], spm: ['growthbook-swift'], gradle: ['io.growthbook.sdk:GrowthBook'], npm: ['@growthbook/growthbook', '@growthbook/growthbook-react'], pub: ['growthbook_sdk_flutter'] } },
{ id: 'optimizely', name: 'Optimizely', kind: 'remote-config', docs: 'https://docs.developers.optimizely.com/feature-experimentation/docs',
match: { cocoapods: ['OptimizelySwiftSDK'], spm: ['optimizely/swift-sdk'], gradle: ['com.optimizely.ab:android-sdk'], npm: ['@optimizely/react-sdk', '@optimizely/optimizely-sdk', '@optimizely/react-native-sdk'], pub: ['optimizely_flutter_sdk'] } },
{ id: 'posthog-flags', name: 'PostHog feature flags', kind: 'remote-config', docs: 'https://posthog.com/docs/feature-flags',
needsCode: /\b(isFeatureEnabled|getFeatureFlag|getFeatureFlagPayload|useFeatureFlag|useFeatureFlagEnabled|onFeatureFlags|reloadFeatureFlags)\b/,
match: { cocoapods: ['PostHog'], spm: ['posthog-ios'], gradle: ['com.posthog:posthog-android', 'com.posthog.android:posthog'], npm: ['posthog-react-native', 'posthog-js'], pub: ['posthog_flutter'] } },
{ id: 'unleash', name: 'Unleash', kind: 'remote-config', docs: 'https://docs.getunleash.io/',
match: { cocoapods: ['UnleashProxyClientSwift'], spm: ['unleash-proxy-client-swift'], gradle: ['io.getunleash:unleash-android'], npm: ['unleash-proxy-client', '@unleash/proxy-client-react'], pub: ['unleash_proxy_client_flutter'] } },
{ id: 'configcat', name: 'ConfigCat', kind: 'remote-config', docs: 'https://configcat.com/docs/',
match: { cocoapods: ['ConfigCat'], spm: ['configcat/swift-sdk'], gradle: ['com.configcat:configcat-android-client'], npm: ['configcat-js', 'configcat-react', 'configcat-js-ssr', '@configcat/sdk'], pub: ['configcat_client'] } },
{ id: 'amplitude-experiment', name: 'Amplitude Experiment', kind: 'remote-config', docs: 'https://amplitude.com/docs/feature-experiment',
match: { cocoapods: ['AmplitudeExperiment'], spm: ['experiment-ios-client'], gradle: ['com.amplitude:experiment-android-client'], npm: ['@amplitude/experiment-js-client', '@amplitude/experiment-react-native-client'] } },
{ id: 'split', name: 'Split', kind: 'remote-config', docs: 'https://help.split.io/',
match: { cocoapods: ['Split'], spm: ['splitio/ios-client'], gradle: ['io.split.client:android-client'], npm: ['@splitsoftware/splitio', '@splitsoftware/splitio-react-native', '@splitsoftware/splitio-react'], pub: ['splitio'] } },
// paywalls whose design, products or prices are set from a dashboard
{ id: 'revenuecat-paywalls', name: 'RevenueCat Paywalls', kind: 'paywall', docs: 'https://www.revenuecat.com/docs/tools/paywalls',
match: { cocoapods: ['RevenueCatUI'], spm: ['purchases-ios#RevenueCatUI'], gradle: ['com.revenuecat.purchases:purchases-ui'], npm: ['react-native-purchases-ui', '@revenuecat/purchases-capacitor-ui'], pub: ['purchases_ui_flutter'] } },
{ id: 'superwall', name: 'Superwall', kind: 'paywall', docs: 'https://superwall.com/docs',
match: { cocoapods: ['SuperwallKit'], spm: ['Superwall-iOS'], gradle: ['com.superwall.sdk:superwall-android'], npm: ['@superwall/react-native-superwall', 'expo-superwall'], pub: ['superwallkit_flutter'] } },
{ id: 'adapty', name: 'Adapty (paywall builder)', kind: 'paywall', docs: 'https://adapty.io/docs/',
match: { cocoapods: ['Adapty', 'AdaptyUI'], spm: ['AdaptySDK-iOS'], gradle: ['io.adapty:android-sdk', 'io.adapty:android-ui'], npm: ['react-native-adapty', '@adapty/capacitor', '@adapty/react-native-ui'], pub: ['adapty_flutter', 'adapty_ui_flutter'], unity: ['com.adapty.unity', 'AdaptySDK'] } },
{ id: 'qonversion', name: 'Qonversion', kind: 'paywall', docs: 'https://documentation.qonversion.io/',
match: { cocoapods: ['Qonversion'], spm: ['qonversion-ios-sdk'], gradle: ['io.qonversion.android.sdk:sdk', 'io.qonversion:sdk'], npm: ['react-native-qonversion', '@qonversion/capacitor-plugin'], pub: ['qonversion_flutter'] } },
{ id: 'purchasely', name: 'Purchasely', kind: 'paywall', docs: 'https://docs.purchasely.com/',
match: { cocoapods: ['Purchasely'], spm: ['Purchasely-iOS'], gradle: ['io.purchasely:core'], npm: ['react-native-purchasely', '@purchasely/cordova-plugin-purchasely'], pub: ['purchasely_flutter'] } },
{ id: 'apphud', name: 'Apphud', kind: 'paywall', docs: 'https://docs.apphud.com/',
match: { cocoapods: ['ApphudSDK'], spm: ['ApphudSDK'], gradle: ['com.apphud:ApphudSDK-Android'], npm: ['@apphud/react-native-apphud-sdk', 'react-native-apphud-sdk'], pub: ['apphud'] } },
];
/** Remote config / paywall use in the own sources without a listed dependency (the web Firebase SDK, a RevenueCat
* paywall presented from the core package). Line-anchored code, never an import line. */
export const REMOTE_CODE: { id: string; name: string; kind: RemoteSdkDef['kind']; re: RegExp }[] = [
{ id: 'firebase-remote-config', name: 'Firebase Remote Config', kind: 'remote-config', re: /\bgetRemoteConfig\(|\bfetchAndActivate\(|\bremoteConfig\(\)\.|RemoteConfig\.remoteConfig\(\)|FirebaseRemoteConfig\.(getInstance|instance)\b|\bFirebase\.RemoteConfig\b/ },
{ id: 'revenuecat-paywalls', name: 'RevenueCat Paywalls', kind: 'paywall', re: /\bpresentPaywall(IfNeeded)?\b|\bRevenueCatUI\b|\bpaywallFooter\(|\bPaywallView\(/ },
];
/** Code obfuscators that hide what the app does (not plain minify: ProGuard / R8 shrinking is normal on Android). */
export const OBFUSCATORS: { name: string; match: Partial<Record<Eco, string[]>> }[] = [
{ name: 'DexGuard', match: { gradle: ['com.guardsquare.dexguard', 'com.guardsquare:dexguard-gradle-plugin', 'dexguard'] } },
{ name: 'iXGuard', match: { cocoapods: ['iXGuard'] } },
{ name: 'JavaScript obfuscator', match: { npm: ['javascript-obfuscator', 'react-native-obfuscating-transformer', 'obfuscator-io-metro-plugin', 'webpack-obfuscator', 'vite-plugin-javascript-obfuscator', 'rollup-plugin-obfuscator'] } },
{ name: 'Beebyte Obfuscator', match: { unity: ['Beebyte'] } },
{ name: 'SwiftShield', match: { cocoapods: ['SwiftShield'], spm: ['swiftshield'] } },
];
export interface DepRef { eco: Eco; name: string; version: string | null; file: string; line: number }
/** Does a dependency match an SDK? Pods match by name or "Name/Sub"; Gradle by "group:artifact" or a "group:" prefix;
* SPM by URL fragment (or "url#Product" for one product of a multi-product repository, matched on the product). */
export function matchesSdk(d: DepRef, s: SdkDef): boolean {
export function matchesSdk(d: DepRef, s: Pick<SdkDef, 'match'>): boolean {
const list = s.match[d.eco] ?? [];
const n = d.name;
return list.some(m => {

View file

@ -9,6 +9,7 @@ fails without the fix.
| # | date | lesson | guard |
|---|---|---|---|
| 74 | 2026-10 | Apple sharpened Guideline 2.3.1 (hidden, dormant or undocumented features; obfuscation that hides features is grounds for removal). Apps with remote config / feature flags or remote paywalls whose review notes said nothing risk a rejection or removal when a feature or a hard paywall is switched on after review; Play's Deceptive behavior policy covers the same. Paywall tests and designs are fine once review knows; a business-model change needs a new review and a description update. | `remoteFeatureFindings` (`cli/lib/scan/rules.ts`, SDK table `REMOTE_SDKS` in `cli/lib/scan/sdks.ts`): scan / validate report `remote-features` for Apple (2.3.1) and Play (Deceptive behavior), medium, unless the review notes (apple.info.reviewNotes, reviewAccess.instructions, accounts.inApp) mention remote config, flags, A/B, a paywall test, an experiment or "remotely"; check prints it as a warning. Warn only: storebound never writes the note; the templates carry a commented example line. Code obfuscators are a low note, minify alone nothing. `tests/remote-features.test.ts`. |
| 73 | 2026-10 | A game with guest play (no sign-in; `reviewAccess.signInRequired: false`, Play Sign in details "No restrictions") was rejected by Play: "Play Console Requirements: provide valid login credentials". The first screen had an empty player-name field with Continue disabled until a name was typed and an "I Have a Code" link to a transfer / recovery code dialog; the reviewer read it as a login wall. | `profileIssues` (`cli/lib/profile.ts`, called by `check` with `SIGNALS.firstRunGate` hits from `cli/lib/scan/rules.ts`): warns "Play may reject as a login wall; add guest steps" for a Play app with a first-run gate or IAP and no `reviewAccess.instructions`, and when the steps exceed 500 characters. The `play-sign-in-details` plan (`cli/lib/console-play-plans.ts`): with guest steps "restricted" and one entry "Guest play (no account needed)" without credentials, the steps in "Any other information", the dialog's required full-access box as an owner step; Add and Save the agent's. `tests/guest-login-wall.test.ts`. |
| 72 | 2026-10 | A game: after Play production 1.0.0 (3) was rejected (Broken Functionality), the fix 1.0.1 (5) went through upload and track updates, then edits.validate answered 400 "Changes cannot be sent for review automatically. Please set the query parameter changesNotSentForReview to true". Play refuses the automatic send after a rejection, with other pending changes or managed publishing. | `cli/play/client.ts` (edit flow) with `cli/play/review-pending.ts`: on exactly that 400 the same edit is validated and committed again with changesNotSentForReview=true (a dry run validates that way and says so), the Publishing overview deep link is printed (the owner's button), the commit is recorded in .storebound/ and `play status` shows it; `--not-sent-for-review` on play release / upload / rollout / listing / images (MCP `not_sent_for_review`). `tests/play-not-sent-for-review.test.ts`. Only edits.commit takes changesNotSentForReview: validate answers 400 "Unknown name ... Cannot bind query parameter" (the fake store does too). |
| 71 | 2026-10 | Messages sent the owner to console pages as plain text paths ("App Store Connect > App Information > Age Rating", "Play Console > App content") without a link; the owner had to find the app and the page by hand. | `cli/lib/console-links.ts`: every message carries the absolute deep link (app list / developer chooser when ids are unknown), `url` in finding JSON, an Open button in the desktop; `tests/console-links.test.ts` fails on a console path in cli/ or templates/ without an https URL; agent rule 11 in AGENTS.md. |

View file

@ -87,6 +87,9 @@ Plain, polite, specific; one guideline per paragraph; no blame, no threats, no "
- **2.3 Accurate metadata**: screenshots must show the real app (for a game: real gameplay); no prices, no "free"
claims for paid items, no other platforms' UI; keywords without competitor names; age rating answers that match
the content.
- **2.3.1 Hidden or undocumented features** (Play: Deceptive behavior): remote config, feature flags or a remote
paywall the review notes did not mention (`storebound scan` rule `remote-features`, store-pitfalls 74). Fix: say in
the notes which SDK changes what remotely; a business-model change needs a new review and a description update.
- **3.1.1 In-app purchase**: digital goods only through IAP; a visible **Restore Purchases** for non-consumables;
no links to outside payment; loot boxes must show odds.
- **3.1.2 Subscriptions**: the purchase screen shows title, length, price per period and what the user gets; links to

View file

@ -877,3 +877,25 @@ that fails without the fix (see CONTRIBUTING.md).
in-app purchases and no `reviewAccess.instructions`, and when the steps are longer than 500 characters
(`cli/lib/profile.ts`). The plan's guest path is in `cli/lib/console-play-plans.ts` (`signInDetails`);
`tests/guest-login-wall.test.ts`.
## 74. Remote config and remote paywalls read as hidden features (2.3.1)
- **Symptom:** App Review rejects or removes an app under Guideline 2.3.1 (hidden, dormant or undocumented features),
or Play flags Deceptive behavior, after a feature or a hard paywall was switched on remotely, or the reviewer saw a
different paywall than the one users get.
- **Cause:** remote config / feature flag SDKs (Firebase Remote Config, Statsig, LaunchDarkly, GrowthBook, Optimizely,
PostHog feature flags, Unleash, ConfigCat, Amplitude Experiment, Split) and remote paywalls (RevenueCat Paywalls,
Superwall, Adapty, Qonversion, Purchasely, Apphud) change what the app does after review, and the review notes said
nothing. Paywall tests and designs are not features and are fine once App Review knows; a business-model change
(soft -> hard paywall, freemium -> premium, features moved behind a subscription) or functionality enabled after
review is a feature change.
- **Do:** this is the developer's call; storebound only warns. Suggest a line in `apple.info.reviewNotes` (and
`reviewAccess.instructions`, which also go to Play App access) that names the SDK and what changes remotely
(paywall designs, prices shown, which flags), document every feature that can be switched on or off, and send a
business-model change as a new review with a description that discloses what needs a paid subscription. Never
write the note into the developer's config or code yourself.
- **Guard:** `storebound scan` (and `validate`, which runs it; `check` prints it as a warning) reports `remote-features`
for Apple (2.3.1) and Play (Deceptive behavior), medium, with the deep links, unless the review notes mention remote
config, a feature flag, A/B, a paywall test, an experiment or "remotely" (`remoteFeatureFindings` in
`cli/lib/scan/rules.ts`, the SDK table `REMOTE_SDKS` in `cli/lib/scan/sdks.ts`). A code obfuscator (DexGuard,
iXGuard, a JavaScript obfuscator) is a low `obfuscation` note; ProGuard / R8 minify alone is never reported.
`tests/remote-features.test.ts`.

View file

@ -59,6 +59,9 @@ offers, offerCodes, reviewScreenshot, basePlanId, gracePeriod, platforms }] }]`,
Agreement; the privacy policy URL field (`storebound apple listing`). `validate` warns when a description has no
Terms link. 3.1.2(a): a subscription must give ongoing value and last at least 7 days.
- Review notes: where the paywall is, that the demo account can buy in the sandbox (`review-access`).
- Remote paywalls (RevenueCat Paywalls, Superwall, Adapty, Qonversion, Purchasely, Apphud) or flags: say in the review
notes that paywall designs / prices shown change remotely (2.3.1, `scan` warns; store-pitfalls 74); moving features
behind the subscription after review needs a new review and a description that discloses it.
## Gotchas
- A product id, a billing period or a subscription's group cannot change; make a new id.

View file

@ -70,6 +70,8 @@ const config: ShipConfig = {
reviewNotes: [
'Example App keeps lists and notes in sync across devices. An account is optional on the first screen.',
'Privacy policy and Terms: Settings > About.',
// With remote config or feature flags (storebound scan warns, 2.3.1) you may add a line like:
// 'Some features are configured remotely with <SDK>: <which ones>; none is switched on after review without a new submission.',
].join('\n\n'),
},
},

View file

@ -71,6 +71,8 @@ const config: ShipConfig = {
'Example App keeps lists and notes in sync across devices. Pro (yearly with a 1-week free trial, or monthly) unlocks unlimited lists, sharing and themes.',
'Subscriptions: Settings > Pro (or the paywall after the third list). Restore Purchases is on the paywall and in Settings > Pro.',
'Privacy policy and Terms of Use: on the paywall and in Settings > About.',
// With a remote paywall or feature flags (storebound scan warns, 2.3.1) you may add a line like:
// 'Paywall designs and the prices shown are tested remotely with <SDK>; what Pro unlocks does not change after review.',
].join('\n\n'),
},
},

View file

@ -70,6 +70,8 @@ const config: ShipConfig = {
'In-app purchases: Home > Shop. Restore Purchases is in Settings.',
'Ads: optional rewarded videos and interstitials between rounds. The App Tracking Transparency prompt is shown before the ad SDK starts.',
'Privacy policy: Settings > Privacy.',
// With remote config, feature flags or a remotely changed paywall (storebound scan warns, 2.3.1) you may add:
// 'Shop offers and prices shown come from <SDK> and change remotely; no feature is switched on or off after review.',
].join('\n\n'),
},
},

View file

@ -0,0 +1,80 @@
// App Review 2.3.1 / Play Deceptive behavior: remote config, feature flags and remote paywalls must be told to review.
// scan warns (Apple and Play) unless the review notes mention them; storebound never writes the note itself. Minify
// alone is never a finding; a code obfuscator is a low note.
import { afterAll, describe, expect, test } from 'bun:test';
import { cpSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import type { ShipConfig } from '../cli/lib/config';
import { runScan } from '../cli/lib/scan';
import { detectProject } from '../cli/lib/scan/project';
import { remoteFeatureFindings } from '../cli/lib/scan/rules';
const dirs: string[] = [];
afterAll(() => { for (const d of dirs) rmSync(d, { recursive: true, force: true }); });
/** A tiny app: Podfile.lock pods, Gradle dependencies and own sources. */
function app(o: { pods?: string[]; gradle?: string[]; src?: string; minify?: boolean }): string {
const d = mkdtempSync(join(tmpdir(), 'sb-remote-'));
dirs.push(d);
if (o.pods) writeFileSync(join(d, 'Podfile.lock'), `PODS:\n${o.pods.map(p => ` - ${p} (11.0.0)`).join('\n')}\n\nDEPENDENCIES:\n${o.pods.map(p => ` - ${p}`).join('\n')}\n\nCOCOAPODS: 1.15.2\n`);
mkdirSync(join(d, 'android/app'), { recursive: true });
writeFileSync(join(d, 'android/app/build.gradle.kts'), [
'plugins { id("com.android.application") }',
'android {', ' namespace = "com.example.app"', ' compileSdk = 36',
' defaultConfig { applicationId = "com.example.app"; minSdk = 24; targetSdk = 36 }',
` buildTypes { release { isMinifyEnabled = ${o.minify ? 'true' : 'false'}; proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") } }`,
'}',
'dependencies {', ...(o.gradle ?? []).map(g => ` implementation("${g}")`), '}', ''].join('\n'));
if (o.src) { mkdirSync(join(d, 'src'), { recursive: true }); writeFileSync(join(d, 'src/App.swift'), o.src); }
return d;
}
const cfg = (notes?: string, instructions?: string) => ({ apple: { info: { reviewNotes: notes } }, play: { packageName: 'com.example.app' }, reviewAccess: instructions ? { signInRequired: false, instructions } : undefined }) as unknown as ShipConfig;
const both = { apple: true, play: true };
const rules = (dir: string, c: ShipConfig | null) => remoteFeatureFindings(detectProject(dir), c, both);
describe('remote features (2.3.1)', () => {
test('Firebase Remote Config without a review note: an Apple and a Play finding with deep links', () => {
const d = app({ pods: ['FirebaseRemoteConfig'], gradle: ['com.google.firebase:firebase-config:22.0.0'] });
const f = rules(d, cfg('Single-player game. No account needed.'));
expect(f.map(x => `${x.store} ${x.rule} ${x.guideline} ${x.severity}`).sort()).toEqual(['apple remote-features 2.3.1 medium', 'play remote-features Play: Deceptive behavior medium']);
const apple = f.find(x => x.store === 'apple')!;
expect(apple.title).toContain('Firebase Remote Config');
expect(apple.fix).toContain('soft -> hard paywall');
expect(apple.url).toStartWith('https://appstoreconnect.apple.com/');
expect(f.find(x => x.store === 'play')!.fix).toContain('App access');
expect(apple.evidence.map(e => e.file)).toContain('Podfile.lock');
});
test('the same app with a note that mentions remote config: no finding', () => {
const d = app({ pods: ['FirebaseRemoteConfig'], gradle: ['com.google.firebase:firebase-config:22.0.0'] });
expect(rules(d, cfg('Shop prices and the paywall design come from Firebase Remote Config and can change remotely.'))).toEqual([]);
expect(rules(d, cfg(undefined, 'No sign-in. Feature flags: the daily event can be switched on remotely.'))).toEqual([]);
});
test('Superwall: a remote paywall finding', () => {
const d = app({ pods: ['SuperwallKit'] });
const f = rules(d, cfg('A photo app.'));
expect(f.filter(x => x.rule === 'remote-features').map(x => x.store).sort()).toEqual(['apple', 'play']);
expect(f[0]!.title).toStartWith('remote paywalls (Superwall)');
});
test('RevenueCat paywall presented from code, Firebase web Remote Config from code, PostHog only with flag calls', () => {
expect(rules(app({ src: 'func show() {\n let v = PaywallView(offering: o)\n}\n' }), cfg('x'))[0]?.title).toContain('RevenueCat Paywalls');
expect(rules(app({ src: 'const rc = getRemoteConfig(app);\n' }), cfg('x'))[0]?.title).toContain('Firebase Remote Config');
expect(rules(app({ pods: ['PostHog'] }), cfg('x'))).toEqual([]);
expect(rules(app({ pods: ['PostHog'], src: 'if posthog.isFeatureEnabled("new-shop") { open() }\n' }), cfg('x'))[0]?.title).toContain('PostHog feature flags');
});
test('no such SDK: no finding; minify alone: no finding; an obfuscator: a low note, never an error', () => {
expect(rules(app({ pods: ['GoogleSignIn'], gradle: ['androidx.core:core-ktx:1.13.1'] }), null)).toEqual([]);
expect(rules(app({ gradle: ['androidx.core:core-ktx:1.13.1'], minify: true }), cfg('x'))).toEqual([]);
const o = rules(app({ pods: ['iXGuard'] }), cfg('x'));
expect(o.map(x => `${x.rule} ${x.severity}`)).toEqual(['obfuscation low', 'obfuscation low']);
});
test('scan and validate carry it: the clean fixture plus Firebase Remote Config', async () => {
const d = mkdtempSync(join(tmpdir(), 'sb-remote-clean-'));
dirs.push(d);
cpSync(join(import.meta.dir, 'fixtures/scan/clean'), d, { recursive: true });
const lock = join(d, 'Podfile.lock');
writeFileSync(lock, readFileSync(lock, 'utf8').replace(' - GoogleSignIn (7.1.0)', ' - GoogleSignIn (7.1.0)\n - FirebaseRemoteConfig (11.0.0)'));
const f = (await runScan(d)).report.findings.filter(x => x.rule === 'remote-features');
expect(f.map(x => x.store).sort()).toEqual(['apple', 'play']);
});
});