From 7219ded3e87fabc99bac0e979ce9871304634f85 Mon Sep 17 00:00:00 2001 From: CRT Date: Wed, 14 Oct 2026 11:36:30 +0800 Subject: [PATCH] template compliance answers never reach a store: init writes unanswered (dataSafety, appPrivacy, reviewNotes, ageRating, accounts, reviewAccess), older configs are recognised by the template values; check / validate / scan block, play data-safety (also --dry), console plan / fill, apple review-details and appinfo refuse. onboard --declarations replaces them on a live app: name and copyright from the stores (a copyright with a private term is left out), the rows the code proves (scan proposal, sign-up methods from auth code and login SDKs), the rest as one owner question list. play data-safety --dry prints the answers that change (old -> new, account and deletion first) against the last uploaded CSV; pitfall 60; tests --- cli/api/wrapped.gen.ts | 1 + cli/apple/appinfo.ts | 2 + cli/apple/review-details.ts | 2 + cli/check.ts | 3 + cli/console/fill.ts | 2 + cli/console/plan.ts | 2 + cli/data/flags.json | 8 +- cli/lib/config.ts | 14 ++ cli/lib/data-safety.ts | 22 +++ cli/lib/embedded.ts | 6 +- cli/lib/onboard-declarations.ts | 161 ++++++++++++++++++ cli/lib/scan/config-edit.ts | 69 ++++++++ cli/lib/scan/index.ts | 3 +- cli/lib/scan/rules.ts | 13 ++ cli/lib/template-answers.ts | 145 ++++++++++++++++ cli/onboard.ts | 45 ++++- cli/play/data-safety.ts | 26 ++- cli/validate.ts | 3 + docs/api-coverage.md | 4 +- examples/demo-app/storebound.config.ts | 1 + examples/demo-game/storebound.config.ts | 1 + skills/store-pitfalls/SKILL.md | 12 ++ templates/profiles/app/storebound.config.ts | 5 + .../subscription-app/storebound.config.ts | 5 + templates/storebound.config.ts | 5 + tests/teaser.test.ts | 3 +- tests/template-answers.test.ts | 115 +++++++++++++ 27 files changed, 659 insertions(+), 19 deletions(-) create mode 100644 cli/lib/onboard-declarations.ts create mode 100644 cli/lib/template-answers.ts create mode 100644 tests/template-answers.test.ts diff --git a/cli/api/wrapped.gen.ts b/cli/api/wrapped.gen.ts index 9dd955b..f04325b 100644 --- a/cli/api/wrapped.gen.ts +++ b/cli/api/wrapped.gen.ts @@ -221,6 +221,7 @@ export const WRAPPED: { apple: Record; play: Record { const { config, root } = ctx.loaded; const info = config.apple?.info ?? {}; const dry = ctx.args.has('dry'); + refuseTemplate(config, ['ageRating'], 'apple appinfo (age rating declaration)'); const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId); const appId = await c.app(); const p = await planAppInfo(ctx, c, appId); diff --git a/cli/apple/review-details.ts b/cli/apple/review-details.ts index fe2ec6f..11f56bc 100644 --- a/cli/apple/review-details.ts +++ b/cli/apple/review-details.ts @@ -5,6 +5,7 @@ // Demo account (reviewAccess.signInRequired: true): "reviewDemoAccount" in .storebound/credentials.json or // REVIEW_DEMO_USER / REVIEW_DEMO_PASSWORD; masked in every log line. Notes: apple.info.reviewNotes plus // reviewAccess.instructions and accounts.inApp (lib/review-details.ts). +import { refuseTemplate } from '../lib/template-answers'; import type { Ctx } from '../lib/ctx'; import { need } from '../lib/ctx'; import { reviewContact, reviewDemoAccount } from '../lib/credentials'; @@ -15,6 +16,7 @@ import { ascClient } from './asc'; export async function run(ctx: Ctx): Promise { const { config, root } = ctx.loaded; + refuseTemplate(config, ['reviewNotes', 'reviewAccess', 'accounts'], 'apple review-details (App Review notes, demo account requirement)'); const c = await ascClient(root, need(config.apple?.bundleId, 'apple.bundleId'), config.apple?.appId); const version = await c.editableVersion(); if (!version) throw new Error('no editable App Store version (apple listing --version x.y.z creates it)'); diff --git a/cli/check.ts b/cli/check.ts index 381a6ad..f94ed37 100644 --- a/cli/check.ts +++ b/cli/check.ts @@ -4,6 +4,7 @@ // storebound check everything above // storebound check --aso keyword audit of both listing files (App Store keyword field, Play stuffing); exit 1 on errors // --json structured result ({ ok, items, warnings, hints, problems } or { findings, errors }), exit 1 on problems +import { templateBlockers } from './lib/template-answers'; import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs'; import { dirname, join, relative } from 'node:path'; import { appPrivacyMismatches, type PrivacyMismatch, trackingDeclaredIssues, type TrackingSources, typeIdOf } from './lib/app-privacy'; @@ -215,6 +216,8 @@ export async function run(ctx: Ctx): Promise { if (fromStores && renderSources.has(f)) note(`render source not there: ${f} (only needed to render icons / screenshots again; the store art is pulled)`); else problems.push(`missing file: ${f}`); } + // template compliance answers (storebound init) never count as the owner's + for (const b of templateBlockers(config)) problems.push(b); if (existsSync(resolve('.gitignore')) && !/^\/?\.storebound\/?$/m.test(readFileSync(resolve('.gitignore'), 'utf8'))) problems.push('.gitignore does not ignore .storebound/ (credentials live there)'); diff --git a/cli/console/fill.ts b/cli/console/fill.ts index 3689841..cf018dd 100644 --- a/cli/console/fill.ts +++ b/cli/console/fill.ts @@ -27,6 +27,7 @@ // Sources (checked October 2026): see cli/lib/console-answers.ts; Playwright launchPersistentContext, getByRole, // getByLabel: https://playwright.dev/docs/api/class-browsertype#browser-type-launch-persistent-context , // https://playwright.dev/docs/locators +import { FORM_AREAS, refuseTemplate } from '../lib/template-answers'; import { homedir } from 'node:os'; import { join } from 'node:path'; import type { Ctx } from '../lib/ctx'; @@ -905,6 +906,7 @@ export async function run(ctx: Ctx): Promise { return; } if (!isForm(form)) throw new Error(`console fill: unknown form "${form}" (one of ${FORM_IDS.join(', ')})`); + refuseTemplate(config, FORM_AREAS[form] ?? [], `console fill ${form}`); const meta = formMeta(form, config); const store = args.get('store'); if (store && store !== 'apple' && store !== 'play') throw new Error('--store: apple | play'); diff --git a/cli/console/plan.ts b/cli/console/plan.ts index 5020208..846645b 100644 --- a/cli/console/plan.ts +++ b/cli/console/plan.ts @@ -7,6 +7,7 @@ // apple.appId / play.developerId / play.appId, the ids cached by earlier runs (.storebound/console-app-ids.json, // ~/.storebound/console-accounts.json) and the workspace's account ids; --lookup also asks the App Store Connect API // for the app id by bundle id (a GET; needs the API key). Pure logic in cli/lib/console-plan.ts. MCP: console_plan. +import { FORM_AREAS, refuseTemplate } from '../lib/template-answers'; import type { Ctx } from '../lib/ctx'; import { formMeta, isForm, isPlanForm, PLAN_FORM_IDS } from '../lib/console-answers'; import { reviewDemoAccount } from '../lib/credentials'; @@ -28,6 +29,7 @@ export async function run(ctx: Ctx): Promise { return; } if (!isPlanForm(form)) throw new Error(`console plan: unknown form "${form}" (one of ${PLAN_FORM_IDS.join(', ')})`); + refuseTemplate(config, FORM_AREAS[form] ?? [], `console plan ${form}`); const { withAccountIds, readAccounts } = await import('./accounts'); const cfg = withAccountIds(config); const pkg = cfg.play?.packageName; diff --git a/cli/data/flags.json b/cli/data/flags.json index b689fa7..f142fbd 100644 --- a/cli/data/flags.json +++ b/cli/data/flags.json @@ -459,6 +459,7 @@ ], "mcp": [], "onboard": [ + "accounts", "all", "allow-price-changes", "apple", @@ -469,6 +470,7 @@ "cordova-android", "cordova-ios", "csv", + "declarations", "expo", "file", "floor", @@ -476,6 +478,7 @@ "force", "from", "godot", + "identity", "no-art", "no-catalog", "no-subscriptions", @@ -806,6 +809,7 @@ ], "ws": [ "aab", + "accounts", "adgroup", "against", "agent", @@ -887,6 +891,7 @@ "hook", "icon", "icons", + "identity", "include-drafts", "internal", "internal-only", @@ -1260,7 +1265,8 @@ "storebound listing live [--json] | set --store play|apple --lang --fields '' [--dry] the texts straight on" ], "onboard": [ - "storebound onboard [--store apple|play] [--write] [--dry] [--json] [--no-web] [--no-catalog] [--no-art]" + "storebound onboard [--store apple|play] [--write] [--dry] [--json] [--no-web] [--no-catalog] [--no-art]", + "storebound onboard --declarations [--write] only the template compliance answers and identity (no catalog, no art)" ], "originality": [ "storebound originality [--against [,]] [--threshold 0.7] [--json]" diff --git a/cli/lib/config.ts b/cli/lib/config.ts index 17400ae..6b60573 100644 --- a/cli/lib/config.ts +++ b/cli/lib/config.ts @@ -367,6 +367,9 @@ export interface AccountsConfig { signUp?: AccountSignUp[]; } +export type TemplateArea = 'dataSafety' | 'appPrivacy' | 'reviewNotes' | 'ageRating' | 'accounts' | 'reviewAccess'; +export const TEMPLATE_AREAS: readonly TemplateArea[] = ['dataSafety', 'appPrivacy', 'reviewNotes', 'ageRating', 'accounts', 'reviewAccess']; + export type AccountSignUp = 'username' | 'username-password' | 'email-password' | 'email-link' | 'phone' | 'oauth' | 'other'; export const ACCOUNT_SIGN_UPS: readonly AccountSignUp[] = ['username', 'username-password', 'email-password', 'email-link', 'phone', 'oauth', 'other']; @@ -645,6 +648,15 @@ export interface ShipConfig { /** App Store base territory of every price schedule (alpha-3). Default: the current one, else USA. */ baseTerritory?: string; }; + /** + * Compliance answers that are still the template's (`storebound init`) or not confirmed by the owner yet: + * 'dataSafety', 'appPrivacy', 'reviewNotes', 'ageRating', 'accounts', 'reviewAccess'. While an area is listed, check + * and validate report a blocker and nothing that sends it (play data-safety, apple review-details / appinfo, console + * plan / fill) runs. Remove an entry once its answers are the owner's (derived from the code: `storebound scan + * --write`, `storebound onboard --declarations`). `[]` = everything answered. Left out (configs before 0.8.4): the + * template answers are recognised by their values (cli/lib/template-answers.ts). + */ + unanswered?: TemplateArea[]; dataSafety?: DataSafety; /** App Store App Privacy answers storebound cannot derive from dataSafety (App Privacy has no API: the owner enters * them in App Store Connect). `storebound scan` proposes them from the detected SDKs and code. */ @@ -1077,6 +1089,8 @@ export function validateConfig(c: unknown): string[] { e.push(...validateCpp(cfg.apple.customProductPages, ids, Object.keys(cfg.shots?.variants ?? {}))); e.push(...validateEvents(cfg.apple.events, ids)); } + if (cfg.unanswered !== undefined && !(Array.isArray(cfg.unanswered) && cfg.unanswered.every(x => TEMPLATE_AREAS.includes(x)))) + e.push(`unanswered: a list of ${TEMPLATE_AREAS.join(' | ')}`); if (cfg.appPrivacy) { if (cfg.appPrivacy.tracking !== undefined && typeof cfg.appPrivacy.tracking !== 'boolean') e.push('appPrivacy.tracking: true | false'); if (cfg.appPrivacy.trackingDomains && !(Array.isArray(cfg.appPrivacy.trackingDomains) && cfg.appPrivacy.trackingDomains.every(d => typeof d === 'string' && /^[a-z0-9.-]+$/i.test(d)))) diff --git a/cli/lib/data-safety.ts b/cli/lib/data-safety.ts index 7464cca..f1a8fcd 100644 --- a/cli/lib/data-safety.ts +++ b/cli/lib/data-safety.ts @@ -263,3 +263,25 @@ export function dataSafetyProblem(rows: string[][]): string | null { const i = dataSafetyIssues(rows); return i.length ? `Data safety CSV is not complete (Play would answer 400):\n${i.map(x => ` - ${x.msg}`).join('\n')}` : null; } + +export interface DataSafetyChange { question: string; response: string; label: string; from: string; to: string } + +/** + * The answers that change between two Data safety CSVs (Play cannot read the form back, so `before` is the CSV this + * project uploaded last; null = nothing recorded, every answer is new). One entry per (question, response) whose value + * differs; a removed answer shows as old -> (empty). Pure. + */ +export function dataSafetyDiff(before: string | null, rows: string[][]): DataSafetyChange[] { + type V = { q: string; r: string; v: string; label: string }; + const vals = (rs: string[][]) => new Map(rs.slice(1).filter(r => r[0]).map(r => [pairKey(r[0]!, r[1] ?? ''), { q: r[0]!, r: r[1] ?? '', v: r[2] ?? '', label: (r[4] ?? '').trim() }])); + const old = before ? vals(parseCsv(before)) : new Map(); + const cur = vals(rows); + const out: DataSafetyChange[] = []; + for (const k of new Set([...old.keys(), ...cur.keys()])) { + const o = old.get(k), n = cur.get(k); + if ((o?.v ?? '') === (n?.v ?? '')) continue; + const x = (n ?? o)!; + out.push({ question: x.q, response: x.r, label: x.label || (formRow(x.q, x.r)?.label ?? ''), from: o?.v ?? '', to: n?.v ?? '' }); + } + return out; +} diff --git a/cli/lib/embedded.ts b/cli/lib/embedded.ts index 27195a1..9ccef13 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 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 ].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. **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**, 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. 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 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 ].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 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 ].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/onboard-declarations.ts b/cli/lib/onboard-declarations.ts new file mode 100644 index 0000000..4c7254e --- /dev/null +++ b/cli/lib/onboard-declarations.ts @@ -0,0 +1,161 @@ +// `storebound onboard` on an app that is already live, the compliance part: the template answers `storebound init` +// wrote (Data safety, App Privacy, review notes, age rating, accounts, review access) and the template identity are +// taken out, the answers the code proves (the scan's declaration proposal, sign-up methods with evidence) are written +// in, and everything else becomes one owner question list. Never invents an answer (AGENTS.md rule 5). Pure: the +// caller reads the project and the stores. +import type { AccountSignUp, ShipConfig, TemplateArea } from './config'; +import { accountCreationOf } from './declarations'; +import { authEvidence } from './project-signals'; +import { proposeDeclarations } from './scan/declare'; +import { grepOwn, type Hit, type ProjectFacts } from './scan/project'; +import { removeKeyPath, upsertBlock, upsertNestedString } from './scan/config-edit'; +import { SIGNALS } from './scan/rules'; +import { hasPrivateTerm, TEMPLATE_NAMES, templateAreas, templateIdentity, withoutTemplate } from './template-answers'; + +/** What the stores say about the app's identity (public values only). */ +export interface StoreIdentity { name?: string | null; copyright?: string | null; source?: string } + +export interface DeclarationReset { + text: string; + /** template answers taken out (area: what) */ + removed: string[]; + /** answers the code or the stores proved (what: evidence) */ + proven: string[]; + questions: string[]; + /** the areas left in `unanswered` */ + unanswered: TemplateArea[]; + /** the config the rest of onboard works with (template answers out, proven ones in) */ + config: ShipConfig; +} + +const ev = (h: Hit[] | undefined) => (h?.length ? `${h[0]!.file}:${h[0]!.line}` : 'config'); + +/** Sign-up methods the code proves, each with its evidence. Built output alone proves nothing. */ +export function signUpFromCode(p: ProjectFacts): { method: AccountSignUp; evidence: Hit[] }[] { + const out: { method: AccountSignUp; evidence: Hit[] }[] = []; + const own = (re: RegExp) => { const g = grepOwn(p, re, 2); return g.built ? [] : g.hits; }; + const add = (method: AccountSignUp, h: Hit[]) => { if (h.length && !out.some(x => x.method === method)) out.push({ method, evidence: h }); }; + add('email-password', own(/signInWithEmailAndPassword|createUserWithEmailAndPassword|signInWithPassword\(\s*\{\s*email|auth\.signUp\(\s*\{\s*email|loginWithEmail|registerWithEmail/)); + add('email-link', own(/sendSignInLinkToEmail|signInWithOtp\(\s*\{\s*email|magic-link|magicLink/)); + add('phone', own(/signInWithPhoneNumber|verifyPhoneNumber|PhoneAuthProvider|signInWithOtp\(\s*\{\s*phone/)); + // a social login SDK in the dependencies (Google Sign-In, Facebook Login, Sign in with Apple) is OAuth sign-up too + const loginSdk = p.sdks.filter(s => s.sdk.category === 'auth' && (s.sdk.login === 'google' || s.sdk.login === 'facebook' || s.sdk.login === 'apple')) + .map(s => ({ file: s.deps[0]!.file, line: s.deps[0]!.line, text: s.sdk.name })); + add('oauth', [...own(SIGNALS.appleSignIn), ...own(SIGNALS.thirdPartyLogin), ...loginSdk]); + add('username', own(/\bcreate(Guest|Anonymous)(Account|User)\b|\bregisterGuest\b|signInAnonymously/)); + // a password field plus an email field in the own sources, without a recognised SDK call + if (!out.length) { + const pw = own(/autoComplete=["'](current|new)-password|\btype=["']password["']|textContentType=["']?\.?(password|newPassword)\b|secureTextEntry/); + const em = own(SIGNALS.email); + if (pw.length && em.length) add('email-password', [...em.slice(0, 1), ...pw.slice(0, 1)]); + } + return out; +} + +/** + * The config text with the template compliance answers and identity replaced by what the code and the stores prove. + * `terms`: private names (never printed) that must not land in public text such as the copyright. + */ +export function resetDeclarations(o: { config: ShipConfig; text: string; path: string; facts: ProjectFacts; store?: StoreIdentity; terms?: string[] }): DeclarationReset { + const { config, path, facts } = o; + let text = o.text; + const removed: string[] = [], proven: string[] = [], questions: string[] = []; + const found = templateAreas(config); + const areas = new Set(found.map(f => f.area)); + const left = new Set(Array.isArray(config.unanswered) ? config.unanswered : []); + let c = withoutTemplate(config); + + // identity: only the store's public values; a value with a private term is left out, never written + const ids = templateIdentity(config); + if (ids.length) { + const terms = o.terms ?? []; + const src = o.store?.source ?? 'the store'; + if (TEMPLATE_NAMES.has(config.app.name)) { + const n = o.store?.name?.trim(); + if (n && !hasPrivateTerm(n, terms)) { text = upsertNestedString(text, path, 'app', 'name', n); proven.push(`app.name: "${n}" (${src})`); c = { ...c, app: { ...c.app, name: n } }; } + else questions.push('App name: no store has a usable name for it; set app.name'); + removed.push(`app.name "${config.app.name}"`); + } + if (config.app.copyright && /\bExample Studio\b/i.test(config.app.copyright)) { + const cp = o.store?.copyright?.trim(); + removed.push(`app.copyright "${config.app.copyright}"`); + if (cp && !hasPrivateTerm(cp, terms)) { text = upsertNestedString(text, path, 'app', 'copyright', cp); proven.push(`app.copyright: "${cp}" (${src})`); } + else { + text = removeKeyPath(text, path, ['app', 'copyright']); + questions.push(cp ? 'Copyright: the store\'s copyright names a private term, so it was left out of the config; set app.copyright to what may be public (or leave it out)' : 'Copyright: no store has one; set app.copyright (e.g. "2026 ") or leave it out'); + } + } + if (Object.values(config.apple?.testflight?.description ?? {}).some(d => /\bExample (Game|App)\b/.test(d ?? ''))) { + text = removeKeyPath(text, path, ['apple', 'testflight', 'description']); + removed.push('apple.testflight.description (the template\'s beta text)'); + questions.push('TestFlight beta description (what testers should try): set apple.testflight.description or leave it out'); + } + } + + // accounts: from the code (auth signals, sign-up methods), never the template's + const auth = authEvidence(facts); + const methods = signUpFromCode(facts); + const proveAccounts = areas.has('accounts') || (!config.accounts && (auth.length > 0 || methods.length > 0) && (areas.has('dataSafety') || areas.has('identity'))); + if (proveAccounts) { + if (areas.has('accounts')) removed.push('accounts (template: sign-up methods, in-app path, deletion page)'); + const keepUrl = config.accounts?.deletionUrl && !/example\.com/i.test(config.accounts.deletionUrl) ? config.accounts.deletionUrl : undefined; + if (auth.length || methods.length) { + const acc = { enabled: true, ...(keepUrl ? { deletionUrl: keepUrl } : {}), ...(methods.length ? { signUp: methods.map(m => m.method) } : {}) }; + text = upsertBlock(text, path, 'accounts', acc, 'from storebound onboard: sign-in code in the project'); + c = { ...c, accounts: acc }; + proven.push(`accounts.enabled: true (${auth[0] ?? ev(methods[0]?.evidence)})`); + for (const m of methods) proven.push(`accounts.signUp ${m.method} (${ev(m.evidence)})`); + if (!methods.length) { questions.push('How do people sign up (email + password, Sign in with Apple / Google, phone, a username only)? The code has sign-in but no recognisable method: set accounts.signUp'); left.add('accounts'); } + else left.delete('accounts'); + questions.push('Where does account deletion start inside the app (e.g. "Settings > Account > Delete account")? set accounts.inApp'); + } else { + text = removeKeyPath(text, path, ['accounts']); + delete c.accounts; + proven.push('no accounts: no sign-in or sign-up code in the project'); + left.delete('accounts'); + } + } + if (areas.has('reviewAccess')) { + text = removeKeyPath(text, path, ['reviewAccess']); + delete c.reviewAccess; + removed.push('reviewAccess (template: sign-in required, demo account instructions)'); + left.delete('reviewAccess'); // onboard sets signInRequired from the store's review detail and the code, or asks + } + if (areas.has('reviewNotes')) { + text = removeKeyPath(text, path, ['apple', 'info', 'reviewNotes']); + removed.push('apple.info.reviewNotes (template text)'); + questions.push('App Review notes: where is every main feature (and the in-app purchases, if any)? write apple.info.reviewNotes'); + left.delete('reviewNotes'); + } + if (areas.has('ageRating')) { + // kept in the file, but unanswered: dropping it would mean "every content question NONE", which is an answer too + left.add('ageRating'); + questions.push('Age rating (App Store age rating questionnaire and Play IARC): violence, gambling, user-generated content, web access, ads ... answer apple.info.ageRating, then remove ageRating from unanswered'); + } + + // Data safety / App Privacy: the scan's proposal against a config without the template answers + if (areas.has('dataSafety') || areas.has('appPrivacy')) { + removed.push(`dataSafety (template rows: ${Object.keys(config.dataSafety?.data ?? {}).join(', ') || 'none'}; accountCreation ${JSON.stringify(config.dataSafety?.accountCreation ?? ['PSL_ACM_NONE'])})`); + if (config.appPrivacy) removed.push('appPrivacy (derived from the template dataSafety)'); + const p = proposeDeclarations(facts, c, { path, text }); + const data = p.dataSafety?.data ?? {}; + const acm = accountCreationOf(c.accounts?.signUp); + const ds = { collectsData: Object.keys(data).length > 0, ...(acm ? { accountCreation: acm } : {}), data }; + text = upsertBlock(text, path, 'dataSafety', ds, 'from storebound onboard: only the rows the code proves; the owner answers the rest (unanswered)'); + c = { ...c, dataSafety: ds as ShipConfig['dataSafety'] }; + for (const d of p.diff.filter(x => x.change !== 'kept')) proven.push(`dataSafety ${d.type}: collect ${d.after.collect.join(', ')}${d.after.shared ? `; shared for ${(d.after.share ?? []).join(', ')}` : ''} (${ev(d.evidence)})`); + if (acm) proven.push(`dataSafety.accountCreation ${JSON.stringify(acm)} (from accounts.signUp)`); + if (p.appPrivacy) { text = upsertBlock(text, path, 'appPrivacy', p.appPrivacy); c = { ...c, appPrivacy: p.appPrivacy }; } + else text = removeKeyPath(text, path, ['appPrivacy']); + for (const i of p.items.filter(x => x.status === 'ask' && (x.area === 'dataSafety' || x.area === 'appPrivacy'))) + questions.push(`${i.area === 'dataSafety' ? 'Data safety' : 'App Privacy'} ${i.what}: ${i.value}${i.note ? ` (${i.note})` : ''}`); + questions.push('Data safety / App Privacy: does the app send anything else off the device (own backend, analytics, crash reports, support chat)? The rows above are only what the code proves. Then remove dataSafety and appPrivacy from unanswered'); + left.add('dataSafety'); left.add('appPrivacy'); + } + + const unanswered = (['dataSafety', 'appPrivacy', 'reviewNotes', 'ageRating', 'accounts', 'reviewAccess'] as const).filter(a => left.has(a)); + if (found.length || config.unanswered !== undefined) { + text = upsertBlock(text, path, 'unanswered', unanswered, 'compliance answers the owner has not confirmed yet: check / validate block and nothing sends them'); + } + return { text, removed, proven, questions: [...new Set(questions)], unanswered, config: { ...c, unanswered } }; +} diff --git a/cli/lib/scan/config-edit.ts b/cli/lib/scan/config-edit.ts index 9835cec..c0eeca4 100644 --- a/cli/lib/scan/config-edit.ts +++ b/cli/lib/scan/config-edit.ts @@ -132,3 +132,72 @@ function depthAt(s: string, at: number): number { } return d; } + +/** End of the value that starts at `i` in `s`: the comma or closing bracket at depth 0. */ +function valueEnd(s: string, i: number): number { + let d = 0; + for (; i < s.length; i++) { + const ch = s[i]!; + if (ch === '/' && s[i + 1] === '/') { const n = s.indexOf('\n', i); if (n < 0) return s.length; i = n; continue; } + if (ch === '/' && s[i + 1] === '*') { const n = s.indexOf('*/', i + 2); if (n < 0) return s.length; i = n + 1; continue; } + if (ch === '"' || ch === '\'' || ch === '`') { for (i++; i < s.length && s[i] !== ch; i++) if (s[i] === '\\') i++; continue; } + if (ch === '{' || ch === '[' || ch === '(') d++; + else if (ch === '}' || ch === ']' || ch === ')') { if (d === 0) return i; d--; } + else if (ch === ',' && d === 0) return i; + } + return s.length; +} + +/** + * The config text without the property at `path` (['dataSafety'], ['apple', 'info', 'reviewNotes']): the key, its + * value (any expression), its trailing comma and the comment lines right above it. Unchanged when the path is not + * there. .json is parsed and written back. + */ +export function removeKeyPath(text: string, file: string, path: string[]): string { + if (file.endsWith('.json')) { + const j = JSON.parse(text) as Record; + let o: Record | undefined = j; + for (const k of path.slice(0, -1)) o = o?.[k] as Record | undefined; + if (!o || typeof o !== 'object' || !(path.at(-1)! in o)) return text; + delete o[path.at(-1)!]; + return JSON.stringify(j, null, 2) + '\n'; + } + let from = 0, to = text.length; + for (let n = 0; n < path.length; n++) { + const region = text.slice(from, to); + const re = new RegExp(`(^|[{,\\s])${path[n]}\\s*:\\s*`, 'gm'); + let hit: { at: number; val: number } | null = null, bestInd = Infinity; + for (let m = re.exec(region); m; m = re.exec(region)) { + const at = m.index + m[1]!.length; + const lineStart = region.lastIndexOf('\n', at - 1) + 1; + if (region.slice(lineStart, at).includes('//')) continue; // in a comment + if (n > 0) { if (depthAt(region, at) !== 0) continue; hit = { at: from + at, val: from + m.index + m[0].length }; break; } + if (at - lineStart >= bestInd) continue; + hit = { at: from + at, val: from + m.index + m[0].length }; bestInd = at - lineStart; + } + if (!hit) return text; + if (n < path.length - 1) { + if (text[hit.val] !== '{') return text; // the parent is not an object literal + const close = matchBrace(text, hit.val); + if (close < 0) return text; + from = hit.val + 1; to = close; + continue; + } + let end = valueEnd(text, hit.val); + if (text[end] === ',') end++; + let start = hit.at; + const ls = text.lastIndexOf('\n', start - 1) + 1; + if (!text.slice(ls, start).trim()) { + start = ls; + while (start > 0) { + const prev = text.lastIndexOf('\n', start - 2) + 1; + if (!/^\s*\/\//.test(text.slice(prev, start - 1))) break; + start = prev; + } + const nl = /^[ \t]*\n/.exec(text.slice(end)); + if (nl) end += nl[0].length; + } + return text.slice(0, start) + text.slice(end); + } + return text; +} diff --git a/cli/lib/scan/index.ts b/cli/lib/scan/index.ts index ac9bccb..f660501 100644 --- a/cli/lib/scan/index.ts +++ b/cli/lib/scan/index.ts @@ -1,5 +1,6 @@ // `storebound scan` engine: detect the project, run the rejection-risk rules, propose the declarations. Offline and // read-only (the only write is `--write`, done by the command, never here). +import { withoutTemplate } from '../template-answers'; import { existsSync, readFileSync } from 'node:fs'; import { join, relative, resolve } from 'node:path'; import { CONFIG_NAMES, loadConfig, type ShipConfig } from '../config'; @@ -53,7 +54,7 @@ export async function runScan(dir: string, o: { config?: string } = {}): Promise captions: readRel(config?.shots?.copy ?? 'store/shots/copy.json'), }; const configFile = configPath ? { path: rel(configPath), text: readFileSync(configPath, 'utf8') } : null; - const declarations = proposeDeclarations(facts, config, configFile); + const declarations = proposeDeclarations(facts, config ? withoutTemplate(config) : null, configFile); const dataMerges = config?.dataSafety ? declarations.diff.filter(d => d.change === 'merge' && d.before).map(d => { const b = d.before!; const missing = [...d.after.collect.filter(x => !b.collect.includes(x)).map(x => `collected for ${x}`), ...(d.after.shared && !b.shared ? ['shared'] : []), diff --git a/cli/lib/scan/rules.ts b/cli/lib/scan/rules.ts index dee5c5a..dce9ec0 100644 --- a/cli/lib/scan/rules.ts +++ b/cli/lib/scan/rules.ts @@ -33,6 +33,7 @@ // error of Play Console (no policy page; developer.android.com/build/configure-app-module on choosing the id). // The other Google Play rules (sensitive permissions, prominent disclosure, Families, advertising ID, 16 KB pages, // testing for personal accounts, Play metadata, ads, payments, impersonation) are in play-rules.ts with their sources. +import { templateBlockers } from '../template-answers'; import { BETA_WORDS, competitorRe, OTHER_PLATFORMS, PLACEHOLDER_MARKERS, PLACEHOLDER_TEXT, PRICE_CLAIMS } from '../listing-rules'; import type { ShipConfig } from '../config'; import { allSubscriptions } from '../subscriptions'; @@ -560,6 +561,10 @@ export function scanRules(inp: ScanInput): ScanFinding[] { playRules(inp, { add: f => add({ ...f, store: 'play' }), cfg, sdkHits, listingHits, creates, restorable, paySdk }); // ---- owner-accepted findings ---- + // compliance answers that are still the template's (storebound init): never the owner's, never pushed + if (c) for (const msg of templateBlockers(c)) for (const store of (['apple', 'play'] as const).filter(x => (x === 'apple' ? c.apple : c.play))) + add({ rule: 'template-answers', guideline: store === 'apple' ? '5.1.2 / 2.1' : 'Play: User Data', store, severity: 'blocker', title: `${msg.split(' still holds ')[0]} still holds template answers`, + evidence: cfgHits(inp, /unanswered\s*:|dataSafety\s*:|reviewNotes\s*:/), fix: msg, skill: 'store-declarations' }); applyIgnore(out, c?.scan?.ignore ?? []); return sortScan(out); } @@ -595,3 +600,11 @@ const RANK: Record = { blocker: 0, high: 1, medium: 2, low: 3 export function sortScan(f: ScanFinding[]): ScanFinding[] { return f.map((x, i) => [x, i] as const).sort(([a, i], [b, j]) => Number(!!a.accepted) - Number(!!b.accepted) || RANK[a.severity] - RANK[b.severity] || i - j).map(([x]) => x); } + +/** The first config line matching `re`, as evidence. */ +function cfgHits(inp: ScanInput, re: RegExp): Hit[] { + const f = inp.configFile; + if (!f) return []; + const line = f.text.split('\n').findIndex(l => re.test(l)); + return line < 0 ? [] : [{ file: f.path, line: line + 1, text: f.text.split('\n')[line]!.trim().slice(0, 100) }]; +} diff --git a/cli/lib/template-answers.ts b/cli/lib/template-answers.ts new file mode 100644 index 0000000..639eb4e --- /dev/null +++ b/cli/lib/template-answers.ts @@ -0,0 +1,145 @@ +// Template compliance answers: what `storebound init` wrote is a starting point, never the owner's answer. A config +// whose Data safety, App Privacy, review notes, age rating, accounts or review access still hold the template's values +// must never reach a store (a game template declares location, device id and diagnostics as shared for advertising). +// - `unanswered: [...]` in the config (the templates write it) lists the areas nobody has answered yet; +// - configs written before it (no `unanswered` key) are recognised by the template's values; +// - identity (app name, the "Example Studio" copyright, the template's TestFlight text) is always recognised by value. +// templateAreas() says what is left; templateRefusal() is the gate every command that sends an answer calls. Pure. +import type { ShipConfig, TemplateArea } from './config'; +import gameTemplate from '../../templates/storebound.config'; +import appTemplate from '../../templates/profiles/app/storebound.config'; +import subscriptionTemplate from '../../templates/profiles/subscription-app/storebound.config'; + +export type Area = TemplateArea | 'identity'; +export interface TemplateFinding { area: Area; why: string } + +const TEMPLATES: ShipConfig[] = [gameTemplate, appTemplate, subscriptionTemplate]; + +/** Stable JSON (sorted keys) for value comparison. */ +const canon = (v: unknown): string => JSON.stringify(v, (_k, x) => (x && typeof x === 'object' && !Array.isArray(x) + ? Object.fromEntries(Object.keys(x).sort().map(k => [k, (x as Record)[k]])) : x)); +const sameData = (a: ShipConfig['dataSafety'], b: ShipConfig['dataSafety']) => !!a && !!b + && canon(a.data) === canon(b.data) && canon(a.accountCreation ?? ['PSL_ACM_NONE']) === canon(b.accountCreation ?? ['PSL_ACM_NONE']); + +export const TEMPLATE_NAMES = new Set(TEMPLATES.map(t => t.app.name)); +const TEMPLATE_COPYRIGHT = /\bExample Studio\b/i; +const TEMPLATE_TEXT = /\bExample (Game|App|Studio)\b/; + +/** Identity values that are still the template's (name, copyright, TestFlight beta description). */ +export function templateIdentity(c: Pick): string[] { + const out: string[] = []; + if (TEMPLATE_NAMES.has(c.app.name)) out.push(`app.name "${c.app.name}"`); + if (c.app.copyright && TEMPLATE_COPYRIGHT.test(c.app.copyright)) out.push(`app.copyright "${c.app.copyright}"`); + if (Object.values(c.apple?.testflight?.description ?? {}).some(d => TEMPLATE_TEXT.test(d ?? ''))) out.push('apple.testflight.description (the template\'s beta text)'); + return out; +} + +/** The areas that still hold template (unconfirmed) answers, with the reason. */ +export function templateAreas(c: ShipConfig): TemplateFinding[] { + const out: TemplateFinding[] = []; + const id = templateIdentity(c); + if (id.length) out.push({ area: 'identity', why: `${id.join(', ')} from the template` }); + if (Array.isArray(c.unanswered)) { + for (const a of c.unanswered) out.push({ area: a, why: 'listed in unanswered (template or unconfirmed answers)' }); + return out; + } + // configs from before `unanswered`: recognise the template's own values + const ds = TEMPLATES.find(t => sameData(c.dataSafety, t.dataSafety)); + if (ds) out.push({ area: 'dataSafety', why: `dataSafety is the "${ds.app.profile}" template's (same data types and account answer)` }, { area: 'appPrivacy', why: 'App Privacy is derived from the template dataSafety' }); + const notes = c.apple?.info?.reviewNotes; + const rn = notes ? TEMPLATES.find(t => t.apple?.info?.reviewNotes === notes) : undefined; + if (rn) out.push({ area: 'reviewNotes', why: `apple.info.reviewNotes is the "${rn.app.profile}" template's text` }); + const origin = ds ?? rn; + if (origin && c.apple?.info?.ageRating && canon(c.apple.info.ageRating) === canon(origin.apple?.info?.ageRating ?? {})) + out.push({ area: 'ageRating', why: `apple.info.ageRating is the "${origin.app.profile}" template's, next to its other answers` }); + if (c.reviewAccess?.instructions && TEMPLATES.some(t => t.reviewAccess?.instructions === c.reviewAccess!.instructions)) + out.push({ area: 'reviewAccess', why: 'reviewAccess is the template\'s (its instructions text)' }); + if (c.accounts && TEMPLATES.some(t => t.accounts && t.accounts.inApp === c.accounts!.inApp && canon(t.accounts.signUp) === canon(c.accounts!.signUp))) + out.push({ area: 'accounts', why: 'accounts is the template\'s (its in-app path and sign-up methods)' }); + return out; +} + +const LABEL: Record = { + identity: 'identity (name / copyright)', dataSafety: 'Data safety', appPrivacy: 'App Privacy', reviewNotes: 'App Review notes', + ageRating: 'age rating', accounts: 'accounts (sign-up methods, deletion)', reviewAccess: 'review access (sign-in for reviewers)', +}; + +/** How to fix one area, in one line. */ +export function templateFix(a: Area): string { + switch (a) { + case 'identity': return 'set app.name / app.copyright to the real ones (storebound onboard --declarations takes them from the stores)'; + case 'dataSafety': case 'appPrivacy': return 'derive them from the code (storebound scan --write proposes them; storebound onboard --declarations for a live app) or answer them, then remove the entry from unanswered'; + case 'reviewNotes': return 'write apple.info.reviewNotes for this app (where every feature is), then remove it from unanswered'; + case 'ageRating': return 'answer apple.info.ageRating for this app (ask the owner), then remove it from unanswered'; + case 'accounts': return 'set accounts from the code (sign-up methods, deletion page) or remove the block if the app has none, then remove it from unanswered'; + case 'reviewAccess': return 'set reviewAccess from the code and the store (storebound onboard), then remove it from unanswered'; + } +} + +/** One blocker line per area (check / validate / scan). */ +export function templateBlockers(c: ShipConfig): string[] { + const f = templateAreas(c); + const lines: string[] = []; + const priv = f.filter(x => x.area === 'dataSafety' || x.area === 'appPrivacy'); + if (priv.length) lines.push(`Data safety / App Privacy still holds template answers (${priv[0]!.why}): ${templateFix('dataSafety')}`); + for (const x of f) if (x.area !== 'dataSafety' && x.area !== 'appPrivacy') lines.push(`${LABEL[x.area]} still holds template answers (${x.why}): ${templateFix(x.area)}`); + return lines; +} + +/** + * The refusal for a command that would send `needs` to a store, or null when none of them is a template answer. + * appPrivacy also needs dataSafety (App Privacy is derived from it). + */ +export function templateRefusal(c: ShipConfig, needs: Area[], what: string): string | null { + const want = new Set(needs); + if (want.has('appPrivacy')) want.add('dataSafety'); + const hit = templateAreas(c).filter(x => want.has(x.area)); + if (!hit.length) return null; + const areas = [...new Set(hit.map(x => x.area))]; + return `refused: ${what} would send template answers, not the owner's: ${areas.map(a => LABEL[a]).join(', ')} (${hit[0]!.why}). ` + + `Nothing was sent. Fix: ${[...new Set(areas.map(templateFix))].join('; ')}.`; +} + +/** Throws the refusal (dry runs too: the dry run must show that the real run would refuse). */ +export function refuseTemplate(c: ShipConfig, needs: Area[], what: string): void { + const r = templateRefusal(c, needs, what); + if (r) throw new Error(r); +} + +/** The config without the template answers of `areas` (what code evidence is proposed against). Pure. */ +export function withoutTemplate(c: ShipConfig, areas = templateAreas(c).map(x => x.area)): ShipConfig { + const has = (a: Area) => areas.includes(a); + const out: ShipConfig = { ...c }; + if (has('dataSafety')) delete out.dataSafety; + if (has('appPrivacy') || has('dataSafety')) delete out.appPrivacy; + if (has('accounts')) delete out.accounts; + if (has('reviewAccess')) delete out.reviewAccess; + if (c.apple?.info && (has('reviewNotes') || has('ageRating'))) { + const info = { ...c.apple.info }; + if (has('reviewNotes')) delete info.reviewNotes; + if (has('ageRating')) delete info.ageRating; + out.apple = { ...c.apple, info }; + } + return out; +} + +/** Private terms (names the owner keeps out of public text), one per line, from STOREBOUND_PRIVATE_TERMS or + * WAYBETTER_FORBIDDEN_TERMS (a file path). Never printed. */ +export function privateTerms(read: (p: string) => string | null, env: Record = process.env): string[] { + const f = env.STOREBOUND_PRIVATE_TERMS || env.WAYBETTER_FORBIDDEN_TERMS; + if (!f) return []; + const text = read(f); + if (text === null) return []; + return text.split(/\r?\n/).map(l => l.trim()).filter(l => l && !l.startsWith('#')); +} + +/** Whether a value contains one of the private terms (case-insensitive). */ +export const hasPrivateTerm = (v: string, terms: string[]): boolean => terms.some(t => v.toLowerCase().includes(t.toLowerCase())); + +/** The answers each console form sends (console plan / fill refuse while one is a template answer). */ +export const FORM_AREAS: Record = { + 'apple-app-record': ['identity'], 'play-app-record': ['identity'], + 'apple-app-privacy': ['dataSafety', 'appPrivacy'], 'apple-age-rating': ['ageRating'], 'play-content-rating': ['ageRating'], + 'play-app-content': ['dataSafety', 'appPrivacy', 'ageRating', 'accounts', 'reviewAccess'], + 'play-ads': ['dataSafety'], 'play-advertising-id': ['dataSafety'], 'play-sign-in-details': ['reviewAccess', 'accounts'], +}; diff --git a/cli/onboard.ts b/cli/onboard.ts index f9605c3..83f93dc 100644 --- a/cli/onboard.ts +++ b/cli/onboard.ts @@ -1,6 +1,7 @@ // Onboarding an app that is already live on a store (after `storebound init` and `listing pull`): reads what the // stores and the code prove and puts it into the project, so the owner is asked only what nothing proves. // storebound onboard [--store apple|play] [--write] [--dry] [--json] [--no-web] [--no-catalog] [--no-art] +// storebound onboard --declarations [--write] only the template compliance answers and identity (no catalog, no art) // Read-only on both stores (GETs; a Play edit that is opened, read and deleted, never committed). Without --write it // prints the plan; --write (never with --dry) changes only local files: // 1. products / subscriptions in storebound.config.ts from the live catalog of both stores: ids, type, listings per @@ -16,6 +17,10 @@ // 5. the live store art (art pull): App Store screenshot sets, previews and icon, Play icon, feature graphic and // screenshots into store/renders with pulled.json, so check / validate / uploads treat them like rendered art; // local files that differ are kept (art pull --force replaces them). +// 6. the template compliance answers and identity `storebound init` wrote (cli/lib/template-answers.ts): name and +// copyright from the stores (a copyright with a private term, STOREBOUND_PRIVATE_TERMS / WAYBETTER_FORBIDDEN_TERMS, +// is left out), the template's Data safety / App Privacy / review notes / accounts / review access out, the +// answers the code proves in (scan's declaration proposal, sign-up methods), the rest stays in `unanswered`. // Then the owner questions that are left (one batch). import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; @@ -32,6 +37,8 @@ import { detectProject } from './lib/scan/project'; import { upsertBlock, upsertNestedString } from './lib/scan/config-edit'; import { playClient } from './play/client'; import { textTable } from './lib/table'; +import { resetDeclarations, type StoreIdentity } from './lib/onboard-declarations'; +import { privateTerms, TEMPLATE_NAMES } from './lib/template-answers'; const TAG = 'onboard'; const TIMEOUT_MS = 10_000; @@ -47,7 +54,7 @@ export const pageGet: PageGet = async url => { } catch { return { status: null, text: '' }; } finally { clearTimeout(t); } }; -async function appleFacts(ctx: Ctx): Promise> { +async function appleFacts(ctx: Ctx): Promise & { name?: string; copyright?: string }> { const { config, root } = ctx.loaded; const c = await ascClient(root, config.apple!.bundleId, config.apple?.appId); const appId = await c.app(); @@ -68,16 +75,19 @@ async function appleFacts(ctx: Ctx): Promise> const info = await c.editableAppInfo(); const infoLocs = info ? await c.all(`/v1/appInfos/${info.id}/appInfoLocalizations?limit=50`) : []; const il = infoLocs.find((x: J) => x.attributes.locale === primary) ?? infoLocs[0]; - return { supportUrl, marketingUrl, privacyPolicyUrl: il?.attributes?.privacyPolicyUrl ?? undefined, review }; + return { supportUrl, marketingUrl, privacyPolicyUrl: il?.attributes?.privacyPolicyUrl ?? undefined, review, name: il?.attributes?.name ?? undefined, copyright: v?.attributes?.copyright ?? undefined }; } -async function playFacts(ctx: Ctx): Promise> { +async function playFacts(ctx: Ctx): Promise & { title?: string }> { const { config, root } = ctx.loaded; const play = await playClient(root, config.play!.packageName); const e = await play.call<{ id: string }>('POST', `${play.API}/edits`, {}); try { - const d = await play.call<{ contactEmail?: string; contactWebsite?: string; contactPhone?: string }>('GET', `${play.API}/edits/${e.id}/details`); - return { contactEmail: d.contactEmail, contactWebsite: d.contactWebsite, contactPhone: d.contactPhone }; + const d = await play.call<{ contactEmail?: string; contactWebsite?: string; contactPhone?: string; defaultLanguage?: string }>('GET', `${play.API}/edits/${e.id}/details`); + const lang = d.defaultLanguage ?? config.play!.defaultLanguage ?? 'en-US'; + // the title only when the config still has the template name (onboard replaces it with the store's) + const title = TEMPLATE_NAMES.has(config.app.name) ? (await play.call<{ title?: string }>('GET', `${play.API}/edits/${e.id}/listings/${lang}`).catch(() => null))?.title : undefined; + return { contactEmail: d.contactEmail, contactWebsite: d.contactWebsite, contactPhone: d.contactPhone, title }; } finally { await play.call('DELETE', `${play.API}/edits/${e.id}`).catch(() => {}); } @@ -94,8 +104,10 @@ function setProjectSecret(root: string, key: 'reviewContact' | 'reviewDemoAccoun } export async function run(ctx: Ctx): Promise { - const { config, root, path: configPath } = ctx.loaded; + const { root, path: configPath } = ctx.loaded; + let config = ctx.loaded.config; const a = ctx.args; + const declOnly = a.has('declarations'); const store = a.get('store'); if (store && store !== 'apple' && store !== 'play') throw new Error('--store apple | play'); const useApple = (!store || store === 'apple') && !!config.apple; @@ -108,7 +120,7 @@ export async function run(ctx: Ctx): Promise { const before = text; // 1. catalog - if (!a.has('no-catalog')) { + if (!a.has('no-catalog') && !declOnly) { log(TAG, 'reading the live products and subscriptions'); const read = async (what: string, f: () => Promise): Promise => { try { return await f(); } catch (e) { warn(TAG, `${what}: ${(e as Error).message.slice(0, 300)}`); return null; } }; const apple: StoreItem[] | null = useApple ? await read('App Store products', () => appleItems(ctx)) : null; @@ -135,6 +147,22 @@ export async function run(ctx: Ctx): Promise { let appleF: Awaited> | null = null, playF: Awaited> | null = null; if (useApple) { try { appleF = await appleFacts(ctx); } catch (e) { warn(TAG, `App Store app details: ${(e as Error).message.slice(0, 300)}`); } } if (usePlay) { try { playF = await playFacts(ctx); } catch (e) { warn(TAG, `Play app details: ${(e as Error).message.slice(0, 300)}`); } } + // 6. template compliance answers and identity (before the steps below read accounts / reviewAccess) + const facts = detectProject(root); + const termsRead = (f: string) => { try { return readFileSync(f, 'utf8'); } catch { return null; } }; + const storeId: StoreIdentity = appleF?.name ? { name: appleF.name, copyright: appleF.copyright, source: 'App Store Connect' } + : { name: playF?.title, copyright: appleF?.copyright, source: playF?.title ? 'Google Play listing' : 'App Store Connect' }; + const reset = resetDeclarations({ config, text, path: configPath, facts, store: storeId, terms: privateTerms(termsRead) }); + text = reset.text; + config = reset.config; + report.declarations = { removed: reset.removed, proven: reset.proven, unanswered: reset.unanswered }; + if (!json) { + for (const r of reset.removed) warn(TAG, `template answer out: ${r}`); + for (const p of reset.proven) log(TAG, `proven: ${p}`); + if (reset.unanswered.length) warn(TAG, `unanswered (check / validate block until the owner answers): ${reset.unanswered.join(', ')}`); + } + questions.push(...reset.questions); + const contact = contactProposal(config.app, { apple: appleF, play: playF }); report.contact = contact; for (const c of contact) { @@ -147,7 +175,6 @@ export async function run(ctx: Ctx): Promise { if (rc && !json) log(TAG, `App Review contact ${maskContact(rc)}: ${haveRc ? 'already in .storebound/credentials.json (kept)' : 'into .storebound/credentials.json reviewContact'}`); if (!rc && useApple && !haveRc) questions.push('App Review contact (first and last name, phone, e-mail): App Store Connect has none; put it in .storebound/credentials.json reviewContact'); - const facts = detectProject(root); const auth = authEvidence(facts); const sign = signInDecision({ auth, apple: appleF?.review }); report.signIn = { signInRequired: sign.signInRequired, because: sign.because, demoAccountOnStore: !!sign.demoAccount, configured: config.reviewAccess?.signInRequired ?? null }; @@ -178,7 +205,7 @@ export async function run(ctx: Ctx): Promise { // 5. the store art (icon, screenshots, previews, feature graphic) from the live stores into store/renders, so // nothing has to be rendered again for an app that is already live (art pull; upsert, local files kept) - if (!a.has('no-art')) { + if (!a.has('no-art') && !declOnly) { try { const art = await artPull(ctx, { store, write, force: false, quiet: json }); report.art = { summary: art.summary, conflicts: art.conflicts, unmapped: art.unmapped, written: art.written, stores: art.stores }; diff --git a/cli/play/data-safety.ts b/cli/play/data-safety.ts index 66af2fd..7610e43 100644 --- a/cli/play/data-safety.ts +++ b/cli/play/data-safety.ts @@ -1,5 +1,8 @@ // Play Data safety form through applications.dataSafety (CSV import; replaces every earlier answer). // storebound play data-safety [--csv out.csv] [--dry] [--force] +// --dry prints the answers that change (question [response]: old -> new, account and deletion answers first) against +// the CSV uploaded last (Play cannot read the form back), then the whole CSV (or --csv writes it). Template answers +// (cli/lib/template-answers.ts) are refused, in the dry run too. // Answers come from `dataSafety` (and accounts.deletionUrl) in storebound.config.ts. --dry only writes / prints the CSV. // Both --dry and a real run stop on a missing required row (dataSafetyIssues) before anything is sent. // Play cannot read the form back, so a run compares with what this project uploaded last: the sha256 of that CSV @@ -9,7 +12,8 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'; import { join } from 'node:path'; import type { Ctx } from '../lib/ctx'; import { need } from '../lib/ctx'; -import { dataSafetyCsv, dataSafetyProblem, dataSafetyRows } from '../lib/data-safety'; +import { type DataSafetyChange, dataSafetyCsv, dataSafetyDiff, dataSafetyProblem, dataSafetyRows } from '../lib/data-safety'; +import { refuseTemplate } from '../lib/template-answers'; import { DATA_SAFETY_UPLOADED } from '../lib/declarations'; import { done, log } from '../lib/log'; import { playClient } from './client'; @@ -36,11 +40,24 @@ function rememberUpload(root: string, pkg: string, sha256: string): void { writeFileSync(f, JSON.stringify(all, null, 2) + '\n'); } +const ACCOUNT_Q = /ACCOUNT|DELETION/; + +/** The changed answers, account and deletion answers first, as `question [response]: old -> new` lines. Pure. */ +export function changeLines(changes: DataSafetyChange[]): string[] { + const show = (v: string) => (v ? v : '(empty)'); + const line = (c: DataSafetyChange) => ` ${c.question}${c.response ? ` [${c.response}]` : ''}: ${show(c.from)} -> ${show(c.to)}${c.label ? ` (${c.label.slice(0, 70)})` : ''}`; + const acc = changes.filter(c => ACCOUNT_Q.test(c.question)), rest = changes.filter(c => !ACCOUNT_Q.test(c.question)); + return [...(acc.length ? [' account and deletion answers:', ...acc.map(line)] : []), ...(rest.length ? [' data types and other answers:', ...rest.map(line)] : [])]; +} + export async function run(ctx: Ctx): Promise { const { config, root, resolve } = ctx.loaded; const ds = need(config.dataSafety, 'dataSafety'); + // template answers are never sent, and the dry run refuses the same way (it shows what a real run would do) + refuseTemplate(config, ['dataSafety', 'accounts'], `play data-safety${ctx.args.has('dry') ? ' --dry' : ''}`); const csv = dataSafetyCsv(ds, config.accounts); - const problem = dataSafetyProblem(dataSafetyRows(ds, config.accounts)); + const rows = dataSafetyRows(ds, config.accounts); + const problem = dataSafetyProblem(rows); const out = ctx.args.get('csv'); if (out) { writeFileSync(resolve(out), csv); log('play', `wrote ${out}`); } const types = Object.keys(config.dataSafety!.data); @@ -50,6 +67,11 @@ export async function run(ctx: Ctx): Promise { const last = lastUpload(root, pkg); const same = last?.sha256 === hash; if (ctx.args.has('dry')) { + const upFile = join(root, DATA_SAFETY_UPLOADED); + const before = existsSync(upFile) ? readFileSync(upFile, 'utf8') : null; + const changes = dataSafetyDiff(before, rows); + log('play', `changes against ${before ? `the last uploaded CSV (${DATA_SAFETY_UPLOADED}; Play cannot read the form back)` : 'nothing (no upload recorded: every answer is new)'}: ${changes.length} answer(s)`); + for (const l of changeLines(changes)) console.log(l); if (!out) process.stdout.write(csv); if (problem) throw new Error(problem); log('play', same ? `the same CSV was uploaded ${last!.at.slice(0, 16).replace('T', ' ')} UTC: a real run sends nothing (--force sends it again)` : 'a real run uploads it (it differs from the last upload, or none is recorded)'); diff --git a/cli/validate.ts b/cli/validate.ts index 627b705..67253e0 100644 --- a/cli/validate.ts +++ b/cli/validate.ts @@ -13,6 +13,7 @@ // blockers stay blockers, high and medium risks are warnings, owner-accepted ones (scan.ignore) are left out. --no-scan skips. // Live apps: crash / ANR vitals (Play Reporting API) and App Store diagnostics, as in `storebound health`: over a Play // bad behavior threshold is a blocker, near it a warning; a store without vitals access is only noted. --no-health skips. +import { templateBlockers } from './lib/template-answers'; import { ascClient, type J } from './apple/asc'; import { requiredDisplayTypes } from './apple/screenshots'; import { type Loaded, playCategoryOf, playCodes, profileOf, sellsOn } from './lib/config'; @@ -191,6 +192,8 @@ export async function run(ctx: Ctx): Promise { try { findings.push(...playRules({ ...(await collectPlay(ctx)), renders: local?.play })); } catch (e) { findings.push({ store: 'play', level: 'blocker', code: 'api', msg: (e as Error).message.slice(0, 400) }, ...rendersFindings('play', local?.play)); } } else if (a.has('play')) warn('validate', 'no play section in the config'); + // template compliance answers: the scan reports them; without the scan, here + if (a.has('no-scan')) for (const msg of templateBlockers(config)) findings.push({ store: config.apple ? 'apple' : 'play', level: 'blocker', code: 'template-answers', msg }); if (!a.has('no-scan')) { log('validate', 'project scan (offline): rejection risks in the project files'); try { diff --git a/docs/api-coverage.md b/docs/api-coverage.md index 26523b6..49542c9 100644 --- a/docs/api-coverage.md +++ b/docs/api-coverage.md @@ -13,7 +13,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co | API | operations | GET | other | wrapped by a command | owner only | |---|---:|---:|---:|---:|---:| | App Store Connect API 4.5.1 | 1303 | 826 | 477 | 204 | 166 | -| Google Play Android Developer API v3 (revision 20261008) | 145 | 45 | 100 | 34 | 49 | +| Google Play Android Developer API v3 (revision 20261008) | 145 | 45 | 100 | 35 | 49 | | Google Play Developer Reporting API v1beta1 (revision 20261008) | 25 | 15 | 10 | 6 | 0 | ## App Store Connect API 4.5.1 @@ -1597,7 +1597,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co | **androidpublisher.edits.listings** (6) | | | | | | `androidpublisher.edits.listings.delete` | DELETE | `androidpublisher/v3/applications/{packageName}/edits/{editId}/listings/{language}` | owner only (typed confirm) | | | `androidpublisher.edits.listings.deleteall` | DELETE | `androidpublisher/v3/applications/{packageName}/edits/{editId}/listings` | owner only (typed confirm) | | -| `androidpublisher.edits.listings.get` | GET | `androidpublisher/v3/applications/{packageName}/edits/{editId}/listings/{language}` | read | | +| `androidpublisher.edits.listings.get` | GET | `androidpublisher/v3/applications/{packageName}/edits/{editId}/listings/{language}` | read | onboard | | `androidpublisher.edits.listings.list` | GET | `androidpublisher/v3/applications/{packageName}/edits/{editId}/listings` | read | art, listing-store, play/listing, play/status, validate | | `androidpublisher.edits.listings.patch` | PATCH | `androidpublisher/v3/applications/{packageName}/edits/{editId}/listings/{language}` | write (dry by default) | listing-store | | `androidpublisher.edits.listings.update` | PUT | `androidpublisher/v3/applications/{packageName}/edits/{editId}/listings/{language}` | write (dry by default) | listing-store, play/listing | diff --git a/examples/demo-app/storebound.config.ts b/examples/demo-app/storebound.config.ts index 62acb8d..f3e6643 100644 --- a/examples/demo-app/storebound.config.ts +++ b/examples/demo-app/storebound.config.ts @@ -37,6 +37,7 @@ const config: ShipConfig = { // `storebound scan` findings that are true for this fictional demo and accepted on purpose (README.md) scan: { ignore: [ + { rule: 'template-answers', reason: 'fictional demo app: its Example Studio copyright is the placeholder of a project that is never uploaded' }, { rule: 'demo-account', reason: 'fictional demo app without a store record: there is no backend to sign in to, so no App Review demo account' }, { rule: 'play-app-access', reason: 'fictional demo app without a store record: no Play App access credentials' }, { rule: 'play-package-name', reason: 'fictional demo app: com.example.* is the repo placeholder and is never uploaded' }, diff --git a/examples/demo-game/storebound.config.ts b/examples/demo-game/storebound.config.ts index e53201f..b4e0c59 100644 --- a/examples/demo-game/storebound.config.ts +++ b/examples/demo-game/storebound.config.ts @@ -22,6 +22,7 @@ const config: ShipConfig = { // `storebound scan` findings that are true for this fictional demo and accepted on purpose (README.md) scan: { ignore: [ + { rule: 'template-answers', reason: 'fictional demo game: its Example Studio copyright is the placeholder of a project that is never uploaded' }, { rule: 'play-package-name', reason: 'fictional demo game: com.example.* is the repo placeholder and is never uploaded' }, { rule: 'play-testing-requirement', reason: 'fictional demo game without a Play developer account, so there is no account type to set' }, ], diff --git a/skills/store-pitfalls/SKILL.md b/skills/store-pitfalls/SKILL.md index 60e247b..4d3caa7 100644 --- a/skills/store-pitfalls/SKILL.md +++ b/skills/store-pitfalls/SKILL.md @@ -700,3 +700,15 @@ that fails without the fix (see CONTRIBUTING.md). - **Guard:** `cli/art.ts`, `cli/lib/art-pull.ts` (`decide`, `pulledChecksum`, `storeEmptySets`), the pulled-file rules in `scanRenders` (`cli/shots/report.ts`) and `check`, `tests/art-pull.test.ts` (recorded ASC screenshot sets with `templateUrl` and Play `images.list`, the layout, the upsert and `--force`). + +## 60. `init` + `onboard` on live apps left the template compliance answers in the config +- **Do:** treat every answer `storebound init` wrote as unanswered. The game template declares approximate location, + app interactions, diagnostics and device id as shared for advertising and `PSL_ACM_NONE`; pushing that from a live + app is a false declaration. Templates list the areas in `unanswered`; configs from before it are recognised by the + template values. `check`, `validate` and `scan` block; `play data-safety` (also `--dry`), `console plan` / `fill`, + `apple review-details` and `apple appinfo` refuse. For a live app run `storebound onboard --declarations --write`: + name and copyright from the stores (a copyright with a private term from `STOREBOUND_PRIVATE_TERMS` is left out), + the template answers out, the rows the code proves in (scan proposal, sign-up methods from auth code), the rest as + one owner question list. Remove an area from `unanswered` only after the owner answered it. +- **Guard:** `cli/lib/template-answers.ts`, `cli/lib/onboard-declarations.ts`, the `template-answers` scan rule, + `tests/template-answers.test.ts` (init blocks, data-safety --dry refuses, onboard on auth code without an ad SDK). diff --git a/templates/profiles/app/storebound.config.ts b/templates/profiles/app/storebound.config.ts index 291e2fa..8286c34 100644 --- a/templates/profiles/app/storebound.config.ts +++ b/templates/profiles/app/storebound.config.ts @@ -5,6 +5,11 @@ import type { ShipConfig } from 'storebound'; const config: ShipConfig = { + // Every compliance answer below is the TEMPLATE's, not yours. While an area is listed here, check / validate block + // and nothing sends it to a store. Derive the answers from the code (storebound scan --write; for an app that is + // already live: storebound onboard --declarations), answer the rest, then remove the area from this list. + unanswered: ['dataSafety', 'appPrivacy', 'reviewNotes', 'ageRating', 'accounts', 'reviewAccess'], + app: { name: 'Example App', profile: 'app', diff --git a/templates/profiles/subscription-app/storebound.config.ts b/templates/profiles/subscription-app/storebound.config.ts index c68f315..8af8a0f 100644 --- a/templates/profiles/subscription-app/storebound.config.ts +++ b/templates/profiles/subscription-app/storebound.config.ts @@ -5,6 +5,11 @@ import type { ShipConfig } from 'storebound'; const config: ShipConfig = { + // Every compliance answer below is the TEMPLATE's, not yours. While an area is listed here, check / validate block + // and nothing sends it to a store. Derive the answers from the code (storebound scan --write; for an app that is + // already live: storebound onboard --declarations), answer the rest, then remove the area from this list. + unanswered: ['dataSafety', 'appPrivacy', 'reviewNotes', 'ageRating', 'accounts', 'reviewAccess'], + app: { name: 'Example App', profile: 'subscription-app', diff --git a/templates/storebound.config.ts b/templates/storebound.config.ts index 3341e78..839589a 100644 --- a/templates/storebound.config.ts +++ b/templates/storebound.config.ts @@ -4,6 +4,11 @@ import type { ShipConfig } from 'storebound'; const config: ShipConfig = { + // Every compliance answer below is the TEMPLATE's, not yours. While an area is listed here, check / validate block + // and nothing sends it to a store. Derive the answers from the code (storebound scan --write; for an app that is + // already live: storebound onboard --declarations), answer the rest, then remove the area from this list. + unanswered: ['dataSafety', 'appPrivacy', 'reviewNotes', 'ageRating'], + app: { name: 'Example Game', profile: 'game', diff --git a/tests/teaser.test.ts b/tests/teaser.test.ts index f964dc2..d0eb067 100644 --- a/tests/teaser.test.ts +++ b/tests/teaser.test.ts @@ -62,7 +62,8 @@ beforeAll(() => { dir = mkdtempSync(join(tmpdir(), 'sb-teaser-')); expect(run(['init'], dir).code).toBe(0); const cfg = join(dir, 'storebound.config.ts'); - writeFileSync(cfg, readFileSync(cfg, 'utf8').replace(' dataSafety: {', ` products: [ + // the teaser's app has answered its declarations (a fresh init lists them as unanswered and nothing is sent) + writeFileSync(cfg, readFileSync(cfg, 'utf8').replace(/^ unanswered: \[[^\]]*\],$/m, ' unanswered: [],').replace(' dataSafety: {', ` products: [ { id: 'com.example.app.no_ads', type: 'non_consumable', usd: 2.99, listings: { en: { title: 'No Ads', description: 'Removes every ad.' }, tr: { title: 'Reklamsız', description: 'Tüm reklamları kaldırır.' } } }, { id: 'com.example.app.gems_100', type: 'consumable', usd: 0.99, listings: { en: { title: '100 Gems', description: 'A pouch of 100 gems.' }, tr: { title: '100 Mücevher', description: '100 mücevherlik bir kese.' } } }, ], diff --git a/tests/template-answers.test.ts b/tests/template-answers.test.ts new file mode 100644 index 0000000..01eac32 --- /dev/null +++ b/tests/template-answers.test.ts @@ -0,0 +1,115 @@ +// Template compliance answers never reach a store: `storebound init` marks them unanswered, check blocks, play +// data-safety (also --dry), console plan, apple review-details / appinfo refuse; onboard replaces them with what the +// code proves (no advertising rows without an ad SDK, a real account answer with auth code) and the template identity +// with the store's. Plus the data-safety dry run's changed-rows diff. +import { describe, expect, test } from 'bun:test'; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join } from 'node:path'; +import { parseArgs } from '../cli/lib/args'; +import { loadConfig, type ShipConfig } from '../cli/lib/config'; +import { dataSafetyDiff, dataSafetyRows } from '../cli/lib/data-safety'; +import { resetDeclarations } from '../cli/lib/onboard-declarations'; +import { removeKeyPath } from '../cli/lib/scan/config-edit'; +import { detectProject } from '../cli/lib/scan/project'; +import { templateAreas, templateRefusal } from '../cli/lib/template-answers'; +import { run as init } from '../cli/init'; +import { run as check } from '../cli/check'; +import { changeLines, run as dataSafety } from '../cli/play/data-safety'; +import { run as consolePlan } from '../cli/console/plan'; +import { run as reviewDetails } from '../cli/apple/review-details'; +import { capture } from './helpers/store-mock'; + +const tmp = () => mkdtempSync(join(tmpdir(), 'sb-tpl-')); +async function initProject(profile: string) { + const root = tmp(); + await capture(() => init(parseArgs(['--dir', root, '--profile', profile]))); + const loaded = await loadConfig(join(root, 'storebound.config.ts')); + return { root, loaded, ctx: (argv: string[]) => ({ loaded, args: parseArgs(argv) }) }; +} + +describe('template answers', () => { + test('init (every profile) marks the compliance answers unanswered and check blocks on them', async () => { + for (const profile of ['game', 'app', 'subscription-app']) { + const p = await initProject(profile); + expect(p.loaded.config.unanswered).toContain('dataSafety'); + const areas = templateAreas(p.loaded.config).map(a => a.area); + expect(areas).toEqual(expect.arrayContaining(['identity', 'dataSafety', 'appPrivacy', 'reviewNotes', 'ageRating'])); + const r = await capture(() => check(p.ctx(['--json']))); + const out = JSON.parse(r.out.slice(r.out.indexOf('{\n'))) as { problems: string[] }; + expect(out.problems.some(x => x.startsWith('Data safety / App Privacy still holds template answers') && x.includes('storebound scan --write'))).toBe(true); + expect(r.exit ?? process.exitCode).not.toBe(0); + } + }); + + test('play data-safety --dry, console plan apple-app-privacy and apple review-details refuse template answers', async () => { + const p = await initProject('game'); + await expect(dataSafety(p.ctx(['--dry']))).rejects.toThrow(/refused: play data-safety --dry would send template answers.*Data safety.*Nothing was sent/); + await expect(dataSafety(p.ctx([]))).rejects.toThrow(/refused: play data-safety would send template answers/); + await expect(consolePlan(p.ctx(['apple-app-privacy']))).rejects.toThrow(/refused: console plan apple-app-privacy/); + await expect(reviewDetails(p.ctx(['--dry']))).rejects.toThrow(/refused: apple review-details.*App Review notes/); + }); + + test('a config from before `unanswered` is recognised by the template values; [] means answered', () => { + const legacy = { app: { name: 'Demo Blast', copyright: '2026 Example Studio' }, apple: { bundleId: 'x', info: { reviewNotes: 'mine', ageRating: { advertising: true } } }, + dataSafety: { collectsData: true, encryptedInTransit: true, accountCreation: ['PSL_ACM_NONE'], data: { + PSL_APPROX_LOCATION: { shared: true, collect: ['analytics', 'advertising', 'fraud'], share: ['analytics', 'advertising', 'fraud'] }, + PSL_USER_INTERACTION: { shared: true, collect: ['analytics', 'advertising'], share: ['analytics', 'advertising'] }, + PSL_OTHER_APP_ACTIVITY: { shared: false, collect: ['functionality'] }, + PSL_PERFORMANCE_DIAGNOSTICS: { shared: true, collect: ['analytics', 'advertising', 'fraud'], share: ['analytics', 'advertising', 'fraud'] }, + PSL_DEVICE_ID: { shared: true, collect: ['functionality', 'analytics', 'advertising', 'fraud'], share: ['analytics', 'advertising', 'fraud'] }, + PSL_PURCHASE_HISTORY: { shared: false, collect: ['functionality', 'fraud'] } } } } as unknown as ShipConfig; + expect(templateAreas(legacy).map(a => a.area)).toEqual(['identity', 'dataSafety', 'appPrivacy', 'ageRating']); + expect(templateRefusal(legacy, ['appPrivacy'], 'x')).toContain('Data safety'); + const answered = { ...legacy, app: { ...legacy.app, copyright: '2026 Someone' }, unanswered: [] } as ShipConfig; + expect(templateAreas(answered)).toEqual([]); + }); + + test('onboard --declarations: no ad SDK + auth code gives no advertising rows, a real account answer, no template identity', async () => { + const p = await initProject('game'); + const files: Record = { + 'package.json': JSON.stringify({ name: 'demo', dependencies: { firebase: '10.0.0' } }), + 'src/auth.ts': "import { getAuth, signInWithEmailAndPassword, createUserWithEmailAndPassword } from 'firebase/auth';\nexport const login = (e: string, pw: string) => signInWithEmailAndPassword(getAuth(), e, pw);\nexport const signUp = (e: string, pw: string) => createUserWithEmailAndPassword(getAuth(), e, pw);\nexport const form = { email: e0 };\nconst e0 = '';\n", + }; + for (const [f, t] of Object.entries(files)) { mkdirSync(dirname(join(p.root, f)), { recursive: true }); writeFileSync(join(p.root, f), t); } + const path = p.loaded.path; + const r = resetDeclarations({ config: p.loaded.config, text: readFileSync(path, 'utf8'), path, facts: detectProject(p.root), + store: { name: 'Real Name', copyright: '2026 Hidden Co', source: 'App Store Connect' }, terms: ['hidden co'] }); + const out = join(p.root, 'after.config.ts'); // a new path: the first import of the config is cached + writeFileSync(out, r.text); + const after = (await loadConfig(out)).config; + const rows = Object.entries(after.dataSafety!.data); + expect(rows.some(([, u]) => u.collect.includes('advertising') || (u.share ?? []).includes('advertising'))).toBe(false); + expect(after.dataSafety!.data.PSL_APPROX_LOCATION).toBeUndefined(); + expect(after.dataSafety!.accountCreation).toEqual(['PSL_ACM_USER_ID_PASSWORD']); + expect(after.accounts).toMatchObject({ enabled: true, signUp: ['email-password'] }); + expect(after.app.name).toBe('Real Name'); + expect(after.app.copyright).toBeUndefined(); // the store's copyright has a private term: left out + expect(r.text).not.toContain('Example Game'); + expect(after.apple?.info?.reviewNotes).toBeUndefined(); + expect(after.unanswered).toEqual(expect.arrayContaining(['dataSafety', 'appPrivacy', 'ageRating'])); + expect(r.questions.some(q => q.startsWith('Age rating'))).toBe(true); + expect(r.questions.some(q => /encryptedInTransit/.test(q))).toBe(true); + // still refused until the owner confirms + expect(templateRefusal(after, ['dataSafety'], 'x')).not.toBeNull(); + }); + + test('removeKeyPath drops a nested expression value and its comments', () => { + const t = "const c = {\n app: { name: 'a' },\n apple: {\n info: {\n // notes\n reviewNotes: ['a', 'b'].join('\\n\\n'),\n free: true,\n },\n },\n};\n"; + expect(removeKeyPath(t, 'x.ts', ['apple', 'info', 'reviewNotes'])).toBe("const c = {\n app: { name: 'a' },\n apple: {\n info: {\n free: true,\n },\n },\n};\n"); + }); + + test('data-safety dry run diff: old -> new per question, account answers first', () => { + const ds = { collectsData: true, encryptedInTransit: true, accountCreation: ['PSL_ACM_NONE'], data: { PSL_DEVICE_ID: { shared: false, collect: ['analytics' as const] } } }; + const before = dataSafetyRows(ds).map(r => r.join(',')).join('\n'); + const next = { ...ds, accountCreation: ['PSL_ACM_USER_ID_PASSWORD'], data: { PSL_CRASH_LOGS: { shared: false, collect: ['analytics' as const] } } }; + const changes = dataSafetyDiff(before, dataSafetyRows(next, { deletionUrl: 'https://example.org/delete' })); + expect(changes.some(c => c.response === 'PSL_ACM_NONE' && c.from === 'TRUE' && c.to === '')).toBe(true); + expect(changes.some(c => c.response === 'PSL_ACM_USER_ID_PASSWORD' && c.to === 'TRUE')).toBe(true); + expect(changes.some(c => c.question.includes('PSL_DEVICE_ID') && c.from && !c.to)).toBe(true); + const lines = changeLines(changes); + expect(lines[0]).toBe(' account and deletion answers:'); + expect(lines.join('\n')).toMatch(/PSL_ACM_NONE\]: TRUE -> \(empty\)/); + expect(dataSafetyDiff(null, dataSafetyRows(ds)).length).toBeGreaterThan(2); + }); +});