From 61cb3338e815df3442f2eec486d67cd2cacfb39e Mon Sep 17 00:00:00 2001 From: CRT Date: Sun, 18 Oct 2026 01:26:32 +0800 Subject: [PATCH] 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 --- CHANGELOG.md | 8 ++ cli/check.ts | 10 ++- cli/lib/embedded.ts | 6 +- cli/lib/scan/rules.ts | 54 ++++++++++++- cli/lib/scan/sdks.ts | 71 +++++++++++++++- docs/pitfalls.md | 1 + skills/review-rejections/SKILL.md | 3 + skills/store-pitfalls/SKILL.md | 22 +++++ skills/store-subscriptions/SKILL.md | 3 + templates/profiles/app/storebound.config.ts | 2 + .../subscription-app/storebound.config.ts | 2 + templates/storebound.config.ts | 2 + tests/remote-features.test.ts | 80 +++++++++++++++++++ 13 files changed, 258 insertions(+), 6 deletions(-) create mode 100644 tests/remote-features.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index ea83304..128d4c5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/cli/check.ts b/cli/check.ts index 4039912..3f1e5d4 100644 --- a/cli/check.ts +++ b/cli/check.ts @@ -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 { 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); diff --git a/cli/lib/embedded.ts b/cli/lib/embedded.ts index 40b0cff..7ef0ac0 100644 --- a/cli/lib/embedded.ts +++ b/cli/lib/embedded.ts @@ -17,16 +17,16 @@ export const TEMPLATE_TEXT: Record = { "profiles/app/listing.md": "# Example App · Google Play listing copy\n\nSource for `storebound play listing` (title, short + full description) and `storebound play release` (release notes).\nRules the parser follows:\n- every `## ...` section with a `` line is a listing; one section can serve several Play codes\n- under it, `### ...` headings that contain **title** (optional, defaults to `app.name`), **short**, **full** and\n **what's new**, each followed by one fenced block\n- limits (checked before anything is sent): title 30, short 80, full 4000, what's new 500 characters\nOnly claim what the app really does. No prices, no \"best / #1\", no other apps' names.\n\n---\n\n## English (en-US)\n\n\n### Play short description (max 80)\n```\nWhat the app does for people, in one plain line\n```\n\n### Play full description (max 4000)\n```\nFirst paragraph: the job the app does and why it is easier with it, in two or three sentences.\n\nFEATURES\n• Lists and notes that sync across your devices\n• Share a list with family or friends\n• Export your data or delete your account in Settings, any time\n\nFree to use. An account is optional and only needed for sync and sharing.\n```\n\n### What's new (max 500)\n```\nFirst release. Thanks for trying it!\n```\n\n## Türkçe (tr-TR)\n\n\n### Play kısa açıklama · short (max 80)\n```\nUygulamanın insanlar için ne yaptığını anlatan sade bir cümle\n```\n\n### Play tam açıklama · full (max 4000)\n```\nİlk paragraf: uygulamanın gördüğü iş ve onunla neden daha kolay olduğu, iki üç cümlede.\n\nÖZELLİKLER\n• Tüm cihazlarında eşitlenen listeler ve notlar\n• Bir listeyi ailen ya da arkadaşlarınla paylaş\n• Verilerini dışa aktar ya da hesabını Ayarlar'dan istediğin an sil\n\nÜcretsiz. Hesap isteğe bağlıdır; yalnızca eşitleme ve paylaşım için gerekir.\n```\n\n### Yenilikler · what's new (max 500)\n```\nİlk sürüm. Denediğin için teşekkürler!\n```\n", "profiles/app/privacy-policy.html": "\n\n\n\n\nPrivacy Policy · Example Studio\n\n\n\n\n
\n \n\n
\n

Privacy Policy

\n

Example Studio · applies to all our apps · last updated 2026-01-01

\n\n

We collect what an app needs to work for you, never sell personal data and show no ads. This page explains what\n each app collects, why, and what you can do about it.

\n\n

What our apps use

\n
\n \n \n \n \n \n
AppServiceWhat and whyYour choice
Example AppAccount (our servers)Email address, account id and the lists and notes you create: to sign you in and sync your content between your devices.The account is optional; export or delete it in Settings > Account
Example AppAnalytics (e.g. a privacy-friendly analytics SDK)Random install id, screens used, device model, OS version: to improve the app.Settings > Analytics off
Example AppCrash reportingCrash logs and device model: to fix crashes.-
Example AppApp storesPurchases and subscriptions are handled by Google Play / the App Store; we never see payment details.-
\n\n

Deleting your account

\n

In the app: Settings > Account > Delete account. Without the app: example.com/delete-account.\n We delete your account and its content within 30 days; backups roll over within 90 days. A store subscription is\n not cancelled by deleting the account: cancel it in your Google Play or App Store subscriptions.

\n\n

Children

\n

Our apps are not directed at children under 13. We do not knowingly collect personal data from children. If you\n believe a child sent us data, contact us and we will delete it.

\n\n

Retention and your rights

\n

We keep account data while the account exists and analytics data for up to 24 months. You can ask for a copy or\n the deletion of your data by writing to privacy@example.com; we answer\n within 30 days.

\n\n

Security

\n

All network traffic of our apps uses HTTPS; passwords are stored only as salted hashes.

\n\n

Changes

\n

When an app starts collecting something new we update this page and its date first.

\n\n

Contact

\n

Example Studio · privacy@example.com

\n
\n\n
\n

Gizlilik Politikası

\n

Example Studio · tüm uygulamalarımız için geçerlidir · son güncelleme 2026-01-01

\n

Yalnızca uygulamanın sizin için çalışması gereken verileri topluyoruz, kişisel verileri asla satmıyoruz ve reklam\n göstermiyoruz. Yukarıdaki tablo her uygulamanın neyi neden topladığını gösterir.

\n

Hesabınızı silmek

\n

Uygulamada: Ayarlar > Hesap > Hesabı sil. Uygulama olmadan: example.com/delete-account.\n Mağaza aboneliği hesap silinince iptal olmaz; Google Play veya App Store aboneliklerinizden iptal edin.

\n

Haklarınız

\n

Verilerinizin bir kopyasını veya silinmesini privacy@example.com adresine\n yazarak isteyebilirsiniz; 30 gün içinde yanıtlıyoruz.

\n
\n
\n\n\n", "profiles/app/shots-copy.json": "{\n \"en\": {\n \"home\": { \"eyebrow\": \"Example App\", \"title\": [\"Everything in order,\", \"in one calm place\"], \"sub\": \"Lists and notes, synced on all your devices.\" },\n \"detail\": { \"eyebrow\": \"Get it done\", \"title\": [\"See what is left\", \"at a glance\"], \"sub\": \"Tap to tick, swipe to reorder.\" },\n \"share\": { \"eyebrow\": \"Together\", \"title\": [\"Share a list,\", \"plan together\"], \"sub\": \"Invite people by link and edit live.\" },\n \"privacy\": { \"eyebrow\": \"Your data\", \"title\": [\"Private by default,\", \"yours to export\"], \"sub\": \"Export your data or delete your account in Settings.\" }\n },\n \"tr\": {\n \"home\": { \"eyebrow\": \"Example App\", \"title\": [\"Her şey düzenli,\", \"tek sakin bir yerde\"], \"sub\": \"Listeler ve notlar, tüm cihazlarında eşitlenir.\" },\n \"detail\": { \"eyebrow\": \"Bitir\", \"title\": [\"Kalanları\", \"bir bakışta gör\"], \"sub\": \"Dokun işaretle, sürükle sırala.\" },\n \"share\": { \"eyebrow\": \"Birlikte\", \"title\": [\"Listeyi paylaş,\", \"birlikte planla\"], \"sub\": \"Bağlantıyla davet et, canlı düzenleyin.\" },\n \"privacy\": { \"eyebrow\": \"Verilerin\", \"title\": [\"Varsayılan olarak gizli,\", \"dışa aktarması senin\"], \"sub\": \"Verilerini dışa aktar ya da hesabını Ayarlar'dan sil.\" }\n }\n}\n", - "profiles/app/storebound.config.ts": "// storebound config for one app (profile \"app\": a utility / productivity app with an optional account and an\n// analytics SDK, no ads). Every answer below is a starting point: edit it to match your app. Credentials NEVER go\n// here (env vars or .storebound/credentials.json, gitignored). Paths are relative to this file.\n// The type import is optional: it only works when storebound is installed in the project (bun add -d storebound).\nimport type { ShipConfig } from 'storebound';\n\nconst config: ShipConfig = {\n // Every compliance answer below is the TEMPLATE's, not yours. While an area is listed here, check / validate block\n // and nothing sends it to a store. Derive the answers from the code (storebound scan --write; for an app that is\n // already live: storebound onboard --declarations), answer the rest, then remove the area from this list.\n unanswered: ['dataSafety', 'appPrivacy', 'reviewNotes', 'ageRating', 'accounts', 'reviewAccess'],\n\n app: {\n name: 'Example App',\n profile: 'app',\n copyright: '2026 Example Studio',\n privacyPolicyUrl: 'https://example.com/privacy',\n // termsUrl: 'https://example.com/terms', // Terms of Use; required once you sell subscriptions\n supportUrl: 'https://example.com/support',\n marketingUrl: 'https://example.com',\n website: 'https://example.com',\n contactEmail: 'support@example.com',\n },\n\n // Accounts (account-deletion skill). Remove this block if the app has no sign-up / sign-in at all.\n // Apple 5.1.1(v): deletion must start inside the app. Google Play: in the app AND on a public web page.\n accounts: {\n enabled: true,\n deletionUrl: 'https://example.com/delete-account', // public page, no login needed to request deletion\n inApp: 'Settings > Account > Delete account', // goes into the App Review notes\n // How people sign up: 'username' (a name picked in the app, no password, no email), 'username-password',\n // 'email-password', 'email-link', 'phone', 'oauth', 'other'. `storebound scan` proposes the account data and\n // dataSafety.accountCreation from it; only the 'email-*' methods mean an email address.\n signUp: ['email-password'],\n },\n\n // Sign-in for store reviewers (review-access skill). The demo account itself lives ONLY in\n // .storebound/credentials.json (\"reviewDemoAccount\") or REVIEW_DEMO_USER / REVIEW_DEMO_PASSWORD.\n reviewAccess: {\n signInRequired: true, // false if every feature works without signing in\n instructions: 'Sign in with the demo account. Sample data is already there; nothing needs a second device.',\n },\n\n // id = your folder / copy key; play = Play language code(s); apple = App Store Connect locale\n languages: [\n { id: 'en', play: 'en-US', apple: 'en-US' },\n { id: 'tr', play: 'tr-TR', apple: 'tr' },\n ],\n\n play: {\n packageName: 'com.example.app',\n defaultLanguage: 'en-US',\n listing: 'store/listing.md',\n icon: 'store/icons/play-512.png',\n },\n\n apple: {\n bundleId: 'com.example.app',\n // name: 'Example: Short Tagline', // App Store name (max 30) when app.name is taken there (\"already being used\"); default app.name\n listing: 'store/appstore.md',\n // Export compliance (export-compliance skill): decide it, then uncomment. false ONLY if the app uses no encryption\n // beyond the OS (HTTPS through the system, standard sign-in).\n // usesNonExemptEncryption: false,\n // ipad: true,\n info: {\n categories: { primary: 'PRODUCTIVITY', secondary: 'UTILITIES' },\n ageRating: {}, // every content question NONE / false; change what applies (user-generated content, web access ...)\n contentRights: 'DOES_NOT_USE_THIRD_PARTY_CONTENT',\n free: true,\n reviewNotes: [\n 'Example App keeps lists and notes in sync across devices. An account is optional on the first screen.',\n 'Privacy policy and Terms: Settings > About.',\n ].join('\\n\\n'),\n },\n },\n\n // In-app purchases (optional): a one-time \"Pro\" unlock, for example. Subscriptions: see the subscription-app profile\n // (storebound init --profile subscription-app) or the store-subscriptions skill.\n // products: [{ id: 'com.example.app.pro', type: 'non_consumable', usd: 4.99,\n // listings: { en: { title: 'Pro', description: 'Unlimited lists and themes.' } }, reviewScreenshot: 'store/iap-review/pro.png' }],\n\n // Google Play Data safety. Types not listed = not collected. Typical app with accounts + an analytics SDK acting as\n // your processor (collected, not shared). Edit to match what your app and its SDKs really do.\n dataSafety: {\n collectsData: true,\n encryptedInTransit: true,\n // Sign-up methods. Google does not publish these ids: compare with Play Console > App content > Data safety >\n // Export to CSV before the first import. The delete-account URL (PSL_ACCOUNT_DELETION_URL) comes from\n // accounts.deletionUrl; `storebound check` names any other row Play requires.\n accountCreation: ['PSL_ACM_USER_ID_PASSWORD'],\n // Can people delete their data without deleting the account (a data-only page)? true needs partialDeletionUrl.\n // Play's \"can users request deletion?\" is answered yes by accounts.deletionUrl. Ask the owner.\n partialDeletion: false,\n data: {\n PSL_EMAIL: { shared: false, collect: ['functionality', 'account'] },\n PSL_USER_ACCOUNT: { shared: false, collect: ['functionality', 'account'] },\n PSL_USER_GENERATED_CONTENT: { shared: false, collect: ['functionality'] },\n PSL_USER_INTERACTION: { shared: false, collect: ['analytics'] },\n PSL_CRASH_LOGS: { shared: false, collect: ['analytics'] },\n PSL_PERFORMANCE_DIAGNOSTICS: { shared: false, collect: ['analytics'] },\n PSL_DEVICE_ID: { shared: false, collect: ['analytics'] },\n },\n },\n\n renders: 'store/renders',\n\n shots: {\n style: 'app', // calm gradient, device frame, headline + subline (game: sparkles and badges)\n copy: 'store/shots/copy.json',\n frames: 'store/shots/frames', // /phone/.png, /tablet/.png\n out: 'store/renders',\n // fonts: { title: 'Inter', body: 'Inter' }, // the app style default\n scenes: [\n { id: 'home', palette: 'mist', frames: [{ src: 'home.png' }] },\n { id: 'detail', palette: 'mint', frames: [{ src: 'detail.png' }] },\n { id: 'share', palette: 'sky', frames: [{ src: 'share.png' }] },\n { id: 'privacy', palette: 'slate', frames: [{ src: 'settings.png' }] },\n ],\n feature: { frame: 'home.png', palette: 'mist' },\n // Raw frames from a web build, the iOS Simulator (ios-sim) or an Android emulator (android-emu): sign in to a demo\n // account with realistic sample data, never real user data (store-screenshots skill).\n capture: {\n baseUrl: 'http://127.0.0.1:5173',\n query: 'lang={lang}&demo=1',\n shots: {\n 'home': { path: '/', delayMs: 1000, device: 'both' },\n 'detail': { path: '/lists/1', delayMs: 1000, device: 'both' },\n 'share': { path: '/lists/1/share', delayMs: 1000, device: 'both' },\n 'settings': { path: '/settings', delayMs: 1000, device: 'both' },\n },\n },\n },\n\n icons: {\n composite: 'store/icon/icon.png',\n foreground: 'store/icon/foreground.png',\n background: 'store/icon/background.png',\n out: 'store/icons',\n },\n};\n\nexport default config;\n", + "profiles/app/storebound.config.ts": "// storebound config for one app (profile \"app\": a utility / productivity app with an optional account and an\n// analytics SDK, no ads). Every answer below is a starting point: edit it to match your app. Credentials NEVER go\n// here (env vars or .storebound/credentials.json, gitignored). Paths are relative to this file.\n// The type import is optional: it only works when storebound is installed in the project (bun add -d storebound).\nimport type { ShipConfig } from 'storebound';\n\nconst config: ShipConfig = {\n // Every compliance answer below is the TEMPLATE's, not yours. While an area is listed here, check / validate block\n // and nothing sends it to a store. Derive the answers from the code (storebound scan --write; for an app that is\n // already live: storebound onboard --declarations), answer the rest, then remove the area from this list.\n unanswered: ['dataSafety', 'appPrivacy', 'reviewNotes', 'ageRating', 'accounts', 'reviewAccess'],\n\n app: {\n name: 'Example App',\n profile: 'app',\n copyright: '2026 Example Studio',\n privacyPolicyUrl: 'https://example.com/privacy',\n // termsUrl: 'https://example.com/terms', // Terms of Use; required once you sell subscriptions\n supportUrl: 'https://example.com/support',\n marketingUrl: 'https://example.com',\n website: 'https://example.com',\n contactEmail: 'support@example.com',\n },\n\n // Accounts (account-deletion skill). Remove this block if the app has no sign-up / sign-in at all.\n // Apple 5.1.1(v): deletion must start inside the app. Google Play: in the app AND on a public web page.\n accounts: {\n enabled: true,\n deletionUrl: 'https://example.com/delete-account', // public page, no login needed to request deletion\n inApp: 'Settings > Account > Delete account', // goes into the App Review notes\n // How people sign up: 'username' (a name picked in the app, no password, no email), 'username-password',\n // 'email-password', 'email-link', 'phone', 'oauth', 'other'. `storebound scan` proposes the account data and\n // dataSafety.accountCreation from it; only the 'email-*' methods mean an email address.\n signUp: ['email-password'],\n },\n\n // Sign-in for store reviewers (review-access skill). The demo account itself lives ONLY in\n // .storebound/credentials.json (\"reviewDemoAccount\") or REVIEW_DEMO_USER / REVIEW_DEMO_PASSWORD.\n reviewAccess: {\n signInRequired: true, // false if every feature works without signing in\n instructions: 'Sign in with the demo account. Sample data is already there; nothing needs a second device.',\n },\n\n // id = your folder / copy key; play = Play language code(s); apple = App Store Connect locale\n languages: [\n { id: 'en', play: 'en-US', apple: 'en-US' },\n { id: 'tr', play: 'tr-TR', apple: 'tr' },\n ],\n\n play: {\n packageName: 'com.example.app',\n defaultLanguage: 'en-US',\n listing: 'store/listing.md',\n icon: 'store/icons/play-512.png',\n },\n\n apple: {\n bundleId: 'com.example.app',\n // name: 'Example: Short Tagline', // App Store name (max 30) when app.name is taken there (\"already being used\"); default app.name\n listing: 'store/appstore.md',\n // Export compliance (export-compliance skill): decide it, then uncomment. false ONLY if the app uses no encryption\n // beyond the OS (HTTPS through the system, standard sign-in).\n // usesNonExemptEncryption: false,\n // ipad: true,\n info: {\n categories: { primary: 'PRODUCTIVITY', secondary: 'UTILITIES' },\n ageRating: {}, // every content question NONE / false; change what applies (user-generated content, web access ...)\n contentRights: 'DOES_NOT_USE_THIRD_PARTY_CONTENT',\n free: true,\n reviewNotes: [\n 'Example App keeps lists and notes in sync across devices. An account is optional on the first screen.',\n 'Privacy policy and Terms: Settings > About.',\n // With remote config or feature flags (storebound scan warns, 2.3.1) you may add a line like:\n // 'Some features are configured remotely with : ; none is switched on after review without a new submission.',\n ].join('\\n\\n'),\n },\n },\n\n // In-app purchases (optional): a one-time \"Pro\" unlock, for example. Subscriptions: see the subscription-app profile\n // (storebound init --profile subscription-app) or the store-subscriptions skill.\n // products: [{ id: 'com.example.app.pro', type: 'non_consumable', usd: 4.99,\n // listings: { en: { title: 'Pro', description: 'Unlimited lists and themes.' } }, reviewScreenshot: 'store/iap-review/pro.png' }],\n\n // Google Play Data safety. Types not listed = not collected. Typical app with accounts + an analytics SDK acting as\n // your processor (collected, not shared). Edit to match what your app and its SDKs really do.\n dataSafety: {\n collectsData: true,\n encryptedInTransit: true,\n // Sign-up methods. Google does not publish these ids: compare with Play Console > App content > Data safety >\n // Export to CSV before the first import. The delete-account URL (PSL_ACCOUNT_DELETION_URL) comes from\n // accounts.deletionUrl; `storebound check` names any other row Play requires.\n accountCreation: ['PSL_ACM_USER_ID_PASSWORD'],\n // Can people delete their data without deleting the account (a data-only page)? true needs partialDeletionUrl.\n // Play's \"can users request deletion?\" is answered yes by accounts.deletionUrl. Ask the owner.\n partialDeletion: false,\n data: {\n PSL_EMAIL: { shared: false, collect: ['functionality', 'account'] },\n PSL_USER_ACCOUNT: { shared: false, collect: ['functionality', 'account'] },\n PSL_USER_GENERATED_CONTENT: { shared: false, collect: ['functionality'] },\n PSL_USER_INTERACTION: { shared: false, collect: ['analytics'] },\n PSL_CRASH_LOGS: { shared: false, collect: ['analytics'] },\n PSL_PERFORMANCE_DIAGNOSTICS: { shared: false, collect: ['analytics'] },\n PSL_DEVICE_ID: { shared: false, collect: ['analytics'] },\n },\n },\n\n renders: 'store/renders',\n\n shots: {\n style: 'app', // calm gradient, device frame, headline + subline (game: sparkles and badges)\n copy: 'store/shots/copy.json',\n frames: 'store/shots/frames', // /phone/.png, /tablet/.png\n out: 'store/renders',\n // fonts: { title: 'Inter', body: 'Inter' }, // the app style default\n scenes: [\n { id: 'home', palette: 'mist', frames: [{ src: 'home.png' }] },\n { id: 'detail', palette: 'mint', frames: [{ src: 'detail.png' }] },\n { id: 'share', palette: 'sky', frames: [{ src: 'share.png' }] },\n { id: 'privacy', palette: 'slate', frames: [{ src: 'settings.png' }] },\n ],\n feature: { frame: 'home.png', palette: 'mist' },\n // Raw frames from a web build, the iOS Simulator (ios-sim) or an Android emulator (android-emu): sign in to a demo\n // account with realistic sample data, never real user data (store-screenshots skill).\n capture: {\n baseUrl: 'http://127.0.0.1:5173',\n query: 'lang={lang}&demo=1',\n shots: {\n 'home': { path: '/', delayMs: 1000, device: 'both' },\n 'detail': { path: '/lists/1', delayMs: 1000, device: 'both' },\n 'share': { path: '/lists/1/share', delayMs: 1000, device: 'both' },\n 'settings': { path: '/settings', delayMs: 1000, device: 'both' },\n },\n },\n },\n\n icons: {\n composite: 'store/icon/icon.png',\n foreground: 'store/icon/foreground.png',\n background: 'store/icon/background.png',\n out: 'store/icons',\n },\n};\n\nexport default config;\n", "profiles/app/workflows/beta.yml": "# storebound flow \"beta\": a TestFlight / internal testing build for your testers.\n# storebound flow run beta VERSION=1.2.0 [--apply]\ndescription: Next build to TestFlight and Play internal testing\nparams:\n VERSION: \"1.0.0\"\n GROUP: \"Internal\"\nsteps:\n - id: check\n run: check\n - id: next-build\n if: apple\n run: apple next-build\n capture: BUILD\n - id: build\n if: apple\n run: apple build --version ${VERSION} --build-number ${BUILD}\n - id: upload\n if: apple\n run: apple upload --no-wait --build ${BUILD} --allow-existing\n - id: wait\n if: apple\n run: apple testflight wait --build ${BUILD} --version ${VERSION}\n - id: what-to-test\n if: apple\n run: apple testflight what-to-test --build ${BUILD}\n - id: distribute\n if: apple\n run: apple testflight distribute --build ${BUILD} --group ${GROUP}\n - id: play-build\n if: play\n run: play build --version-name ${VERSION}\n - id: play-upload\n if: play\n run: play upload --track internal\n - id: external\n owner: \"External testers: storebound apple testflight distribute --build ${BUILD} --group --submit-review (Beta App Review is your call)\"\n", "profiles/app/workflows/release.yml": "# storebound flow \"release\": one version of the app on both stores, up to your Submit click.\n# storebound flow run release VERSION=1.2.0 reads run, writes run as dry runs (nothing changes)\n# storebound flow run release VERSION=1.2.0 --apply writes run for real; each command still asks you first\n# storebound flow run release --resume --apply continue after fixing a failed step\n# Steps are storebound commands (no \"storebound\" prefix). `if: apple` / `if: play` skip a store the config does not\n# have; `owner:` steps are never run, they list what you click. Edit freely: this file is yours.\ndescription: Build, upload and check one version on both stores, then your Submit click\nparams:\n VERSION: \"1.0.0\"\nsteps:\n - id: next-build\n if: apple\n run: apple next-build\n capture: BUILD\n - id: build\n if: apple\n run: apple build --version ${VERSION} --build-number ${BUILD}\n - id: upload\n if: apple\n run: apple upload --no-wait --build ${BUILD} --allow-existing\n - id: wait\n if: apple\n run: apple testflight wait --build ${BUILD} --version ${VERSION}\n - id: play-build\n if: play\n run: play build --version-name ${VERSION}\n - id: play-upload\n if: play\n run: play upload --track internal\n - id: whats-new\n if: exists store/whats-new\n run: whats-new apply\n - id: listing\n if: apple\n run: apple listing --version ${VERSION} --build ${BUILD}\n - id: validate\n run: validate\n - id: scan\n run: scan\n - id: plan\n run: release plan ${VERSION}\n - id: submit\n owner: \"App Store Connect: Submit for Review (version ${VERSION}, build ${BUILD}). Play Console: promote the internal release and Send changes for review\"\n", "profiles/subscription-app/app-privacy.md": "# Example App · App Store \"App Privacy\" answers\n\nApp Store Connect > your app > App Privacy (https://appstoreconnect.apple.com/apps, then the app; `storebound console plan apple-app-privacy` gives the deep link). **Web UI only, there is no API.** Fill it from this table, then press\n**Publish** (saving alone does not publish it). Keep it consistent with the privacy policy and the Play Data safety\nanswers (`dataSafety` in `storebound.config.ts`). **Edit to match your app.**\n\nTypical subscription app: account (email), content synced to your backend, auto-renewable subscriptions checked by\nyour server, an analytics SDK acting as your processor, no ads, no tracking.\n\n**Do you or your third-party partners collect data from this app?** Yes\n\n**Tracking**: No (no ad SDK, no data broker, nothing combined with other companies' data for advertising). If that\nchanges, add the App Tracking Transparency prompt first.\n\n| data type | collected | used to track | linked to the user | purposes |\n|---|---|---|---|---|\n| Contact Info > Email Address | yes (account) | no | yes | App Functionality |\n| Identifiers > User ID | yes (account id) | no | yes | App Functionality, Analytics |\n| User Content > Other User Content (lists, notes) | yes (synced) | no | yes | App Functionality |\n| Usage Data > Product Interaction | yes (analytics) | no | yes if tied to the account, else no | Analytics |\n| Purchases > Purchase History (subscription status) | yes | no | yes | App Functionality |\n| Diagnostics > Crash Data | yes | no | no | App Functionality, Analytics |\n| Diagnostics > Performance Data | yes | no | no | Analytics |\n\n\"Linked to you\" is **Yes** for what you store under the account. Not collected (typical): Location, Health and\nFitness, Financial Info, Contacts, Photos or Videos (unless people attach them), Browsing and Search History,\nSensitive Info.\n\nPer data type the UI asks, in order: purposes > \"Is this data linked to the user's identity?\" > \"Do you or your\nthird-party partners use this data for tracking purposes?\".\n", "profiles/subscription-app/appstore.md": "# Example App · App Store listing copy\n\nSource for `storebound apple listing`. One `## ` section per locale (the first word is the locale code:\n`en-US`, `tr`, `de-DE`, `ja`, `zh-Hans`, `ar-SA`, ...), one fenced block per `### `, field names exactly:\n`name` (optional, defaults to `apple.name`, else `app.name`), `subtitle` (30), `promotionalText` (170), `keywords` (100 characters, comma\nseparated, no spaces after commas, do not repeat the app name), `description` (4000), `whatsNew` (optional; not allowed\non the first version). Shared values (privacy policy, support and marketing URL) come from `storebound.config.ts`.\n\n---\n\n## en-US · English (U.S.)\n\n### subtitle\n```\nLists and notes, in sync\n```\n\n### promotionalText\n```\nOne or two sentences you can change any time without a new version.\n```\n\n### keywords\n```\ntodo,checklist,groceries,planner,notes,shared,family,organizer,tasks,sync\n```\n\n### description\n```\nFirst paragraph: the job the app does and why it is easier with it.\n\nFEATURES\n• Lists and notes that sync across your devices\n• Share a list with family or friends\n• Export your data or delete your account in Settings, any time\n\nFree to use with up to 3 lists. Example Pro unlocks unlimited lists, sharing and themes: yearly with a 1-week free trial, or monthly. Payment is charged to your store account; the subscription renews automatically unless cancelled at least 24 hours before the end of the period, and you can manage or cancel it in your account settings.\n\nTerms of Use: https://example.com/terms\nPrivacy Policy: https://example.com/privacy\n```\n\n## tr · Türkçe\n\n### subtitle\n```\nListeler ve notlar, eşitli\n```\n\n### promotionalText\n```\nYeni sürüm göndermeden istediğin zaman değiştirebileceğin bir iki cümle.\n```\n\n### keywords\n```\nyapılacaklar,kontrol,market,planlayıcı,not,paylaşım,aile,düzen,görev\n```\n\n### description\n```\nİlk paragraf: uygulamanın gördüğü iş ve onunla neden daha kolay olduğu.\n\nÖZELLİKLER\n• Tüm cihazlarında eşitlenen listeler ve notlar\n• Bir listeyi ailen ya da arkadaşlarınla paylaş\n• Verilerini dışa aktar ya da hesabını Ayarlar'dan istediğin an sil\n\n3 listeye kadar ücretsiz. Example Pro sınırsız liste, paylaşım ve tema açar: 1 hafta ücretsiz denemeli yıllık ya da aylık. Ödeme mağaza hesabınızdan alınır; abonelik, dönem bitmeden en az 24 saat önce iptal edilmezse otomatik yenilenir; hesap ayarlarınızdan yönetebilir ya da iptal edebilirsiniz.\n\nKullanım Koşulları: https://example.com/terms\nGizlilik Politikası: https://example.com/privacy\n```\n", "profiles/subscription-app/declarations.md": "# Example App · store declarations (answers to copy into the consoles)\n\nStarting answers for a typical **subscription app**: auto-renewable subscriptions (free trial on the yearly plan), an account (email + password, or sign in\nwith a provider), an analytics SDK acting as your processor, data synced to your own backend, no ads, no chat with\nstrangers, general audience. **Edit every line to match your app**: these are your statements to the stores. Everything\nmarked **UI** has no API: Play Console > your app > Policy and programs > **App content** (https://play.google.com/console), and App Store Connect for\nApple. Data safety (Play) has an API: `storebound play data-safety`.\n\n## Google Play · App content (UI)\n\n| declaration | answer |\n|---|---|\n| Privacy policy | your policy URL (same one in the listing and inside the app) |\n| App access | \"All or some functionality is restricted\": add the demo account (username, password) and the steps from `reviewAccess.instructions`; credentials must work at all times, no 2FA / OTP (review-access skill) |\n| Ads | No, my app does not contain ads |\n| Content rating | IARC questionnaire, below |\n| Target audience and content | 18 and over (or 13+ if teens use it); \"Could your app unintentionally appeal to children?\" No (only if true) |\n| News app | No |\n| Data safety | `storebound play data-safety`, then check the summary; with accounts: the delete-account URL (`accounts.deletionUrl`) |\n| Government apps | No |\n| Financial features | My app doesn't provide any financial features |\n| Health apps | No health features |\n| Advertising ID | No (unless an SDK in your app reads it: check the SDK docs and your merged manifest) |\n| Store settings | App, category (Productivity, Tools, ...); contact email + website |\n\n## Account deletion (account-deletion skill)\n\n- In the app: Settings > Account > Delete account deletes the account and its data (not only \"deactivate\").\n- On the web: `accounts.deletionUrl` lets people request deletion without the app; Play asks for it in Data safety.\n- With subscriptions: say that deleting the account does not cancel the store subscription, and how to cancel.\n\n## IARC content rating (Play UI; Apple has its own form, `storebound apple appinfo`)\n\nCategory: not a game (e.g. Utility, Productivity, Communication, or Social Networking if people share content with\nothers). Typical answers for a lists / notes app:\n\n| topic | answer |\n|---|---|\n| Violence, sexuality, language, controlled substances | No |\n| Gambling (simulated / real money) | No |\n| Users interact or exchange content | Yes only if people can share with others outside their own invitations; then also moderation and reporting |\n| Shares location with other users | No |\n| Digital purchases | Yes (subscriptions) |\n| Unrestricted internet / browser | No |\n\nExpected: PEGI 3, ESRB Everyone, IARC 3+. Apple age rating 4+ (all content questions None) unless users share\ncontent publicly.\n\n## App Store (UI-only parts)\n\n- App record: App Store Connect > Apps > + > New App (https://appstoreconnect.apple.com/apps). No API.\n- App Privacy: `app-privacy.md`, then **Publish**.\n- App Review information: `storebound apple review-details` sends the contact, the demo account (from\n `.storebound/credentials.json`, masked in the log) and the notes.\n- Export compliance (export-compliance skill): `ITSAppUsesNonExemptEncryption = NO` only if true.\n- Guideline 5.1.1: the privacy policy link must also be reachable **inside the app**; 5.1.1(v): account deletion\n inside the app.\n\n## Subscriptions (store-subscriptions skill)\n\n- Purchase screen (both stores): plan name, length, the full renewal price per period (most prominent), what is\n included, free trial length and what is charged after it, how to cancel, Restore Purchases, links to the Terms of Use\n and the Privacy Policy. Apple 3.1.2; Google Play subscriptions policy.\n- Metadata: the App Store description (or a custom EULA in App Information > License Agreement) links the Terms of\n Use; the Privacy Policy URL field is set by `storebound apple listing`.\n- First subscriptions are submitted together with an app version (App Store Connect: add them to the version on its\n page before Submit for Review), like first in-app purchases.\n- App Review notes say where the paywall is and that sandbox purchases work with the demo account.\n- Play: base plans and offers must be active before people can buy; new subscribers see the converted regional prices.\n", "profiles/subscription-app/listing.md": "# Example App · Google Play listing copy\n\nSource for `storebound play listing` (title, short + full description) and `storebound play release` (release notes).\nRules the parser follows:\n- every `## ...` section with a `` line is a listing; one section can serve several Play codes\n- under it, `### ...` headings that contain **title** (optional, defaults to `app.name`), **short**, **full** and\n **what's new**, each followed by one fenced block\n- limits (checked before anything is sent): title 30, short 80, full 4000, what's new 500 characters\nOnly claim what the app really does. No prices, no \"best / #1\", no other apps' names.\n\n---\n\n## English (en-US)\n\n\n### Play short description (max 80)\n```\nWhat the app does for people, in one plain line\n```\n\n### Play full description (max 4000)\n```\nFirst paragraph: the job the app does and why it is easier with it, in two or three sentences.\n\nFEATURES\n• Lists and notes that sync across your devices\n• Share a list with family or friends\n• Export your data or delete your account in Settings, any time\n\nFree to use with up to 3 lists. Example Pro unlocks unlimited lists, sharing and themes: yearly with a 1-week free trial, or monthly. Payment is charged to your store account; the subscription renews automatically unless cancelled at least 24 hours before the end of the period, and you can manage or cancel it in your account settings.\n\nTerms of Use: https://example.com/terms\nPrivacy Policy: https://example.com/privacy\n```\n\n### What's new (max 500)\n```\nFirst release. Thanks for trying it!\n```\n\n## Türkçe (tr-TR)\n\n\n### Play kısa açıklama · short (max 80)\n```\nUygulamanın insanlar için ne yaptığını anlatan sade bir cümle\n```\n\n### Play tam açıklama · full (max 4000)\n```\nİlk paragraf: uygulamanın gördüğü iş ve onunla neden daha kolay olduğu, iki üç cümlede.\n\nÖZELLİKLER\n• Tüm cihazlarında eşitlenen listeler ve notlar\n• Bir listeyi ailen ya da arkadaşlarınla paylaş\n• Verilerini dışa aktar ya da hesabını Ayarlar'dan istediğin an sil\n\n3 listeye kadar ücretsiz. Example Pro sınırsız liste, paylaşım ve tema açar: 1 hafta ücretsiz denemeli yıllık ya da aylık. Ödeme mağaza hesabınızdan alınır; abonelik, dönem bitmeden en az 24 saat önce iptal edilmezse otomatik yenilenir; hesap ayarlarınızdan yönetebilir ya da iptal edebilirsiniz.\n\nKullanım Koşulları: https://example.com/terms\nGizlilik Politikası: https://example.com/privacy\n```\n\n### Yenilikler · what's new (max 500)\n```\nİlk sürüm. Denediğin için teşekkürler!\n```\n", - "profiles/subscription-app/storebound.config.ts": "// storebound config for one app (profile \"subscription-app\": accounts plus auto-renewable subscriptions, an\n// analytics SDK, no ads). Every answer below is a starting point: edit it to match your app. Credentials NEVER go\n// here (env vars or .storebound/credentials.json, gitignored). Paths are relative to this file.\n// The type import is optional: it only works when storebound is installed in the project (bun add -d storebound).\nimport type { ShipConfig } from 'storebound';\n\nconst config: ShipConfig = {\n // Every compliance answer below is the TEMPLATE's, not yours. While an area is listed here, check / validate block\n // and nothing sends it to a store. Derive the answers from the code (storebound scan --write; for an app that is\n // already live: storebound onboard --declarations), answer the rest, then remove the area from this list.\n unanswered: ['dataSafety', 'appPrivacy', 'reviewNotes', 'ageRating', 'accounts', 'reviewAccess'],\n\n app: {\n name: 'Example App',\n profile: 'subscription-app',\n copyright: '2026 Example Studio',\n privacyPolicyUrl: 'https://example.com/privacy',\n termsUrl: 'https://example.com/terms', // Terms of Use (EULA): in the app and the metadata (Apple 3.1.2)\n supportUrl: 'https://example.com/support',\n marketingUrl: 'https://example.com',\n website: 'https://example.com',\n contactEmail: 'support@example.com',\n },\n\n // Accounts (account-deletion skill). Remove this block if the app has no sign-up / sign-in at all.\n // Apple 5.1.1(v): deletion must start inside the app. Google Play: in the app AND on a public web page.\n accounts: {\n enabled: true,\n deletionUrl: 'https://example.com/delete-account', // public page, no login needed to request deletion\n inApp: 'Settings > Account > Delete account', // goes into the App Review notes\n // How people sign up: 'username' (a name picked in the app, no password, no email), 'username-password',\n // 'email-password', 'email-link', 'phone', 'oauth', 'other'. `storebound scan` proposes the account data and\n // dataSafety.accountCreation from it; only the 'email-*' methods mean an email address.\n signUp: ['email-password'],\n },\n\n // Sign-in for store reviewers (review-access skill). The demo account itself lives ONLY in\n // .storebound/credentials.json (\"reviewDemoAccount\") or REVIEW_DEMO_USER / REVIEW_DEMO_PASSWORD.\n reviewAccess: {\n signInRequired: true, // false if every feature works without signing in\n instructions: 'Sign in with the demo account. Sample data is already there; nothing needs a second device.',\n },\n\n // id = your folder / copy key; play = Play language code(s); apple = App Store Connect locale\n languages: [\n { id: 'en', play: 'en-US', apple: 'en-US' },\n { id: 'tr', play: 'tr-TR', apple: 'tr' },\n ],\n\n play: {\n packageName: 'com.example.app',\n defaultLanguage: 'en-US',\n listing: 'store/listing.md',\n icon: 'store/icons/play-512.png',\n },\n\n apple: {\n bundleId: 'com.example.app',\n // name: 'Example: Short Tagline', // App Store name (max 30) when app.name is taken there (\"already being used\"); default app.name\n listing: 'store/appstore.md',\n // Export compliance (export-compliance skill): decide it, then uncomment. false ONLY if the app uses no encryption\n // beyond the OS (HTTPS through the system, standard sign-in).\n // usesNonExemptEncryption: false,\n // ipad: true,\n info: {\n categories: { primary: 'PRODUCTIVITY', secondary: 'UTILITIES' },\n ageRating: {}, // every content question NONE / false; change what applies (user-generated content, web access ...)\n contentRights: 'DOES_NOT_USE_THIRD_PARTY_CONTENT',\n free: true,\n reviewNotes: [\n '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.',\n 'Subscriptions: Settings > Pro (or the paywall after the third list). Restore Purchases is on the paywall and in Settings > Pro.',\n 'Privacy policy and Terms of Use: on the paywall and in Settings > About.',\n ].join('\\n\\n'),\n },\n },\n\n // Auto-renewable subscriptions (store-subscriptions skill): `storebound apple subscriptions`, `storebound play\n // subscriptions`. Apple: one group, levels rank the plans (1 = highest). Play: one subscription per entry with one\n // auto-renewing base plan. Prices: USD base, Apple equalizes from the USA, Play converts per region; overrides set a\n // region by hand. The disclosure (price per period, renewal, how to cancel, Terms + Privacy links) belongs on the\n // purchase screen and in the descriptions.\n subscriptions: [\n {\n name: 'Example Pro',\n listings: { en: { name: 'Example Pro' }, tr: { name: 'Example Pro' } },\n subscriptions: [\n {\n id: 'com.example.app.pro_yearly', period: 'P1Y', level: 1, usd: 29.99,\n listings: {\n en: { title: 'Pro yearly', description: 'Unlimited lists, sharing and themes.', benefits: ['Unlimited lists', 'Shared lists', 'All themes'] },\n tr: { title: 'Pro yıllık', description: 'Sınırsız liste, paylaşım ve tema.', benefits: ['Sınırsız liste', 'Paylaşılan listeler', 'Tüm temalar'] },\n },\n intro: { type: 'free', duration: 'P1W' }, // 1 week free trial for new subscribers\n reviewScreenshot: 'store/iap-review/paywall.png',\n },\n {\n id: 'com.example.app.pro_monthly', period: 'P1M', level: 2, usd: 3.99,\n overrides: [{ region: 'TR', currency: 'TRY', price: 99.99 }],\n listings: {\n en: { title: 'Pro monthly', description: 'Unlimited lists, sharing and themes.', benefits: ['Unlimited lists', 'Shared lists', 'All themes'] },\n tr: { title: 'Pro aylık', description: 'Sınırsız liste, paylaşım ve tema.', benefits: ['Sınırsız liste', 'Paylaşılan listeler', 'Tüm temalar'] },\n },\n reviewScreenshot: 'store/iap-review/paywall.png',\n },\n ],\n },\n ],\n\n // Google Play Data safety. Types not listed = not collected. Typical app with accounts + an analytics SDK acting as\n // your processor (collected, not shared). Edit to match what your app and its SDKs really do.\n dataSafety: {\n collectsData: true,\n encryptedInTransit: true,\n // Sign-up methods. Google does not publish these ids: compare with Play Console > App content > Data safety >\n // Export to CSV before the first import. The delete-account URL (PSL_ACCOUNT_DELETION_URL) comes from\n // accounts.deletionUrl; `storebound check` names any other row Play requires.\n accountCreation: ['PSL_ACM_USER_ID_PASSWORD'],\n // Can people delete their data without deleting the account (a data-only page)? true needs partialDeletionUrl.\n // Play's \"can users request deletion?\" is answered yes by accounts.deletionUrl. Ask the owner.\n partialDeletion: false,\n data: {\n PSL_EMAIL: { shared: false, collect: ['functionality', 'account'] },\n PSL_USER_ACCOUNT: { shared: false, collect: ['functionality', 'account'] },\n PSL_USER_GENERATED_CONTENT: { shared: false, collect: ['functionality'] },\n PSL_USER_INTERACTION: { shared: false, collect: ['analytics'] },\n PSL_CRASH_LOGS: { shared: false, collect: ['analytics'] },\n PSL_PERFORMANCE_DIAGNOSTICS: { shared: false, collect: ['analytics'] },\n PSL_DEVICE_ID: { shared: false, collect: ['analytics'] },\n PSL_PURCHASE_HISTORY: { shared: false, collect: ['functionality'] }, // subscription status tied to the account\n },\n },\n\n renders: 'store/renders',\n\n shots: {\n style: 'app', // calm gradient, device frame, headline + subline (game: sparkles and badges)\n copy: 'store/shots/copy.json',\n frames: 'store/shots/frames', // /phone/.png, /tablet/.png\n out: 'store/renders',\n // fonts: { title: 'Inter', body: 'Inter' }, // the app style default\n scenes: [\n { id: 'home', palette: 'mist', frames: [{ src: 'home.png' }] },\n { id: 'detail', palette: 'mint', frames: [{ src: 'detail.png' }] },\n { id: 'share', palette: 'sky', frames: [{ src: 'share.png' }] },\n { id: 'privacy', palette: 'slate', frames: [{ src: 'settings.png' }] },\n ],\n feature: { frame: 'home.png', palette: 'mist' },\n // Raw frames from a web build, the iOS Simulator (ios-sim) or an Android emulator (android-emu): sign in to a demo\n // account with realistic sample data, never real user data (store-screenshots skill).\n capture: {\n baseUrl: 'http://127.0.0.1:5173',\n query: 'lang={lang}&demo=1',\n shots: {\n 'home': { path: '/', delayMs: 1000, device: 'both' },\n 'detail': { path: '/lists/1', delayMs: 1000, device: 'both' },\n 'share': { path: '/lists/1/share', delayMs: 1000, device: 'both' },\n 'settings': { path: '/settings', delayMs: 1000, device: 'both' },\n },\n },\n },\n\n icons: {\n composite: 'store/icon/icon.png',\n foreground: 'store/icon/foreground.png',\n background: 'store/icon/background.png',\n out: 'store/icons',\n },\n};\n\nexport default config;\n", + "profiles/subscription-app/storebound.config.ts": "// storebound config for one app (profile \"subscription-app\": accounts plus auto-renewable subscriptions, an\n// analytics SDK, no ads). Every answer below is a starting point: edit it to match your app. Credentials NEVER go\n// here (env vars or .storebound/credentials.json, gitignored). Paths are relative to this file.\n// The type import is optional: it only works when storebound is installed in the project (bun add -d storebound).\nimport type { ShipConfig } from 'storebound';\n\nconst config: ShipConfig = {\n // Every compliance answer below is the TEMPLATE's, not yours. While an area is listed here, check / validate block\n // and nothing sends it to a store. Derive the answers from the code (storebound scan --write; for an app that is\n // already live: storebound onboard --declarations), answer the rest, then remove the area from this list.\n unanswered: ['dataSafety', 'appPrivacy', 'reviewNotes', 'ageRating', 'accounts', 'reviewAccess'],\n\n app: {\n name: 'Example App',\n profile: 'subscription-app',\n copyright: '2026 Example Studio',\n privacyPolicyUrl: 'https://example.com/privacy',\n termsUrl: 'https://example.com/terms', // Terms of Use (EULA): in the app and the metadata (Apple 3.1.2)\n supportUrl: 'https://example.com/support',\n marketingUrl: 'https://example.com',\n website: 'https://example.com',\n contactEmail: 'support@example.com',\n },\n\n // Accounts (account-deletion skill). Remove this block if the app has no sign-up / sign-in at all.\n // Apple 5.1.1(v): deletion must start inside the app. Google Play: in the app AND on a public web page.\n accounts: {\n enabled: true,\n deletionUrl: 'https://example.com/delete-account', // public page, no login needed to request deletion\n inApp: 'Settings > Account > Delete account', // goes into the App Review notes\n // How people sign up: 'username' (a name picked in the app, no password, no email), 'username-password',\n // 'email-password', 'email-link', 'phone', 'oauth', 'other'. `storebound scan` proposes the account data and\n // dataSafety.accountCreation from it; only the 'email-*' methods mean an email address.\n signUp: ['email-password'],\n },\n\n // Sign-in for store reviewers (review-access skill). The demo account itself lives ONLY in\n // .storebound/credentials.json (\"reviewDemoAccount\") or REVIEW_DEMO_USER / REVIEW_DEMO_PASSWORD.\n reviewAccess: {\n signInRequired: true, // false if every feature works without signing in\n instructions: 'Sign in with the demo account. Sample data is already there; nothing needs a second device.',\n },\n\n // id = your folder / copy key; play = Play language code(s); apple = App Store Connect locale\n languages: [\n { id: 'en', play: 'en-US', apple: 'en-US' },\n { id: 'tr', play: 'tr-TR', apple: 'tr' },\n ],\n\n play: {\n packageName: 'com.example.app',\n defaultLanguage: 'en-US',\n listing: 'store/listing.md',\n icon: 'store/icons/play-512.png',\n },\n\n apple: {\n bundleId: 'com.example.app',\n // name: 'Example: Short Tagline', // App Store name (max 30) when app.name is taken there (\"already being used\"); default app.name\n listing: 'store/appstore.md',\n // Export compliance (export-compliance skill): decide it, then uncomment. false ONLY if the app uses no encryption\n // beyond the OS (HTTPS through the system, standard sign-in).\n // usesNonExemptEncryption: false,\n // ipad: true,\n info: {\n categories: { primary: 'PRODUCTIVITY', secondary: 'UTILITIES' },\n ageRating: {}, // every content question NONE / false; change what applies (user-generated content, web access ...)\n contentRights: 'DOES_NOT_USE_THIRD_PARTY_CONTENT',\n free: true,\n reviewNotes: [\n '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.',\n 'Subscriptions: Settings > Pro (or the paywall after the third list). Restore Purchases is on the paywall and in Settings > Pro.',\n 'Privacy policy and Terms of Use: on the paywall and in Settings > About.',\n // With a remote paywall or feature flags (storebound scan warns, 2.3.1) you may add a line like:\n // 'Paywall designs and the prices shown are tested remotely with ; what Pro unlocks does not change after review.',\n ].join('\\n\\n'),\n },\n },\n\n // Auto-renewable subscriptions (store-subscriptions skill): `storebound apple subscriptions`, `storebound play\n // subscriptions`. Apple: one group, levels rank the plans (1 = highest). Play: one subscription per entry with one\n // auto-renewing base plan. Prices: USD base, Apple equalizes from the USA, Play converts per region; overrides set a\n // region by hand. The disclosure (price per period, renewal, how to cancel, Terms + Privacy links) belongs on the\n // purchase screen and in the descriptions.\n subscriptions: [\n {\n name: 'Example Pro',\n listings: { en: { name: 'Example Pro' }, tr: { name: 'Example Pro' } },\n subscriptions: [\n {\n id: 'com.example.app.pro_yearly', period: 'P1Y', level: 1, usd: 29.99,\n listings: {\n en: { title: 'Pro yearly', description: 'Unlimited lists, sharing and themes.', benefits: ['Unlimited lists', 'Shared lists', 'All themes'] },\n tr: { title: 'Pro yıllık', description: 'Sınırsız liste, paylaşım ve tema.', benefits: ['Sınırsız liste', 'Paylaşılan listeler', 'Tüm temalar'] },\n },\n intro: { type: 'free', duration: 'P1W' }, // 1 week free trial for new subscribers\n reviewScreenshot: 'store/iap-review/paywall.png',\n },\n {\n id: 'com.example.app.pro_monthly', period: 'P1M', level: 2, usd: 3.99,\n overrides: [{ region: 'TR', currency: 'TRY', price: 99.99 }],\n listings: {\n en: { title: 'Pro monthly', description: 'Unlimited lists, sharing and themes.', benefits: ['Unlimited lists', 'Shared lists', 'All themes'] },\n tr: { title: 'Pro aylık', description: 'Sınırsız liste, paylaşım ve tema.', benefits: ['Sınırsız liste', 'Paylaşılan listeler', 'Tüm temalar'] },\n },\n reviewScreenshot: 'store/iap-review/paywall.png',\n },\n ],\n },\n ],\n\n // Google Play Data safety. Types not listed = not collected. Typical app with accounts + an analytics SDK acting as\n // your processor (collected, not shared). Edit to match what your app and its SDKs really do.\n dataSafety: {\n collectsData: true,\n encryptedInTransit: true,\n // Sign-up methods. Google does not publish these ids: compare with Play Console > App content > Data safety >\n // Export to CSV before the first import. The delete-account URL (PSL_ACCOUNT_DELETION_URL) comes from\n // accounts.deletionUrl; `storebound check` names any other row Play requires.\n accountCreation: ['PSL_ACM_USER_ID_PASSWORD'],\n // Can people delete their data without deleting the account (a data-only page)? true needs partialDeletionUrl.\n // Play's \"can users request deletion?\" is answered yes by accounts.deletionUrl. Ask the owner.\n partialDeletion: false,\n data: {\n PSL_EMAIL: { shared: false, collect: ['functionality', 'account'] },\n PSL_USER_ACCOUNT: { shared: false, collect: ['functionality', 'account'] },\n PSL_USER_GENERATED_CONTENT: { shared: false, collect: ['functionality'] },\n PSL_USER_INTERACTION: { shared: false, collect: ['analytics'] },\n PSL_CRASH_LOGS: { shared: false, collect: ['analytics'] },\n PSL_PERFORMANCE_DIAGNOSTICS: { shared: false, collect: ['analytics'] },\n PSL_DEVICE_ID: { shared: false, collect: ['analytics'] },\n PSL_PURCHASE_HISTORY: { shared: false, collect: ['functionality'] }, // subscription status tied to the account\n },\n },\n\n renders: 'store/renders',\n\n shots: {\n style: 'app', // calm gradient, device frame, headline + subline (game: sparkles and badges)\n copy: 'store/shots/copy.json',\n frames: 'store/shots/frames', // /phone/.png, /tablet/.png\n out: 'store/renders',\n // fonts: { title: 'Inter', body: 'Inter' }, // the app style default\n scenes: [\n { id: 'home', palette: 'mist', frames: [{ src: 'home.png' }] },\n { id: 'detail', palette: 'mint', frames: [{ src: 'detail.png' }] },\n { id: 'share', palette: 'sky', frames: [{ src: 'share.png' }] },\n { id: 'privacy', palette: 'slate', frames: [{ src: 'settings.png' }] },\n ],\n feature: { frame: 'home.png', palette: 'mist' },\n // Raw frames from a web build, the iOS Simulator (ios-sim) or an Android emulator (android-emu): sign in to a demo\n // account with realistic sample data, never real user data (store-screenshots skill).\n capture: {\n baseUrl: 'http://127.0.0.1:5173',\n query: 'lang={lang}&demo=1',\n shots: {\n 'home': { path: '/', delayMs: 1000, device: 'both' },\n 'detail': { path: '/lists/1', delayMs: 1000, device: 'both' },\n 'share': { path: '/lists/1/share', delayMs: 1000, device: 'both' },\n 'settings': { path: '/settings', delayMs: 1000, device: 'both' },\n },\n },\n },\n\n icons: {\n composite: 'store/icon/icon.png',\n foreground: 'store/icon/foreground.png',\n background: 'store/icon/background.png',\n out: 'store/icons',\n },\n};\n\nexport default config;\n", "shots-copy.json": "{\n \"en\": {\n \"hero\": { \"title\": [\"Tap. Match.\", \"Repeat!\"], \"badges\": [[\"100+ levels\", \"and more every update\"], [\"Play offline\", \"No account needed\"]] },\n \"play\": { \"eyebrow\": \"Play\", \"title\": [\"Easy to learn,\", \"hard to put down\"], \"sub\": \"One thumb, short rounds, big satisfying moments.\", \"badges\": [[\"Quick rounds\", \"Two minutes each\"], [\"Great!\"]] },\n \"levels\": { \"eyebrow\": \"Worlds\", \"title\": [\"Explore new\", \"worlds\"], \"sub\": \"A fresh look and new rules every ten levels.\", \"badges\": [[\"10 worlds\", \"A new one every 10 levels\"]] },\n \"stars\": { \"eyebrow\": \"Stars\", \"title\": [\"Play smart,\", \"grab 3 stars\"], \"sub\": \"Finish with moves to spare and collect every star.\", \"badges\": [[\"3 stars\"]] }\n },\n \"tr\": {\n \"hero\": { \"title\": [\"Dokun. Eşleştir.\", \"Tekrarla!\"], \"badges\": [[\"100+ bölüm\", \"her güncellemede yenileri\"], [\"İnternetsiz\", \"hesap gerekmez\"]] },\n \"play\": { \"eyebrow\": \"Oyna\", \"title\": [\"Öğrenmesi kolay,\", \"bırakması zor\"], \"sub\": \"Tek parmak, kısa turlar, büyük keyifli anlar.\", \"badges\": [[\"Kısa turlar\", \"Her biri iki dakika\"], [\"Harika!\"]] },\n \"levels\": { \"eyebrow\": \"Dünyalar\", \"title\": [\"Yeni dünyaları\", \"keşfet\"], \"sub\": \"Her on bölümde yeni bir görünüm ve yeni kurallar.\", \"badges\": [[\"10 dünya\", \"Her 10 bölümde yeni biri\"]] },\n \"stars\": { \"eyebrow\": \"Yıldızlar\", \"title\": [\"Akıllı oyna,\", \"3 yıldızı kap\"], \"sub\": \"Hamle artırarak bitir, tüm yıldızları topla.\", \"badges\": [[\"3 yıldız\"]] }\n },\n \"es\": {\n \"hero\": { \"title\": [\"Toca. Combina.\", \"¡Repite!\"], \"badges\": [[\"100+ niveles\", \"y más en cada versión\"], [\"Sin conexión\", \"Sin cuenta\"]] },\n \"play\": { \"eyebrow\": \"Juega\", \"title\": [\"Fácil de aprender,\", \"difícil de soltar\"], \"sub\": \"Un pulgar, rondas cortas, grandes momentos.\", \"badges\": [[\"Rondas cortas\", \"Dos minutos cada una\"], [\"¡Genial!\"]] },\n \"levels\": { \"eyebrow\": \"Mundos\", \"title\": [\"Explora\", \"nuevos mundos\"], \"sub\": \"Un aspecto nuevo y nuevas reglas cada diez niveles.\", \"badges\": [[\"10 mundos\", \"Uno nuevo cada 10 niveles\"]] },\n \"stars\": { \"eyebrow\": \"Estrellas\", \"title\": [\"Juega con cabeza,\", \"logra 3 estrellas\"], \"sub\": \"Termina con movimientos de sobra y consigue todas las estrellas.\", \"badges\": [[\"3 estrellas\"]] }\n },\n \"ja\": {\n \"hero\": { \"title\": [\"タップして、そろえて、\", \"もう一回!\"], \"badges\": [[\"100以上のステージ\", \"アップデートで追加\"], [\"オフラインで遊べる\", \"アカウント不要\"]] },\n \"play\": { \"eyebrow\": \"プレイ\", \"title\": [\"かんたんなのに\", \"やめられない\"], \"sub\": \"片手で遊べる短いラウンド、気持ちいい瞬間がたくさん。\", \"badges\": [[\"短いラウンド\", \"1回2分\"], [\"すごい!\"]] },\n \"levels\": { \"eyebrow\": \"ワールド\", \"title\": [\"新しいワールドを\", \"冒険しよう\"], \"sub\": \"10ステージごとに新しい見た目と新しいルール。\", \"badges\": [[\"10のワールド\", \"10ステージごとに追加\"]] },\n \"stars\": { \"eyebrow\": \"スター\", \"title\": [\"かしこく遊んで\", \"星を3つゲット\"], \"sub\": \"手数を残してクリアし、すべての星を集めよう。\", \"badges\": [[\"星3つ\"]] }\n },\n \"ar\": {\n \"hero\": { \"title\": [\"انقر. طابق.\", \"كرّر!\"], \"badges\": [[\"أكثر من 100 مرحلة\", \"والمزيد مع كل تحديث\"], [\"العب دون اتصال\", \"دون حساب\"]] },\n \"play\": { \"eyebrow\": \"العب\", \"title\": [\"سهلة التعلّم،\", \"صعبة التوقف\"], \"sub\": \"إبهام واحد، جولات قصيرة، ولحظات ممتعة.\", \"badges\": [[\"جولات قصيرة\", \"دقيقتان لكل جولة\"], [\"رائع!\"]] },\n \"levels\": { \"eyebrow\": \"العوالم\", \"title\": [\"استكشف\", \"عوالم جديدة\"], \"sub\": \"شكل جديد وقواعد جديدة كل عشر مراحل.\", \"badges\": [[\"10 عوالم\", \"عالم جديد كل 10 مراحل\"]] },\n \"stars\": { \"eyebrow\": \"النجوم\", \"title\": [\"العب بذكاء،\", \"واجمع 3 نجوم\"], \"sub\": \"أنهِ المرحلة بحركات متبقية واجمع كل النجوم.\", \"badges\": [[\"3 نجوم\"]] }\n }\n}\n", - "storebound.config.ts": "// storebound config for one game. Everything game-specific lives here; credentials NEVER do (they go in env vars or\n// .storebound/credentials.json, which must stay gitignored). Paths are relative to this file.\n// The type import is optional: it only works when storebound is installed in the project (bun add -d ).\nimport type { ShipConfig } from 'storebound';\n\nconst config: ShipConfig = {\n // Every compliance answer below is the TEMPLATE's, not yours. While an area is listed here, check / validate block\n // and nothing sends it to a store. Derive the answers from the code (storebound scan --write; for an app that is\n // already live: storebound onboard --declarations), answer the rest, then remove the area from this list.\n unanswered: ['dataSafety', 'appPrivacy', 'reviewNotes', 'ageRating'],\n\n app: {\n name: 'Example Game',\n profile: 'game',\n copyright: '2026 Example Studio',\n privacyPolicyUrl: 'https://example.com/privacy',\n supportUrl: 'https://example.com',\n marketingUrl: 'https://example.com',\n // Developer website on Play: the same domain must serve /app-ads.txt if the game shows ads\n website: 'https://example.com',\n contactEmail: 'support@example.com',\n },\n\n // id = your folder / copy key; play = Play language code(s); apple = App Store Connect locale\n languages: [\n { id: 'en', play: 'en-US', apple: 'en-US' },\n { id: 'tr', play: 'tr-TR', apple: 'tr' },\n { id: 'es', play: ['es-ES', 'es-419'], apple: 'es-ES' },\n { id: 'ja', play: 'ja-JP', apple: 'ja' },\n { id: 'ar', play: 'ar', apple: 'ar-SA', rtl: true },\n ],\n\n play: {\n packageName: 'com.example.game',\n defaultLanguage: 'en-US',\n listing: 'store/listing.md',\n icon: 'store/icons/play-512.png',\n // read by `storebound scan` (fill in what is true for your account and app):\n // accountType: 'personal', accountCreated: 'YYYY-MM-DD', // new personal accounts: 12 testers x 14 days closed test\n // targetAges: ['13-15', '16-17', '18+'], // App content > Target audience; under 13 = Families policy\n },\n\n apple: {\n bundleId: 'com.example.game',\n // name: 'Example: Short Tagline', // App Store name (max 30) when app.name is taken there (\"already being used\"); default app.name\n // appId: '1234567890', // Apple ID of the app record; looked up by bundle id when left out\n listing: 'store/appstore.md',\n reviewShots: 'store/iap-review',\n // Export compliance (export-compliance skill). false ONLY if the game uses no encryption beyond the OS (HTTPS through\n // the system). Left unset on purpose: decide it, then uncomment. Same answer as ITSAppUsesNonExemptEncryption.\n // usesNonExemptEncryption: false,\n // ipad: true, // the app runs on iPad: 13\" iPad screenshots (2064 x 2752, ipad13) are rendered, checked and uploaded\n // iphoneMedium: true, // optional 6.3\" iPhone set (1206 x 2622, iphone-medium); off: Apple scales the 6.5\" set down\n // screenshotTypes: ['APP_IPHONE_67'], // what `storebound validate` requires per locale (default; + APP_IPAD_PRO_3GEN_129 with ipad)\n // TestFlight (`storebound apple testflight`, testflight skill). Tester emails live in .storebound/testers.csv only.\n testflight: {\n groups: [\n { name: 'Team', internal: true, allBuilds: true },\n { name: 'Friends' },\n ],\n description: { en: 'Example Game beta: try the new levels and tell us what feels too hard.' },\n },\n info: {\n categories: { primary: 'GAMES', primarySubOne: 'GAMES_CASUAL', primarySubTwo: 'GAMES_PUZZLE', secondary: 'ENTERTAINMENT' },\n ageRating: { advertising: true }, // everything else NONE / false\n contentRights: 'DOES_NOT_USE_THIRD_PARTY_CONTENT',\n free: true,\n reviewNotes: [\n 'Single-player game. No account or login is needed; there is no user-generated content or chat.',\n 'In-app purchases: Home > Shop. Restore Purchases is in Settings.',\n 'Ads: optional rewarded videos and interstitials between rounds. The App Tracking Transparency prompt is shown before the ad SDK starts.',\n 'Privacy policy: Settings > Privacy.',\n ].join('\\n\\n'),\n },\n },\n\n // Price changes later for many products / regions: storebound prices export, edit store/prices.csv, prices import --dry.\n // prices: { csv: 'store/prices.csv', baseTerritory: 'USA' },\n\n // Same ids on both stores. Prices: USD base; Play converts per region, Apple equalizes from the USA. Overrides set a\n // region by hand (Apple needs an existing price point with exactly that value).\n products: [\n {\n id: 'com.example.game.remove_ads', type: 'non_consumable', usd: 2.99,\n overrides: [{ region: 'TR', currency: 'TRY', price: 99.99 }],\n listings: {\n en: { title: 'Remove Ads', description: 'Removes interstitial ads for good.' },\n tr: { title: 'Reklamları Kaldır', description: 'Ara reklamları kalıcı olarak kaldırır.' },\n },\n reviewScreenshot: 'store/iap-review/shop.png',\n },\n {\n id: 'com.example.game.coins_small', type: 'consumable', usd: 0.99,\n listings: {\n en: { title: '500 Coins', description: 'A small bag of coins.' },\n tr: { title: '500 Altın', description: 'Küçük bir kese altın.' },\n },\n reviewScreenshot: 'store/iap-review/shop.png',\n },\n ],\n\n // Google Play Data safety. Types not listed = not collected. Purposes: functionality, analytics, communications,\n // fraud, advertising, personalization, account. Typical F2P game with an analytics SDK + ad mediation:\n dataSafety: {\n collectsData: true,\n encryptedInTransit: true,\n // No accounts: PSL_ACM_NONE. A guest account with a name picked on the device and a server token (no password, no\n // email): accounts { enabled: true, signUp: ['username'], ... } and ['PSL_ACM_USER_ID_OTHER_AUTH'] here (Play's\n // \"Username and other authentication\"; `storebound scan` proposes it). With accounts Play also requires the\n // delete-account URL (from accounts.deletionUrl). That page also answers \"can users request that their data is\n // deleted?\" with yes (dataDeletion: 'yes' | 'no' | 'auto90' only for apps without one). partialDeletion: true +\n // partialDeletionUrl when a page deletes data without deleting the account. `storebound check` compares every row with Play's\n // current form (cli/data/play-data-safety-form.csv).\n accountCreation: ['PSL_ACM_NONE'],\n data: {\n PSL_APPROX_LOCATION: { shared: true, collect: ['analytics', 'advertising', 'fraud'], share: ['analytics', 'advertising', 'fraud'] },\n PSL_USER_INTERACTION: { shared: true, collect: ['analytics', 'advertising'], share: ['analytics', 'advertising'] },\n PSL_OTHER_APP_ACTIVITY: { shared: false, collect: ['functionality'] },\n PSL_PERFORMANCE_DIAGNOSTICS: { shared: true, collect: ['analytics', 'advertising', 'fraud'], share: ['analytics', 'advertising', 'fraud'] },\n PSL_DEVICE_ID: { shared: true, collect: ['functionality', 'analytics', 'advertising', 'fraud'], share: ['analytics', 'advertising', 'fraud'] },\n PSL_PURCHASE_HISTORY: { shared: false, collect: ['functionality', 'fraud'] },\n },\n },\n\n // Ads: `storebound validate --play` checks /app-ads.txt for these lines (copy them from your mediation\n // dashboard's app-ads.txt page). Placeholder line below; replace it.\n // ads: { appAdsTxt: ['example-network.com, pub-0000000000000000, DIRECT, 0000000000000000'] },\n\n renders: 'store/renders',\n\n shots: {\n copy: 'store/shots/copy.json',\n frames: 'store/shots/frames', // /phone/.png, /tablet/.png\n out: 'store/renders',\n logo: 'store/shots/logo.png', // optional: hero scene + feature graphic\n // Google Fonts; both cover Latin Extended (tr, pl, cs, ro ...) and Vietnamese. `shots fonts` checks every glyph of the\n // copy; a character the font lacks needs a font that has it or fallback: ['Noto Sans Display'] (else shots check fails).\n fonts: { title: 'Paytone One', body: 'Montserrat' },\n // phone: { w: 440, h: 902, dpr: 3 }, // raw frame size in CSS px (screen below a 54 px status bar)\n // tablet: { w: 1032, h: 1352, dpr: 2 }, // default with apple.ipad (13\" iPad below its 24 px bar), else 800 x 1240\n scenes: [\n { id: 'hero', palette: 'fire', hero: true, over: 34, frames: [{ src: 'hero.png', rotate: -4 }],\n badges: [{ y: 0.42, side: 'l', icon: 'flag', iconBg: 'red', rot: -4 }, { y: 0.66, side: 'r', icon: 'offline', iconBg: 'red', rot: 3 }] },\n { id: 'play', palette: 'violet', frames: [{ src: 'gameplay.png', rotate: 3 }],\n badges: [{ y: 0.3, side: 'l', icon: 'bolt', iconBg: 'gold', rot: -3 }, { y: 0.7, side: 'r', hot: true, rot: 4 }] },\n { id: 'levels', palette: 'sky', frames: [{ src: 'level-b.png', scale: 0.82 }, { src: 'level-a.png', x: -31, dy: 10, scale: 0.7, rotate: -8 }, { src: 'level-c.png', x: 31, dy: 10, scale: 0.7, rotate: 8 }],\n badges: [{ y: 0.66, side: 'r', icon: 'map', iconBg: 'blue', rot: 3 }] },\n { id: 'stars', palette: 'gold', frames: [{ src: 'results.png', rotate: -3 }],\n badges: [{ y: 0.42, side: 'l', stars: true, rot: -3 }] },\n ],\n feature: { frame: 'hero.png', palette: 'fire' },\n capture: {\n baseUrl: 'http://127.0.0.1:5173',\n query: 'lang={lang}&noads=1&seed=1',\n shots: {\n 'hero': { path: '/?level=12', waitFor: \"window.game && game.state === 'playing'\", delayMs: 1500, device: 'both' },\n 'gameplay': { path: '/?level=3', delayMs: 2500, device: 'both' },\n 'level-a': { path: '/?level=20', delayMs: 2000 },\n 'level-b': { path: '/?level=40', delayMs: 2000 },\n 'level-c': { path: '/?level=60', delayMs: 2000 },\n 'results': { path: '/?level=5&result=3', delayMs: 2000, device: 'both' },\n },\n },\n },\n\n icons: {\n composite: 'store/icon/icon.png', // 1024 opaque\n foreground: 'store/icon/foreground.png', // 1024 RGBA, subject on transparent\n background: 'store/icon/background.png', // 1024 opaque\n out: 'store/icons',\n web: 'public/icons',\n // capacitorAssets: 'native/assets',\n // capacitorCommand: 'bunx @capacitor/assets generate --assetPath native/assets',\n // androidRes: 'native/android/app/src/main/res',\n },\n};\n\nexport default config;\n", + "storebound.config.ts": "// storebound config for one game. Everything game-specific lives here; credentials NEVER do (they go in env vars or\n// .storebound/credentials.json, which must stay gitignored). Paths are relative to this file.\n// The type import is optional: it only works when storebound is installed in the project (bun add -d ).\nimport type { ShipConfig } from 'storebound';\n\nconst config: ShipConfig = {\n // Every compliance answer below is the TEMPLATE's, not yours. While an area is listed here, check / validate block\n // and nothing sends it to a store. Derive the answers from the code (storebound scan --write; for an app that is\n // already live: storebound onboard --declarations), answer the rest, then remove the area from this list.\n unanswered: ['dataSafety', 'appPrivacy', 'reviewNotes', 'ageRating'],\n\n app: {\n name: 'Example Game',\n profile: 'game',\n copyright: '2026 Example Studio',\n privacyPolicyUrl: 'https://example.com/privacy',\n supportUrl: 'https://example.com',\n marketingUrl: 'https://example.com',\n // Developer website on Play: the same domain must serve /app-ads.txt if the game shows ads\n website: 'https://example.com',\n contactEmail: 'support@example.com',\n },\n\n // id = your folder / copy key; play = Play language code(s); apple = App Store Connect locale\n languages: [\n { id: 'en', play: 'en-US', apple: 'en-US' },\n { id: 'tr', play: 'tr-TR', apple: 'tr' },\n { id: 'es', play: ['es-ES', 'es-419'], apple: 'es-ES' },\n { id: 'ja', play: 'ja-JP', apple: 'ja' },\n { id: 'ar', play: 'ar', apple: 'ar-SA', rtl: true },\n ],\n\n play: {\n packageName: 'com.example.game',\n defaultLanguage: 'en-US',\n listing: 'store/listing.md',\n icon: 'store/icons/play-512.png',\n // read by `storebound scan` (fill in what is true for your account and app):\n // accountType: 'personal', accountCreated: 'YYYY-MM-DD', // new personal accounts: 12 testers x 14 days closed test\n // targetAges: ['13-15', '16-17', '18+'], // App content > Target audience; under 13 = Families policy\n },\n\n apple: {\n bundleId: 'com.example.game',\n // name: 'Example: Short Tagline', // App Store name (max 30) when app.name is taken there (\"already being used\"); default app.name\n // appId: '1234567890', // Apple ID of the app record; looked up by bundle id when left out\n listing: 'store/appstore.md',\n reviewShots: 'store/iap-review',\n // Export compliance (export-compliance skill). false ONLY if the game uses no encryption beyond the OS (HTTPS through\n // the system). Left unset on purpose: decide it, then uncomment. Same answer as ITSAppUsesNonExemptEncryption.\n // usesNonExemptEncryption: false,\n // ipad: true, // the app runs on iPad: 13\" iPad screenshots (2064 x 2752, ipad13) are rendered, checked and uploaded\n // iphoneMedium: true, // optional 6.3\" iPhone set (1206 x 2622, iphone-medium); off: Apple scales the 6.5\" set down\n // screenshotTypes: ['APP_IPHONE_67'], // what `storebound validate` requires per locale (default; + APP_IPAD_PRO_3GEN_129 with ipad)\n // TestFlight (`storebound apple testflight`, testflight skill). Tester emails live in .storebound/testers.csv only.\n testflight: {\n groups: [\n { name: 'Team', internal: true, allBuilds: true },\n { name: 'Friends' },\n ],\n description: { en: 'Example Game beta: try the new levels and tell us what feels too hard.' },\n },\n info: {\n categories: { primary: 'GAMES', primarySubOne: 'GAMES_CASUAL', primarySubTwo: 'GAMES_PUZZLE', secondary: 'ENTERTAINMENT' },\n ageRating: { advertising: true }, // everything else NONE / false\n contentRights: 'DOES_NOT_USE_THIRD_PARTY_CONTENT',\n free: true,\n reviewNotes: [\n 'Single-player game. No account or login is needed; there is no user-generated content or chat.',\n 'In-app purchases: Home > Shop. Restore Purchases is in Settings.',\n 'Ads: optional rewarded videos and interstitials between rounds. The App Tracking Transparency prompt is shown before the ad SDK starts.',\n 'Privacy policy: Settings > Privacy.',\n // With remote config, feature flags or a remotely changed paywall (storebound scan warns, 2.3.1) you may add:\n // 'Shop offers and prices shown come from and change remotely; no feature is switched on or off after review.',\n ].join('\\n\\n'),\n },\n },\n\n // Price changes later for many products / regions: storebound prices export, edit store/prices.csv, prices import --dry.\n // prices: { csv: 'store/prices.csv', baseTerritory: 'USA' },\n\n // Same ids on both stores. Prices: USD base; Play converts per region, Apple equalizes from the USA. Overrides set a\n // region by hand (Apple needs an existing price point with exactly that value).\n products: [\n {\n id: 'com.example.game.remove_ads', type: 'non_consumable', usd: 2.99,\n overrides: [{ region: 'TR', currency: 'TRY', price: 99.99 }],\n listings: {\n en: { title: 'Remove Ads', description: 'Removes interstitial ads for good.' },\n tr: { title: 'Reklamları Kaldır', description: 'Ara reklamları kalıcı olarak kaldırır.' },\n },\n reviewScreenshot: 'store/iap-review/shop.png',\n },\n {\n id: 'com.example.game.coins_small', type: 'consumable', usd: 0.99,\n listings: {\n en: { title: '500 Coins', description: 'A small bag of coins.' },\n tr: { title: '500 Altın', description: 'Küçük bir kese altın.' },\n },\n reviewScreenshot: 'store/iap-review/shop.png',\n },\n ],\n\n // Google Play Data safety. Types not listed = not collected. Purposes: functionality, analytics, communications,\n // fraud, advertising, personalization, account. Typical F2P game with an analytics SDK + ad mediation:\n dataSafety: {\n collectsData: true,\n encryptedInTransit: true,\n // No accounts: PSL_ACM_NONE. A guest account with a name picked on the device and a server token (no password, no\n // email): accounts { enabled: true, signUp: ['username'], ... } and ['PSL_ACM_USER_ID_OTHER_AUTH'] here (Play's\n // \"Username and other authentication\"; `storebound scan` proposes it). With accounts Play also requires the\n // delete-account URL (from accounts.deletionUrl). That page also answers \"can users request that their data is\n // deleted?\" with yes (dataDeletion: 'yes' | 'no' | 'auto90' only for apps without one). partialDeletion: true +\n // partialDeletionUrl when a page deletes data without deleting the account. `storebound check` compares every row with Play's\n // current form (cli/data/play-data-safety-form.csv).\n accountCreation: ['PSL_ACM_NONE'],\n data: {\n PSL_APPROX_LOCATION: { shared: true, collect: ['analytics', 'advertising', 'fraud'], share: ['analytics', 'advertising', 'fraud'] },\n PSL_USER_INTERACTION: { shared: true, collect: ['analytics', 'advertising'], share: ['analytics', 'advertising'] },\n PSL_OTHER_APP_ACTIVITY: { shared: false, collect: ['functionality'] },\n PSL_PERFORMANCE_DIAGNOSTICS: { shared: true, collect: ['analytics', 'advertising', 'fraud'], share: ['analytics', 'advertising', 'fraud'] },\n PSL_DEVICE_ID: { shared: true, collect: ['functionality', 'analytics', 'advertising', 'fraud'], share: ['analytics', 'advertising', 'fraud'] },\n PSL_PURCHASE_HISTORY: { shared: false, collect: ['functionality', 'fraud'] },\n },\n },\n\n // Ads: `storebound validate --play` checks /app-ads.txt for these lines (copy them from your mediation\n // dashboard's app-ads.txt page). Placeholder line below; replace it.\n // ads: { appAdsTxt: ['example-network.com, pub-0000000000000000, DIRECT, 0000000000000000'] },\n\n renders: 'store/renders',\n\n shots: {\n copy: 'store/shots/copy.json',\n frames: 'store/shots/frames', // /phone/.png, /tablet/.png\n out: 'store/renders',\n logo: 'store/shots/logo.png', // optional: hero scene + feature graphic\n // Google Fonts; both cover Latin Extended (tr, pl, cs, ro ...) and Vietnamese. `shots fonts` checks every glyph of the\n // copy; a character the font lacks needs a font that has it or fallback: ['Noto Sans Display'] (else shots check fails).\n fonts: { title: 'Paytone One', body: 'Montserrat' },\n // phone: { w: 440, h: 902, dpr: 3 }, // raw frame size in CSS px (screen below a 54 px status bar)\n // tablet: { w: 1032, h: 1352, dpr: 2 }, // default with apple.ipad (13\" iPad below its 24 px bar), else 800 x 1240\n scenes: [\n { id: 'hero', palette: 'fire', hero: true, over: 34, frames: [{ src: 'hero.png', rotate: -4 }],\n badges: [{ y: 0.42, side: 'l', icon: 'flag', iconBg: 'red', rot: -4 }, { y: 0.66, side: 'r', icon: 'offline', iconBg: 'red', rot: 3 }] },\n { id: 'play', palette: 'violet', frames: [{ src: 'gameplay.png', rotate: 3 }],\n badges: [{ y: 0.3, side: 'l', icon: 'bolt', iconBg: 'gold', rot: -3 }, { y: 0.7, side: 'r', hot: true, rot: 4 }] },\n { id: 'levels', palette: 'sky', frames: [{ src: 'level-b.png', scale: 0.82 }, { src: 'level-a.png', x: -31, dy: 10, scale: 0.7, rotate: -8 }, { src: 'level-c.png', x: 31, dy: 10, scale: 0.7, rotate: 8 }],\n badges: [{ y: 0.66, side: 'r', icon: 'map', iconBg: 'blue', rot: 3 }] },\n { id: 'stars', palette: 'gold', frames: [{ src: 'results.png', rotate: -3 }],\n badges: [{ y: 0.42, side: 'l', stars: true, rot: -3 }] },\n ],\n feature: { frame: 'hero.png', palette: 'fire' },\n capture: {\n baseUrl: 'http://127.0.0.1:5173',\n query: 'lang={lang}&noads=1&seed=1',\n shots: {\n 'hero': { path: '/?level=12', waitFor: \"window.game && game.state === 'playing'\", delayMs: 1500, device: 'both' },\n 'gameplay': { path: '/?level=3', delayMs: 2500, device: 'both' },\n 'level-a': { path: '/?level=20', delayMs: 2000 },\n 'level-b': { path: '/?level=40', delayMs: 2000 },\n 'level-c': { path: '/?level=60', delayMs: 2000 },\n 'results': { path: '/?level=5&result=3', delayMs: 2000, device: 'both' },\n },\n },\n },\n\n icons: {\n composite: 'store/icon/icon.png', // 1024 opaque\n foreground: 'store/icon/foreground.png', // 1024 RGBA, subject on transparent\n background: 'store/icon/background.png', // 1024 opaque\n out: 'store/icons',\n web: 'public/icons',\n // capacitorAssets: 'native/assets',\n // capacitorCommand: 'bunx @capacitor/assets generate --assetPath native/assets',\n // androidRes: 'native/android/app/src/main/res',\n },\n};\n\nexport default config;\n", "testers.csv": "email,first_name,last_name,groups\n", "web/appstore.svg": "App Store", "web/googleplay.svg": "Google Play", diff --git a/cli/lib/scan/rules.ts b/cli/lib/scan/rules.ts index 861722a..1757172 100644 --- a/cli/lib/scan/rules.ts +++ b/cli/lib/scan/rules.ts @@ -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, c: ShipConfig | null, stores: { apple: boolean; play: boolean }): ScanFinding[] { + const out: ScanFinding[] = []; + const found = new Map(); + 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 }); diff --git a/cli/lib/scan/sdks.ts b/cli/lib/scan/sdks.ts index 36de524..078776d 100644 --- a/cli/lib/scan/sdks.ts +++ b/cli/lib/scan/sdks.ts @@ -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>; + /** 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> }[] = [ + { 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): boolean { const list = s.match[d.eco] ?? []; const n = d.name; return list.some(m => { diff --git a/docs/pitfalls.md b/docs/pitfalls.md index 638eba3..85d33d1 100644 --- a/docs/pitfalls.md +++ b/docs/pitfalls.md @@ -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. | diff --git a/skills/review-rejections/SKILL.md b/skills/review-rejections/SKILL.md index bf928e0..2648094 100644 --- a/skills/review-rejections/SKILL.md +++ b/skills/review-rejections/SKILL.md @@ -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 diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index 28a176b..8458b2d 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -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`. diff --git a/skills/store-subscriptions/SKILL.md b/skills/store-subscriptions/SKILL.md index 88c223c..ca2179e 100644 --- a/skills/store-subscriptions/SKILL.md +++ b/skills/store-subscriptions/SKILL.md @@ -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. diff --git a/templates/profiles/app/storebound.config.ts b/templates/profiles/app/storebound.config.ts index 8286c34..f5db7e0 100644 --- a/templates/profiles/app/storebound.config.ts +++ b/templates/profiles/app/storebound.config.ts @@ -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 : ; none is switched on after review without a new submission.', ].join('\n\n'), }, }, diff --git a/templates/profiles/subscription-app/storebound.config.ts b/templates/profiles/subscription-app/storebound.config.ts index 8af8a0f..4277289 100644 --- a/templates/profiles/subscription-app/storebound.config.ts +++ b/templates/profiles/subscription-app/storebound.config.ts @@ -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 ; what Pro unlocks does not change after review.', ].join('\n\n'), }, }, diff --git a/templates/storebound.config.ts b/templates/storebound.config.ts index 839589a..aceb998 100644 --- a/templates/storebound.config.ts +++ b/templates/storebound.config.ts @@ -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 and change remotely; no feature is switched on or off after review.', ].join('\n\n'), }, }, diff --git a/tests/remote-features.test.ts b/tests/remote-features.test.ts new file mode 100644 index 0000000..7c14b33 --- /dev/null +++ b/tests/remote-features.test.ts @@ -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']); + }); +});