storebound/cli/lib/console-answers.ts

448 lines
39 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 { consoleLink } from './console-links';
import { AGE_RATING_NONE } from '../apple/appinfo';
import { type AgeGroup, AGE_GROUPS, appleName, IARC_DETAIL_KEYS, IARC_DETAIL_SECTIONS, IARC_QUESTIONS, type IarcDetailSection, 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';
import type { ProjectSignals } from './project-signals';
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];
/** Forms with a console plan only (the agent drives them; no scripted fill): apple-app-group (the developer portal App Group
* assignment the API cannot do, cli/lib/console-apple-group-plan.ts), Play Store settings (the App category the
* API cannot set), the App content overview and its declarations without a fill form (cli/lib/console-play-plans.ts). */
export const PLAN_ONLY_FORM_IDS = ['play-store-settings', 'play-app-content', 'play-privacy-policy', 'play-ads', 'play-sign-in-details',
'play-advertising-id', 'play-government-apps', 'play-financial-features', 'play-health-apps', 'play-production-countries', 'play-production-release',
'play-games-services', 'play-publish-changes', 'apple-app-group'] as const;
export type PlanOnlyFormId = typeof PLAN_ONLY_FORM_IDS[number];
/** Every form `console plan` / `console verify` know. */
export const PLAN_FORM_IDS = [...FORM_IDS, ...PLAN_ONLY_FORM_IDS] as const;
export type PlanFormId = typeof PLAN_FORM_IDS[number];
export const isPlanForm = (s: string | undefined): s is PlanFormId => !!s && (PLAN_FORM_IDS as readonly string[]).includes(s);
export const isPlanOnly = (s: string): s is PlanOnlyFormId => (PLAN_ONLY_FORM_IDS as readonly string[]).includes(s);
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.',
'Every field is checked before the final button (`storebound console verify <form>` reads the page back). storebound never submits or publishes: the owner presses the final button (App Privacy: storebound saves the answers as a draft, public only when the owner presses 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/<play.developerId>/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/<developer id>/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: 'Send changes for review', fillable: true,
open: [pickApp, `${consoleLink('play.content-rating', '> Start questionnaire')}`],
notes: ['The IARC Terms of Use box on page 1 is an agreement: storebound never ticks it. Tick it before the run (page 1, under the email address and the category): Next does nothing until it is ticked (verified 2026-10-13).',
'The Summary\'s button is "Save", not "Submit" (verified 2026-10-12). storebound presses it: it stores the rating declaration, which Google gets only with the next "Send changes for review" in Publishing overview (yours).'] };
case 'play-target-audience': return { id, store: 'play', title: 'Play Console: Target audience and content', url: PLAY, stopBefore: 'Send changes for review', fillable: true,
open: [pickApp, `${consoleLink('play.target-audience', '> Start (or Manage)')}; the Ads declaration is ${consoleLink('play.ads')}`],
notes: ['storebound presses the last page\'s Save: it stores the declaration, which Google gets only with the next "Send changes for review" in Publishing overview (yours).'] };
}
}
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;
/** 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<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;
}
/** App Privacy types with no Play Data safety equivalent that an app without tracking and without ad / attribution
* SDKs does not collect unless a project signal says so: type -> the signal that keeps it a question. */
const QUIET_TYPES: Partial<Record<AppleDataTypeId, (s: ProjectSignals) => string | null>> = {
ADVERTISING_DATA: () => null,
CUSTOMER_SUPPORT: s => s.support,
ENVIRONMENT_SCANNING: s => s.ar,
HANDS: s => s.ar,
HEAD: s => s.ar,
};
function appPrivacy(config: ShipConfig, signals?: ProjectSignals): Answer[] {
const ds = config.dataSafety, ov: Partial<Record<AppleDataTypeId, AppleTypeAnswer>> = config.appPrivacy?.types ?? {};
// appPrivacy.types is authoritative: a type it does not list is not collected (not derived from dataSafety, not asked)
const authoritative = Object.keys(ov).length > 0;
// no tracking, no ad / attribution SDK, no app-ads.txt: the ad-related and AR types default to not collected
const quiet = !!signals && config.appPrivacy?.tracking === false && !signals.adSdks.length && !signals.attributionSdks.length && !config.ads?.appAdsTxt?.length;
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; }
if (authoritative && !o) {
out.push(ok(aid, l, false, 'appPrivacy.types (not listed: not collected)', g ? `dataSafety.data.${g.from.join(', ')} maps here, but appPrivacy.types does not list ${id}: add it there if the app or an SDK collects it.` : undefined));
continue;
}
const quietWhy = quiet && QUIET_TYPES[id] ? QUIET_TYPES[id]!(signals!) : undefined;
if (quietWhy === null) { out.push(ok(aid, l, false, 'appPrivacy.tracking false, no ad / attribution SDK and no code signal in the project')); 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)',
};
/** IARC's Digital purchases question and the box its Yes reveals (exact console labels, 2026-10-21). */
export const IARC_DIGITAL_PURCHASES = 'Digital purchases', IARC_DIGITAL_GOODS = 'Purchases of digital goods';
const CATEGORY_LABELS = { game: 'Game', social: 'Social or communication', other: 'All other app types' } as const;
/** The IARC follow-up questions (console.contentRating.details), in the page's order: section -> key -> label. The
* structure was recorded on a real questionnaire (2026-10-12); the wording is storebound's. */
export const IARC_DETAIL_LABELS: Record<IarcDetailSection, Record<string, string>> = {
violence: {
humans: 'Violence: against humans or human-like characters (checkbox)', nonHumans: 'Violence: against non-humans (fantasy creatures, animals, robots; checkbox)',
setting: 'Violence: the setting (option)', childlike: 'Violence: in a cartoon or childlike style?', reactions: 'Violence: reactions to harm (checkboxes)',
presentation: 'Violence: how it is presented (checkboxes)', blood: 'Violence: blood level (option)', historicalWar: 'Violence: set in a historical war?',
innocents: 'Violence: against innocent or defenseless characters?', fierceSounds: 'Violence: fierce or realistic sounds?',
},
fear: {}, sexuality: {}, gambling: {}, language: {}, controlledSubstances: {}, crudeHumor: {},
digitalPurchases: { lootBoxes: 'Digital purchases: random items (loot boxes)?', playerTrading: 'Digital purchases: can players trade items with each other?' },
userInteraction: { block: 'User interaction: users can block other users?', report: 'User interaction: users can report users or content?',
moderation: 'User interaction: chat or shared content is moderated?', friendsOnly: 'User interaction: only with accepted friends?' },
miscellaneous: { naziSymbols: 'Miscellaneous: Nazi symbols or references?', koreaHistory: 'Miscellaneous: content about Korean history that may be sensitive?',
terrorism: 'Miscellaneous: terrorism or extremist content?', crimes: 'Miscellaneous: depicts or encourages crimes?' },
};
/** Detail section -> the yes / no topic that reveals it (miscellaneous: always shown). */
const DETAIL_TOPIC: Record<IarcDetailSection, IarcQuestion[] | null> = {
violence: ['violence'], fear: ['fear'], sexuality: ['sexuality'], gambling: ['gambling', 'simulatedGambling'], language: ['language'],
controlledSubstances: ['controlledSubstances'], crudeHumor: ['crudeHumor'], digitalPurchases: ['digitalPurchases'], userInteraction: ['userInteraction'], miscellaneous: null,
};
const SECTION_NAMES: Record<IarcDetailSection, string> = {
violence: 'Violence', fear: 'Fear', sexuality: 'Sexuality', gambling: 'Gambling', language: 'Language', controlledSubstances: 'Controlled Substance',
crudeHumor: 'Crude Humor', digitalPurchases: 'Digital Purchases', userInteraction: 'Miscellaneous (user interaction)', miscellaneous: 'Miscellaneous',
};
export const iarcSectionName = (s: IarcDetailSection): string => SECTION_NAMES[s];
/** The follow-up ids a topic reveals when it is Yes (for the note of an unanswered topic). */
const followUpsOf = (q: IarcQuestion): string => {
const secs = IARC_DETAIL_SECTIONS.filter(s => DETAIL_TOPIC[s]?.includes(q));
const keys = secs.flatMap(s => Object.keys(IARC_DETAIL_KEYS[s]).filter(k => !(s === 'digitalPurchases' && k === 'lootBoxes')).map(k => `${s}.${k}`));
return secs.length ? ` If Yes, the page asks follow-ups: set console.contentRating.details.${keys.length ? keys.join(', ') : `${secs[0]}.other`} too.` : '';
};
function contentRating(config: ShipConfig): Answer[] {
const cr = config.console?.contentRating, det = cr?.details ?? {}, social = config.social;
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.'));
out.push({ ...ask('agree:iarcTerms', 'IARC Terms of Use', 'none', 'An agreement on page 1 (a checkbox): only the owner ticks it, never the agent or storebound.'), agreement: true });
const products = config.products ?? [];
const sells = products.length > 0 || (config.subscriptions?.length ?? 0) > 0;
const derived = (q: IarcQuestion): [boolean, string] | null => {
if (q === 'digitalPurchases' && sells) return [true, products.length ? 'products' : 'subscriptions'];
if (q === 'lootBoxes') {
if (det.digitalPurchases?.lootBoxes !== undefined) return [det.digitalPurchases.lootBoxes, 'console.contentRating.details.digitalPurchases.lootBoxes'];
if (products.some(p => p.randomRewards === true)) return [true, `products[].randomRewards (${products.filter(p => p.randomRewards).map(p => p.id).join(', ')})`];
if (products.length && products.every(p => p.randomRewards === false) && !config.subscriptions?.length) return [false, 'products[].randomRewards (all false)'];
if (!sells) return [false, 'products (nothing is sold)'];
}
if (q === 'userInteraction' && social) return [social.interaction, 'social.interaction'];
if (q === 'sharesLocation' && social?.sharesLocation !== undefined) return [social.sharesLocation, 'social.sharesLocation'];
return null;
};
const topic = new Map<IarcQuestion, boolean | null>();
for (const q of IARC_QUESTIONS) {
const v = cr?.answers?.[q], d = v === undefined ? derived(q) : null;
topic.set(q, v ?? d?.[0] ?? null);
if (v !== undefined) out.push(ok(`q:${q}`, IARC_LABELS[q], v, `console.contentRating.answers.${q}`));
else if (d) out.push(ok(`q:${q}`, IARC_LABELS[q], d[0], d[1], q === 'lootBoxes' && d[0] ? 'Paid random items: ClassInd (Brazil) rated a game with them 18 on a real run (2026-10-12).' : undefined));
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.${followUpsOf(q)}`));
}
// the follow-ups a Yes reveals (and Miscellaneous, always shown): from details, else proven facts, else the owner
for (const sec of IARC_DETAIL_SECTIONS) {
const topics = DETAIL_TOPIC[sec];
if (topics && !topics.some(t => topic.get(t) === true)) continue;
const given = (det as Record<string, Record<string, unknown> | undefined>)[sec] ?? {};
const src = (k: string) => `console.contentRating.details.${sec}.${k}`;
// Yes on Digital purchases reveals boxes; "Purchases of digital goods" must be ticked before the loot-box and trading
// follow-ups show (2026-10-21)
if (sec === 'digitalPurchases') out.push(ok(`d:digitalPurchases:digitalGoods`, `Digital purchases: ${IARC_DIGITAL_GOODS}`, true, `console.contentRating.answers.digitalPurchases (Yes) or what is sold`, 'Tick it first: the loot-box and trading questions show only after it.'));
for (const [k, kind] of Object.entries(IARC_DETAIL_KEYS[sec])) {
if (sec === 'digitalPurchases' && k === 'lootBoxes') continue; // the q:lootBoxes answer above
const label = IARC_DETAIL_LABELS[sec][k]!, id = `d:${sec}:${k}`, v = given[k];
if (kind === 'labels') {
if (Array.isArray(v)) for (const l of v as string[]) out.push(ok(`${id}:${l}`, `${label}: ${l}`, true, src(k))); // [] = tick none
else out.push(ask(id, label, src(k), `Tick the boxes that fit; set ${src(k)} (the console's labels, [] for none) to have it filled.`));
continue;
}
if (v !== undefined) { out.push(ok(id, label, v as string | boolean, src(k))); continue; }
const fact = sec === 'userInteraction' && social && social[k as 'block'] !== undefined ? [social[k as 'block'] as boolean, `social.${k}`] as const
: sec === 'digitalPurchases' && k === 'playerTrading' && social && !social.interaction ? [false, 'social.interaction (no interaction, so no trading)'] as const : null;
if (fact) out.push(ok(id, label, fact[0], fact[1]));
else out.push(ask(id, label, src(k), `Answer from the app's real content; set ${src(k)} to have it filled.`));
}
const other = Object.entries((given.other as Record<string, boolean | string | string[]> | undefined) ?? {});
for (const [qText, v] of other) {
if (Array.isArray(v)) for (const l of v) out.push(ok(`d:${sec}:other:${qText}:${l}`, `${SECTION_NAMES[sec]}: ${qText}: ${l}`, true, src('other')));
else out.push(ok(`d:${sec}:other:${qText}`, `${SECTION_NAMES[sec]}: ${qText}`, v, src('other')));
}
if (!Object.keys(IARC_DETAIL_KEYS[sec]).length && !other.length)
out.push(ask(`d:${sec}:followUps`, `${SECTION_NAMES[sec]}: the follow-up questions shown after Yes`, src('other'), `Read them on the page once Yes is picked; set ${src('other')} ({ '<question as shown>': true | false | '<option>' | ['<labels>'] }) to have them 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;
}
/** `signals`: what the project scan found (console plan passes it); without it nothing is derived from the project. */
export function consoleAnswers(id: FormId, config: ShipConfig, signals?: ProjectSignals): Answer[] {
switch (id) {
case 'apple-app-record': return appRecord(config);
case 'apple-age-rating': return ageRating(config);
case 'apple-app-privacy': return appPrivacy(config, signals);
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}`));
const declaration = meta.id === 'play-content-rating' || meta.id === 'play-target-audience';
g.push(!meta.fillable ? 'Answers marked "check" are yours to confirm in storebound.config.ts (apple.info.ageRating).'
: declaration ? `storebound presses the declaration's Save itself (nothing goes to Google before it is sent for review). Once every App content card is done, press ${meta.stopBefore} in Publishing overview yourself.`
: `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.'}`);
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) };
}