storebound/cli/lib/console-answers.ts
CRT 8ffc83bb39 console fill: walk the whole form; the owner only reviews and presses the final button
App Privacy stopped at "now click Get Started yourself" and every page
asked the owner to click Next. The owner should not click what
storebound can safely do.

- Every button of every console form is classified (navigation,
  draft-save, final-submit) in FORM_BUTTONS (cli/lib/console-forms.ts),
  citing Apple's and Google's help pages. A scoped flow gate
  (flowAllowed) lets navigation through; the generic click gate stays
  absolute for field clicks.
- App Privacy: Get Started, Edit next to Data Types (never the Privacy
  Policy Edit), each data type / "Set Up <type>", Next, and the Save
  inside the questionnaire dialogs (a draft: Apple publishes only after
  Publish, per "Manage app privacy"). Each type's dialog may be one page
  or purposes > Next > linked > Next > tracking > Save. It ends with
  "review the summary, then press Publish yourself". Publish is never
  clicked.
- Play content rating and target audience: Next by itself. Save draft,
  Submit, the final Save, Create and Create app stay the owner's.
- An uncertain answer stops the walk on its page (outlined, "answer
  needed on ...") until the owner has set it; then it goes on.

Fixture apple-app-privacy-flow.html (first time, ?existing=1, decoys);
tests/console-flow.test.ts with the guardrail that no log line asks the
owner to click a navigation control.
2026-10-12 06:51:01 +08:00

319 lines
27 KiB
TypeScript

// Answers for console forms that have no API (`storebound console fill <form>`), 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: PLAY, stopBefore: 'Create app', fillable: true,
open: ['Pick the developer account if Play Console asks (play.developerId or play.developerAccount picks it)', 'Home (All apps) > Create app'],
notes: [
'The package name is not part of this form: the first AAB upload sets it for good (storebound play upload --track internal, or storebound flow run release).',
'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<string, string> = {
'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}: <short tagline>") 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<string, string> = {
'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;
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 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.`),
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<string, { from: string[]; purposes: Set<string>; comms: boolean }> {
const m = new Map<string, { from: string[]; purposes: Set<string>; 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<string>(), 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<Record<AppleDataTypeId, AppleTypeAnswer>> = 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<string, { from: string[]; purposes: Set<string>; 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<IarcQuestion, string> = {
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<AgeGroup, string> = { '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) };
}