// Answers for console forms that have no API (`storebound console fill
`), built from the config only. Pure. // AGENTS.md rule 5: an answer is `certain` only when the config proves it; everything else has value null, stays EMPTY // in the page, is highlighted and is listed for the owner. Forms: // apple-app-record App Store Connect > Apps > + > New App // apple-age-rating guide only: `storebound apple appinfo` sets every question through the API // apple-app-privacy App Store Connect > the app > App Privacy (no API) // play-app-record Play Console > Home (All apps) > Create app (the Play Developer API cannot create apps) // play-content-rating Play Console > Policy > App content > Content rating (IARC questionnaire) // play-target-audience Play Console > Policy > App content > Target audience and content (+ the Ads declaration) // Sources (checked October 2026): // https://developer.apple.com/help/app-store-connect/create-an-app-record/add-a-new-app // https://developer.apple.com/help/app-store-connect/manage-app-information/manage-app-privacy // https://developer.apple.com/app-store/app-privacy-details/ (data types, purposes) // https://developer.apple.com/tutorials/data/documentation/appstoreconnectapi/ageratingdeclaration/attributes-data.dictionary.json // https://support.google.com/googleplay/android-developer/answer/9859152 (create and set up your app) // https://support.google.com/googleplay/android-developer/answer/9859655 (content ratings) // https://support.google.com/googleplay/android-developer/answer/9867159 (target audience and content) import { AGE_RATING_NONE } from '../apple/appinfo'; import { type AgeGroup, AGE_GROUPS, appleName, IARC_QUESTIONS, type IarcQuestion, profileOf, type ShipConfig } from './config'; import { APPLE_PRIVACY, applePurpose } from './declarations'; import { APPLE_DATA_TYPE_IDS, APPLE_PRIVACY_TYPES, APPLE_PURPOSE_IDS, type AppleDataTypeId, type AppleTypeAnswer, purposeName } from './app-privacy'; import { maskUser } from './credentials'; export const FORM_IDS = ['apple-app-record', 'apple-age-rating', 'apple-app-privacy', 'play-app-record', 'play-content-rating', 'play-target-audience'] as const; export type FormId = typeof FORM_IDS[number]; export type Store = 'apple' | 'play'; export type AnswerValue = string | boolean | null; export interface Answer { id: string; label: string; value: AnswerValue; certain: boolean; source: string; note?: string; /** Filled from the config but still the owner's call (Play free or paid: free can never become paid): highlighted * in the page even when filled, and printed with its note. */ ownerCheck?: boolean; /** An agreement (Play's Developer Program Policies / US export laws boxes): NEVER ticked by storebound, only highlighted. */ agreement?: boolean; } export interface FormMeta { id: FormId; store: Store; title: string; url: string; /** What the owner clicks to open the form when storebound cannot open it itself (a navigation step was not found). */ open: string[]; /** The control storebound stops before; the owner presses it. */ stopBefore: string; /** false: no page filling at all, guidance only (the API covers it). */ fillable: boolean; /** Lines said at the start of a fill run and printed in the guidance (what this form does not set, owner checks). */ notes?: string[]; } /** The `--dry --json` result (and the head of a fill run's result). */ export interface ConsoleResult { form: FormId; store: Store; title: string; url: string; answers: Answer[]; uncertain: number; guidance: string[] } export const DISCLAIMER = [ 'storebound console fill is AI-assisted autofill. Console pages change without notice and it may misplace an answer.', 'Review every field yourself. storebound never submits or publishes: you press the final button (App Privacy: storebound saves the answers as a draft, public only when you press Publish).', 'storebound never types passwords or touches sign-in pages: you sign in in the browser window yourself.', ]; const ASC = 'https://appstoreconnect.apple.com'; const PLAY = 'https://play.google.com/console'; export const isForm = (s: string | undefined): s is FormId => !!s && (FORM_IDS as readonly string[]).includes(s); export function formMeta(id: FormId, config: ShipConfig): FormMeta { const app = config.apple?.appId ? `${ASC}/apps/${config.apple.appId}` : `${ASC}/apps`; const privacy = config.apple?.appId ? `${app}/distribution/privacy` : app; const pickApp = 'Pick the developer account and the app if Play Console asks'; switch (id) { case 'apple-app-record': return { id, store: 'apple', title: 'App Store Connect: New App', url: `${ASC}/apps`, stopBefore: 'Create', fillable: true, open: ['Apps > the add button (+) at the top left > New App'] }; case 'apple-age-rating': return { id, store: 'apple', title: 'App Store Connect: Age Ratings', url: app, stopBefore: 'Save', fillable: false, open: ['General > App Information > Age Ratings > Set Up Age Ratings (only to look: `storebound apple appinfo` sets it)'] }; case 'apple-app-privacy': return { id, store: 'apple', title: 'App Store Connect: App Privacy', url: privacy, stopBefore: 'Publish', fillable: true, open: [...(config.apple?.appId ? [] : ['Apps > your app (set apple.appId so storebound opens it directly)']), 'App Privacy in the sidebar (storebound clicks Get Started, or Edit next to Data Types, and walks the questionnaire itself)'] }; case 'play-app-record': return { id, store: 'play', title: 'Play Console: Create app', url: config.play?.developerId ? `${PLAY}/u/0/developers/${encodeURIComponent(config.play.developerId)}/create-new-app` : PLAY, stopBefore: 'Create app', fillable: true, open: config.play?.developerId ? ['the Create app form of the developer account opens by link (/developers//create-new-app)'] : ['Pick the developer account if Play Console asks (play.developerId or play.developerAccount picks it)', 'Home (All apps) > Create app (or /console/u/0/developers//create-new-app)'], notes: [ 'The form has an App package name field (play.packageName, at most 150 characters): storebound fills it and presses "Check availability" (read-only), which should say "Package name available". The package name is fixed for good once the app is created.', 'Free or paid is an owner check: a free app can never become paid later on Google Play.', 'The Developer Program Policies and US export laws boxes are agreements: storebound never ticks them. Read and tick them yourself, then press Create app.', ] }; case 'play-content-rating': return { id, store: 'play', title: 'Play Console: Content rating (IARC)', url: PLAY, stopBefore: 'Submit', fillable: true, open: [pickApp, 'Policy > App content > Content rating > Start (or Start new questionnaire)'] }; case 'play-target-audience': return { id, store: 'play', title: 'Play Console: Target audience and content', url: PLAY, stopBefore: 'Save', fillable: true, open: [pickApp, 'Policy > App content > Target audience and content > Start (or Manage); the Ads declaration is App content > Ads'] }; } } const ok = (id: string, label: string, value: AnswerValue, source: string, note?: string): Answer => ({ id, label, value, certain: true, source, ...(note ? { note } : {}) }); const ask = (id: string, label: string, source: string, note: string): Answer => ({ id, label, value: null, certain: false, source, note }); /** App Store Connect primary language names for the common locales (the dialog shows names, not codes). */ const LANGUAGE_NAMES: Record = { 'en-US': 'English (U.S.)', 'en-GB': 'English (U.K.)', 'en-AU': 'English (Australia)', 'en-CA': 'English (Canada)', 'de-DE': 'German', 'fr-FR': 'French', 'fr-CA': 'French (Canada)', 'es-ES': 'Spanish (Spain)', 'es-MX': 'Spanish (Mexico)', 'it': 'Italian', 'ja': 'Japanese', 'ko': 'Korean', 'pt-BR': 'Portuguese (Brazil)', 'pt-PT': 'Portuguese (Portugal)', 'nl-NL': 'Dutch', 'ru': 'Russian', 'tr': 'Turkish', 'zh-Hans': 'Chinese (Simplified)', 'zh-Hant': 'Chinese (Traditional)', }; function appRecord(config: ShipConfig): Answer[] { const a = config.apple, r = config.console?.appRecord; const locale = a?.primaryLocale ?? config.languages.find(l => l.apple)?.apple; const lang = locale ? LANGUAGE_NAMES[locale] : undefined; return [ a ? ok('platform:ios', 'Platforms: iOS', true, 'apple section') : ask('platform:ios', 'Platforms: iOS', 'config', 'The config has no apple section.'), ok('name', 'Name', appleName(config), a?.name?.trim() ? 'apple.name' : 'app.name', `Must be unique on the App Store (at most 30 characters); when App Store Connect says it is already being used, set apple.name (e.g. "${config.app.name}: ") and fill again.`), lang ? ok('language', 'Primary Language', lang, a?.primaryLocale ? 'apple.primaryLocale' : 'languages[0].apple') : ask('language', 'Primary Language', 'apple.primaryLocale', locale ? `Pick the language of locale "${locale}".` : 'Set apple.primaryLocale (e.g. en-US).'), a?.bundleId ? ok('bundleId', 'Bundle ID', a.bundleId, 'apple.bundleId', 'Register it first if it is not in the list: storebound apple bundle.') : ask('bundleId', 'Bundle ID', 'apple.bundleId', 'Set apple.bundleId.'), r?.sku ? ok('sku', 'SKU', r.sku, 'console.appRecord.sku') : ask('sku', 'SKU', 'console.appRecord.sku', `Your internal id, never shown and never changeable (for example ${a?.bundleId ?? 'the bundle id'}). Set console.appRecord.sku to have it filled.`), r?.userAccess ? ok('access', 'User Access', r.userAccess, 'console.appRecord.userAccess') : ask('access', 'User Access', 'console.appRecord.userAccess', 'Full Access (everyone in your team) or Limited Access (pick the users).'), ]; } /** Play Console's default language entries ("English (United States) – en-US"), by Play listing code. MODELED from * the Play Console language list (help page "Add translations and localized assets"), not recorded. */ export const PLAY_LANGUAGE_NAMES: Record = { 'af': 'Afrikaans', 'am': 'Amharic', 'ar': 'Arabic', 'hy-AM': 'Armenian', 'az-AZ': 'Azerbaijani', 'bn-BD': 'Bangla', 'eu-ES': 'Basque', 'be': 'Belarusian', 'bg': 'Bulgarian', 'my-MM': 'Burmese', 'ca': 'Catalan', 'zh-HK': 'Chinese (Hong Kong)', 'zh-CN': 'Chinese (Simplified)', 'zh-TW': 'Chinese (Traditional)', 'hr': 'Croatian', 'cs-CZ': 'Czech', 'da-DK': 'Danish', 'nl-NL': 'Dutch', 'en-AU': 'English (Australia)', 'en-CA': 'English (Canada)', 'en-IN': 'English (India)', 'en-SG': 'English (Singapore)', 'en-ZA': 'English (South Africa)', 'en-GB': 'English (United Kingdom)', 'en-US': 'English (United States)', 'et': 'Estonian', 'fil': 'Filipino', 'fi-FI': 'Finnish', 'fr-CA': 'French (Canada)', 'fr-FR': 'French (France)', 'gl-ES': 'Galician', 'ka-GE': 'Georgian', 'de-DE': 'German', 'el-GR': 'Greek', 'gu': 'Gujarati', 'iw-IL': 'Hebrew', 'hi-IN': 'Hindi', 'hu-HU': 'Hungarian', 'is-IS': 'Icelandic', 'id': 'Indonesian', 'it-IT': 'Italian', 'ja-JP': 'Japanese', 'kn-IN': 'Kannada', 'kk': 'Kazakh', 'km-KH': 'Khmer', 'ko-KR': 'Korean', 'ky-KG': 'Kyrgyz', 'lo-LA': 'Lao', 'lv': 'Latvian', 'lt': 'Lithuanian', 'mk-MK': 'Macedonian', 'ms': 'Malay', 'ms-MY': 'Malay (Malaysia)', 'ml-IN': 'Malayalam', 'mr-IN': 'Marathi', 'mn-MN': 'Mongolian', 'ne-NP': 'Nepali', 'no-NO': 'Norwegian', 'fa': 'Persian', 'pl-PL': 'Polish', 'pt-BR': 'Portuguese (Brazil)', 'pt-PT': 'Portuguese (Portugal)', 'pa': 'Punjabi', 'ro': 'Romanian', 'rm': 'Romansh', 'ru-RU': 'Russian', 'sr': 'Serbian', 'si-LK': 'Sinhala', 'sk': 'Slovak', 'sl': 'Slovenian', 'es-419': 'Spanish (Latin America)', 'es-ES': 'Spanish (Spain)', 'es-US': 'Spanish (United States)', 'sw': 'Swahili', 'sv-SE': 'Swedish', 'ta-IN': 'Tamil', 'te-IN': 'Telugu', 'th': 'Thai', 'tr-TR': 'Turkish', 'uk': 'Ukrainian', 'ur': 'Urdu', 'vi': 'Vietnamese', 'zu': 'Zulu', }; /** The Play Console label of a listing language code ("English (United States) – en-US"), or null when unknown. */ export const playLanguageLabel = (code: string): string | null => (PLAY_LANGUAGE_NAMES[code] ? `${PLAY_LANGUAGE_NAMES[code]} – ${code}` : null); /** The Play app name limit (Create app form and store listing title). */ export const PLAY_NAME_MAX = 30; /** The Create app form's App package name limit (verified 2026-10-12). */ export const PLAY_PACKAGE_MAX = 150; function playAppRecord(config: ShipConfig): Answer[] { const p = config.play; const name = p?.name ?? config.app.name, nameSrc = p?.name ? 'play.name' : 'app.name'; const len = [...name].length; const first = config.languages.find(l => l.play)?.play; const code = p?.defaultLanguage ?? (Array.isArray(first) ? first[0] : first); const lang = code ? playLanguageLabel(code) : null; const langSrc = p?.defaultLanguage ? 'play.defaultLanguage' : 'languages[0].play'; const profile = config.app.profile; const sells = (config.products?.length ?? 0) > 0 || (config.subscriptions?.length ?? 0) > 0, ads = (config.ads?.appAdsTxt?.length ?? 0) > 0; const once = 'A free app can never become paid later on Google Play (a paid app can become free).'; const pkg = p?.packageName; const out: Answer[] = [ len <= PLAY_NAME_MAX ? ok('name', 'App name', name, nameSrc, 'Shown on Google Play; the store listing can change it later.') : ask('name', 'App name', nameSrc, `"${name}" is ${len} characters; Play allows ${PLAY_NAME_MAX}. Set play.name to a shorter name.`), pkg && pkg.length <= PLAY_PACKAGE_MAX ? ok('packageName', 'App package name', pkg, 'play.packageName', 'Fixed for good once the app is created. Press "Check availability" next to it (read-only): "Package name available".') : ask('packageName', 'App package name', 'play.packageName', pkg ? `"${pkg}" is longer than ${PLAY_PACKAGE_MAX} characters.` : 'Set play.packageName (the applicationId of the Android build): it is fixed for good once the app is created.'), lang ? ok('language', 'Default language', lang, langSrc) : ask('language', 'Default language', 'play.defaultLanguage', code ? `Pick the language of code "${code}" (storebound does not know its Play Console name).` : 'Set play.defaultLanguage (e.g. en-US) or a play code on the first language.'), profile ? ok('type', 'App or game', profile === 'game' ? 'Game' : 'App', 'app.profile', 'Changeable later in the store settings.') : ask('type', 'App or game', 'app.profile', 'Set app.profile (game | app | subscription-app) to have it filled.'), ]; if (p?.free !== undefined) out.push({ ...ok('price', 'Free or paid', p.free ? 'Free' : 'Paid', 'play.free', `Owner check: ${once}`), ownerCheck: true }); else if (sells || ads) out.push({ ...ok('price', 'Free or paid', 'Free', [sells ? (config.products?.length ? 'products' : 'subscriptions') : '', ads ? 'ads.appAdsTxt' : ''].filter(Boolean).join(' + '), `Owner check: the config earns from in-app purchases / ads only, so Free. ${once} Set play.free to make it explicit.`), ownerCheck: true }); else out.push(ask('price', 'Free or paid', 'play.free', `Set play.free. ${once}`)); const agree = (id: string, label: string) => ({ ...ask(id, label, 'none', 'An agreement: only you can accept it. storebound never ticks it.'), agreement: true }); out.push(agree('agree:policies', 'Declarations: Developer Program Policies'), agree('agree:exportLaws', 'Declarations: US export laws')); return out; } /** Every question of the API's ageRatingDeclarations (checked October 2026); appinfo.ts AGE_RATING_NONE plus the newer ones. */ export const AGE_RATING_API = [...new Set([...Object.keys(AGE_RATING_NONE), 'socialMedia', 'socialMediaAgeRestricted'])]; function ageRating(config: ShipConfig): Answer[] { const o = config.apple?.info?.ageRating; return AGE_RATING_API.map(q => { if (o && q in o) return ok(q, q, o[q] ?? 'none', `apple.info.ageRating.${q}`); if (!(q in AGE_RATING_NONE)) return ask(q, q, `apple.info.ageRating.${q}`, 'storebound apple appinfo does not send this one unless you set it in apple.info.ageRating; App Store Connect asks it.'); const none = AGE_RATING_NONE[q] ?? 'none'; return o ? ok(q, q, none, 'apple.info.ageRating (not overridden)') : ask(q, q, 'apple.info.ageRating', `storebound apple appinfo answers ${String(none)} unless you set apple.info.ageRating; confirm that is true.`); }); } /** App Privacy data types (App privacy details on the App Store), in Apple's order: [category, type]. */ export const APPLE_DATA_TYPES: [string, string][] = APPLE_DATA_TYPE_IDS.map(id => [APPLE_PRIVACY_TYPES[id][0], APPLE_PRIVACY_TYPES[id][1]]); export const APPLE_PURPOSES: string[] = APPLE_PURPOSE_IDS.map(purposeName); const DEV_ADS = purposeName('DEVELOPERS_ADVERTISING'); /** Apple data types the config's Data safety answers say are collected: type -> { from, purposes, comms (a Play * "communications" purpose: maybe marketing) }. One App Privacy purpose per Play purpose (applePurpose). */ export function privacyTypes(config: ShipConfig): Map; comms: boolean }> { const m = new Map; comms: boolean }>(); for (const [id, u] of Object.entries(config.dataSafety?.data ?? {})) { const name = APPLE_PRIVACY[id]?.[1] ?? 'Other Data Types'; const e = m.get(name) ?? { from: [], purposes: new Set(), comms: false }; e.from.push(id); const all = [...u.collect, ...(u.shared ? u.share ?? [] : [])]; for (const p of all) e.purposes.add(applePurpose(p)); e.comms ||= all.includes('communications'); m.set(name, e); } return m; } function appPrivacy(config: ShipConfig): Answer[] { const ds = config.dataSafety, ov: Partial> = config.appPrivacy?.types ?? {}; const label = 'Do you or your third-party partners collect data from this app?'; const ovIds = Object.keys(ov) as AppleDataTypeId[]; if (!ds && !ovIds.length) return [ask('collects', label, 'dataSafety', 'The config has no dataSafety answers and no appPrivacy.types: answer what the app and every SDK collect (store-declarations skill), then rerun.')]; const ovYes = ovIds.filter(id => ov[id]?.collected === true); const out: Answer[] = []; if (ovYes.length) out.push(ok('collects', label, true, `appPrivacy.types (${ovYes.join(', ')})`)); else if (ds) out.push(ok('collects', label, ds.collectsData, 'dataSafety.collectsData')); else if (APPLE_DATA_TYPE_IDS.every(id => ov[id]?.collected === false)) out.push(ok('collects', label, false, 'appPrivacy.types')); else out.push(ask('collects', label, 'appPrivacy.types', 'No type is marked collected, but not every type is answered: answer the rest, or add dataSafety.')); if (out[0]!.value === false) return out; const got = ds?.collectsData ? privacyTypes(config) : new Map; comms: boolean }>(); const mapped = new Set(Object.values(APPLE_PRIVACY).map(([, n]) => n)); const collected: AppleDataTypeId[] = []; for (const id of APPLE_DATA_TYPE_IDS) { const [cat, name] = APPLE_PRIVACY_TYPES[id]; const g = got.get(name), o = ov[id], aid = `type:${name}`, l = `Data type: ${cat} > ${name}`; if (o?.collected !== undefined) { out.push(ok(aid, l, o.collected, `appPrivacy.types.${id}.collected`)); if (o.collected) collected.push(id); continue; } const set = `set appPrivacy.types.${id}.collected to have it filled`; if (g && name === 'Other Data Types') out.push(ask(aid, l, `dataSafety.data.${g.from.join(', ')}`, `${g.from.join(', ')} has no exact App Store type: decide where it belongs (${set}).`)); else if (g) { out.push(ok(aid, l, true, `dataSafety.data.${g.from.join(', ')}`)); collected.push(id); } else if (ds && mapped.has(name)) out.push(ok(aid, l, false, 'dataSafety.data (not listed)')); else out.push(ask(aid, l, `appPrivacy.types.${id}`, `${name === 'Advertising Data' ? 'No Play Data safety equivalent; ad SDKs usually collect it: check each SDK\'s privacy disclosure' : ds ? 'No Play Data safety equivalent: decide from the app and its SDKs' : 'Decide from the app and its SDKs'}; ${set}.`)); } for (const id of collected) { const name = APPLE_PRIVACY_TYPES[id][1], g = got.get(name), o = ov[id]; if (o?.purposes) for (const p of APPLE_PURPOSE_IDS) out.push(ok(`purpose:${name}:${purposeName(p)}`, `${name}: purpose ${purposeName(p)}`, o.purposes.includes(p), `appPrivacy.types.${id}.purposes`)); else if (g) for (const p of APPLE_PURPOSES) { const pid = `purpose:${name}:${p}`, pl = `${name}: purpose ${p}`, src = `dataSafety.data.${g.from.join(', ')}`; if (p === DEV_ADS && g.comms) out.push(ask(pid, pl, src, `Play says "developer communications": marketing messages to your users are Developer's Advertising or Marketing, service notifications are App Functionality. Set appPrivacy.types.${id}.purposes to have it filled.`)); else if (p === DEV_ADS) out.push(ok(pid, pl, false, src, `Yes only for your own ads or marketing to your users (not an ad SDK's ads): set appPrivacy.types.${id}.purposes with DEVELOPERS_ADVERTISING.`)); else out.push(ok(pid, pl, g.purposes.has(p), src)); } else for (const p of APPLE_PURPOSES) out.push(ask(`purpose:${name}:${p}`, `${name}: purpose ${p}`, `appPrivacy.types.${id}.purposes`, `Set appPrivacy.types.${id}.purposes to have it filled.`)); out.push(o?.linked !== undefined ? ok(`linked:${name}`, `${name}: linked to the user's identity?`, o.linked, `appPrivacy.types.${id}.linked`) : ask(`linked:${name}`, `${name}: linked to the user's identity?`, `appPrivacy.types.${id}.linked`, `Only you know whether you or a partner tie it to an account, device or other identity details. Set appPrivacy.types.${id}.linked to have it filled.`)); out.push(o?.tracking !== undefined ? ok(`tracking:${name}`, `${name}: used for tracking?`, o.tracking, `appPrivacy.types.${id}.tracking`) : ask(`tracking:${name}`, `${name}: used for tracking?`, `appPrivacy.types.${id}.tracking`, `Yes when it is combined with other companies' data for ads or shared with data brokers; tracking also needs App Tracking Transparency. Set appPrivacy.types.${id}.tracking to have it filled.`)); } return out; } export const IARC_LABELS: Record = { violence: 'Violence', fear: 'Fear / horror', sexuality: 'Sexuality or nudity', language: 'Language (profanity)', controlledSubstances: 'Controlled substances (drugs, alcohol, tobacco)', crudeHumor: 'Crude humour', gambling: 'Gambling with real money', simulatedGambling: 'Simulated gambling', lootBoxes: 'Random paid items (loot boxes)', userInteraction: 'Users can interact or exchange content', sharesLocation: 'Shares the user\'s location with other users', digitalPurchases: 'Digital purchases', unrestrictedInternet: 'Unrestricted internet access (browser)', }; const CATEGORY_LABELS = { game: 'Game', social: 'Social or communication', other: 'All other app types' } as const; function contentRating(config: ShipConfig): Answer[] { const cr = config.console?.contentRating; const out: Answer[] = [ok('email', 'Email address', maskUser(config.app.contactEmail), 'app.contactEmail', 'IARC writes to this address; the page gets the full address from the config.')]; const cat = cr?.category ?? (profileOf(config) === 'game' && config.app.profile ? 'game' : undefined); out.push(cat ? ok('category', 'Category', CATEGORY_LABELS[cat], cr?.category ? 'console.contentRating.category' : 'app.profile') : ask('category', 'Category', 'console.contentRating.category', 'Pick the category; set console.contentRating.category to have it filled.')); const sells = (config.products?.length ?? 0) > 0 || (config.subscriptions?.length ?? 0) > 0; for (const q of IARC_QUESTIONS) { const v = cr?.answers?.[q]; if (v !== undefined) out.push(ok(`q:${q}`, IARC_LABELS[q], v, `console.contentRating.answers.${q}`)); else if (q === 'digitalPurchases' && sells) out.push(ok(`q:${q}`, IARC_LABELS[q], true, config.products?.length ? 'products' : 'subscriptions')); else out.push(ask(`q:${q}`, IARC_LABELS[q], `console.contentRating.answers.${q}`, 'Answer from the app\'s real content; set console.contentRating.answers to have it filled.')); } return out; } export const AGE_LABELS: Record = { '5-under': 'Ages 5 and under', '6-8': 'Ages 6-8', '9-12': 'Ages 9-12', '13-15': 'Ages 13-15', '16-17': 'Ages 16-17', '18-over': 'Ages 18 and over' }; function targetAudience(config: ShipConfig): Answer[] { const ta = config.console?.targetAudience; const out: Answer[] = AGE_GROUPS.map(g => ta?.ages ? ok(`age:${g}`, `Target age: ${AGE_LABELS[g]}`, ta.ages.includes(g), 'console.targetAudience.ages') : ask(`age:${g}`, `Target age: ${AGE_LABELS[g]}`, 'console.targetAudience.ages', 'Choose the ages you really target; any group under 13 brings the Families policy.')); const kids = ta?.ages?.some(g => ['5-under', '6-8', '9-12'].includes(g)); if (!kids) out.push(ta?.appealsToChildren !== undefined ? ok('appeal', 'Could your app unintentionally appeal to children?', ta.appealsToChildren, 'console.targetAudience.appealsToChildren') : ask('appeal', 'Could your app unintentionally appeal to children?', 'console.targetAudience.appealsToChildren', 'Look at your art style, characters and store listing honestly.')); const ads = ta?.containsAds ?? (config.ads?.appAdsTxt?.length ? true : undefined); out.push(ads !== undefined ? ok('ads', 'Ads: does your app contain ads?', ads, ta?.containsAds !== undefined ? 'console.targetAudience.containsAds' : 'ads.appAdsTxt') : ask('ads', 'Ads: does your app contain ads?', 'console.targetAudience.containsAds', profileOf(config) === 'game' ? 'The game profile usually ships ads; confirm and set console.targetAudience.containsAds.' : 'Set console.targetAudience.containsAds.')); return out; } export function consoleAnswers(id: FormId, config: ShipConfig): Answer[] { switch (id) { case 'apple-app-record': return appRecord(config); case 'apple-age-rating': return ageRating(config); case 'apple-app-privacy': return appPrivacy(config); case 'play-app-record': return playAppRecord(config); case 'play-content-rating': return contentRating(config); case 'play-target-audience': return targetAudience(config); } } /** The value the page gets: the same as the printed one, except the IARC email (printed masked, filled in full). */ export const fillValue = (form: FormId, a: Answer, config: ShipConfig): AnswerValue => form === 'play-content-rating' && a.id === 'email' && a.certain ? config.app.contactEmail : a.value; export const valueText = (a: Answer): string => a.value === null ? '(you decide)' : a.value === true ? 'Yes' : a.value === false ? 'No' : a.value; /** Step-by-step lines the owner can follow (and copy from) without any autofill. */ export function guidance(meta: FormMeta, answers: Answer[]): string[] { const g: string[] = []; if (!meta.fillable) g.push('Nothing to fill here: `storebound apple appinfo --dry`, then `storebound apple appinfo` sets every age rating answer through the App Store Connect API.'); g.push(...meta.open.map(o => `Open: ${o}`)); for (const a of answers) g.push(`${a.label}: ${a.agreement ? 'you tick it (an agreement; storebound never does)' : valueText(a)}${a.certain && !a.ownerCheck ? '' : ` (check: ${a.note ?? 'not in the config'})`}`); g.push(...(meta.notes ?? []).map(n => `Note: ${n}`)); g.push(meta.fillable ? `Review every field, then press ${meta.stopBefore} yourself. ${meta.id === 'apple-app-privacy' ? 'storebound saves the answers only as a draft (Save in each dialog); nothing is public before Publish.' : 'storebound never saves, submits or publishes.'}` : 'Answers marked "check" are yours to confirm in storebound.config.ts (apple.info.ageRating).'); return g; } export function consoleResult(id: FormId, config: ShipConfig): ConsoleResult { const meta = formMeta(id, config); const answers = consoleAnswers(id, config); return { form: id, store: meta.store, title: meta.title, url: meta.url, answers, uncertain: answers.filter(a => !a.certain).length, guidance: guidance(meta, answers) }; }