Play console steps without an API as agent plans: play-store-settings (App category; play listing cannot set it), play-app-content (every card in order, one owner batch) and its cards (privacy policy, ads, sign in details, advertising ID, government, financial, health), play-production-countries and play-production-release; IARC plan with the Terms of Use agreement, follow-ups (console.contentRating.details, products[].randomRewards, social) and the Summary's final Save; config play.category / countries / managedPublishing, console.appContent; check, validate, play status and play release reminders; inPageLoop helper and material checkbox clicks; App Privacy published signal; first IAPs via apple iap-submit --with-version. Pitfalls 27, 28

This commit is contained in:
CRTkafa 2026-10-12 10:16:34 +08:00
parent dbd9f2973c
commit b7bfe89151
37 changed files with 1624 additions and 121 deletions

View file

@ -55,6 +55,10 @@ export interface Product {
platforms?: Platform[];
/** Apple offer codes for this one-time purchase (`storebound apple offer-codes`). */
offerCodes?: IapOfferCodeDef[];
/** The purchase gives a random reward (loot box, gacha pull, mystery pack). The Play IARC questionnaire asks it
* (Digital Purchases > random items): any true answers it Yes, every product false answers it No. Brazil's ClassInd
* rates such games 18 (seen on a real run, 2026-10-12). */
randomRewards?: boolean;
}
export interface Language {
@ -514,6 +518,9 @@ export interface ShipConfig {
accounts?: AccountsConfig;
/** Sign-in for store reviewers (demo account; credentials live in .storebound/ only). */
reviewAccess?: ReviewAccess;
/** What players / users can do with each other (chat, sharing, friends). Answers the Play IARC questionnaire's
* Miscellaneous > user interaction questions (`console plan play-content-rating`). */
social?: SocialConfig;
languages: Language[];
play?: {
packageName: string; defaultLanguage?: string; listing?: string; icon?: string;
@ -532,6 +539,19 @@ export interface ShipConfig {
name?: string;
/** Free (true) or paid (false) on Play. A free app can never become paid later; storebound flags it for the owner. */
free?: boolean;
/** Play Console > Store presence > Store settings > App category, by Play's English name ("Strategy", "Puzzle",
* "Tools"; PLAY_GAME_CATEGORIES / PLAY_APP_CATEGORIES). The Play Developer API has no category field (`play
* listing` cannot set it): an agent sets it with `storebound console plan play-store-settings`. Default: mapped
* from apple.info.categories when there is an exact match (GAMES_STRATEGY -> Strategy). */
category?: string;
/** Countries / regions of the production track (ISO 3166-1 alpha-2, e.g. ["US", "GB", "TR"]). Default: all. The API's
* edits.countryAvailability is read-only, so an agent sets it in Play Console with `storebound console plan
* play-production-countries` before the first production release; `validate` reports a track with 0 countries. */
countries?: string[];
/** Play Console > Publishing overview > Managed publishing, which only the console sets (no API). true: approved
* changes wait until the owner publishes them. Off by default on Play: a release goes live automatically as soon as
* Google approves it, and `play release` / the release plan say so. Record here what the console shows. */
managedPublishing?: boolean;
/** Play 7" and 10" tablet screenshots (renders tab7/ and tab10/). Optional on Play (required: icon, feature graphic,
* at least 2 phone screenshots), but without them the app is not eligible for tablet featuring. Default: true when
* tablet renders exist under the renders folder, else false. false = `shots render` / `shots check`, `validate` and
@ -707,13 +727,132 @@ export const RATING_CATEGORIES = ['game', 'social', 'other'] as const;
export const AGE_GROUPS = ['5-under', '6-8', '9-12', '13-15', '16-17', '18-over'] as const;
export type AgeGroup = typeof AGE_GROUPS[number];
/** Users interacting with each other (Play IARC Miscellaneous; see ShipConfig.social). */
export interface SocialConfig {
/** Users can chat, message, post or share content with each other (in-game chat, comments, shared levels). */
interaction: boolean;
/** Users can block other users. */
block?: boolean;
/** Users can report other users or their content. */
report?: boolean;
/** Chat or shared content is moderated (people or filters). */
moderation?: boolean;
/** Interaction is limited to friends the user accepted (no strangers). */
friendsOnly?: boolean;
/** The app shares the user's location with other users. */
sharesLocation?: boolean;
}
/** A follow-up the plan does not model, keyed by the question text as the console shows it: true / false (Yes / No
* or tick / untick), a radio option label, or the labels of the checkboxes to tick. */
export type IarcOther = Record<string, boolean | string | string[]>;
/**
* Follow-up questions of the Play IARC questionnaire (one long page: each Yes reveals its follow-ups; recorded on a
* real run 2026-10-12). Asked only when the section's answer is Yes (Miscellaneous always). Option labels are the
* console's own words (the agent matches them by "starts with"). Anything left out is asked of the owner in the one
* batch at the start (`storebound console plan play-content-rating`).
*/
export interface IarcDetails {
violence?: {
/** Violence against humans / human-like characters (checkbox). */
humans?: boolean;
/** Violence against non-humans: fantasy creatures, animals, robots (checkbox). */
nonHumans?: boolean;
/** The setting option as the console labels it (a fantasy or a realistic setting ...). */
setting?: string;
/** Cartoon / childlike art style. */
childlike?: boolean;
/** The reaction checkboxes to tick (how characters react to harm), console labels ([] = none). */
reactions?: string[];
/** The presentation checkboxes to tick (how violence is shown), console labels ([] = none). */
presentation?: string[];
/** The blood level option, console label. */
blood?: string;
/** Set in a historical war. */
historicalWar?: boolean;
/** Violence against innocent / defenseless characters. */
innocents?: boolean;
/** Fierce or realistic sounds of violence. */
fierceSounds?: boolean;
other?: IarcOther;
};
fear?: { other?: IarcOther };
sexuality?: { other?: IarcOther };
gambling?: { other?: IarcOther };
language?: { other?: IarcOther };
controlledSubstances?: { other?: IarcOther };
crudeHumor?: { other?: IarcOther };
/** Digital Purchases, after "purchases of digital goods" Yes: random items (default from products[].randomRewards),
* then trading between players (default from social). */
digitalPurchases?: { lootBoxes?: boolean; playerTrading?: boolean; other?: IarcOther };
/** Miscellaneous > user interaction follow-ups (default from `social`). */
userInteraction?: { block?: boolean; report?: boolean; moderation?: boolean; friendsOnly?: boolean; other?: IarcOther };
/** The other Miscellaneous questions (always asked). */
miscellaneous?: { naziSymbols?: boolean; koreaHistory?: boolean; terrorism?: boolean; crimes?: boolean; other?: IarcOther };
}
export const IARC_DETAIL_SECTIONS = ['violence', 'fear', 'sexuality', 'gambling', 'language', 'controlledSubstances', 'crudeHumor', 'digitalPurchases', 'userInteraction', 'miscellaneous'] as const;
export type IarcDetailSection = typeof IARC_DETAIL_SECTIONS[number];
/** Every key a details section may have and its kind (validation and the answer sheet); `other` is allowed everywhere. */
export const IARC_DETAIL_KEYS: Record<IarcDetailSection, Record<string, 'boolean' | 'label' | 'labels'>> = {
violence: { humans: 'boolean', nonHumans: 'boolean', setting: 'label', childlike: 'boolean', reactions: 'labels', presentation: 'labels', blood: 'label', historicalWar: 'boolean', innocents: 'boolean', fierceSounds: 'boolean' },
fear: {}, sexuality: {}, gambling: {}, language: {}, controlledSubstances: {}, crudeHumor: {},
digitalPurchases: { lootBoxes: 'boolean', playerTrading: 'boolean' },
userInteraction: { block: 'boolean', report: 'boolean', moderation: 'boolean', friendsOnly: 'boolean' },
miscellaneous: { naziSymbols: 'boolean', koreaHistory: 'boolean', terrorism: 'boolean', crimes: 'boolean' },
};
/** Play Console > Policy and programs > App content declarations without a form of their own in ConsoleConfig
* (`storebound console plan play-app-content` lists every card). */
export interface AppContentConfig {
/** Government apps: developed by or on behalf of a government. */
government?: boolean;
/** Financial features: false = "My app doesn't provide any financial features"; else the console labels to tick. */
financialFeatures?: false | string[];
/** Health apps: false = no health features; else the console labels to tick. */
healthFeatures?: false | string[];
/** Advertising ID: the app (or an SDK in it) uses the Android advertising ID. Default: yes with ads.appAdsTxt or a
* dataSafety Device or other IDs entry. */
advertisingId?: boolean;
}
export interface ConsoleConfig {
/** App Store Connect > Apps > + > New App: the SKU (internal, never shown) and who in the team sees the app. */
appRecord?: { sku?: string; userAccess?: 'full' | 'limited' };
/** Play IARC questionnaire: the category and the answers you decided (true = Yes). */
contentRating?: { category?: typeof RATING_CATEGORIES[number]; answers?: Partial<Record<IarcQuestion, boolean>> };
/** Play IARC questionnaire: the category, the answers you decided (true = Yes) and their follow-ups (details). */
contentRating?: { category?: typeof RATING_CATEGORIES[number]; answers?: Partial<Record<IarcQuestion, boolean>>; details?: IarcDetails };
/** Play Target audience and content, and the Ads declaration. */
targetAudience?: { ages?: AgeGroup[]; appealsToChildren?: boolean; containsAds?: boolean };
/** Play App content declarations: government apps, financial features, health apps, advertising ID. */
appContent?: AppContentConfig;
}
/** Play Console's game categories (Store settings > App category, app type Game). */
export const PLAY_GAME_CATEGORIES = ['Action', 'Adventure', 'Arcade', 'Board', 'Card', 'Casino', 'Casual', 'Educational', 'Music', 'Puzzle', 'Racing',
'Role Playing', 'Simulation', 'Sports', 'Strategy', 'Trivia', 'Word'] as const;
/** Play Console's app categories (Store settings > App category, app type App). */
export const PLAY_APP_CATEGORIES = ['Art & Design', 'Auto & Vehicles', 'Beauty', 'Books & Reference', 'Business', 'Comics', 'Communication', 'Dating',
'Education', 'Entertainment', 'Events', 'Finance', 'Food & Drink', 'Health & Fitness', 'House & Home', 'Libraries & Demo', 'Lifestyle',
'Maps & Navigation', 'Medical', 'Music & Audio', 'News & Magazines', 'Parenting', 'Personalization', 'Photography', 'Productivity', 'Shopping',
'Social', 'Sports', 'Tools', 'Travel & Local', 'Video Players & Editors', 'Weather'] as const;
/** App Store category ids with an exact Play counterpart. */
const APPLE_TO_PLAY: Record<string, string> = {
GAMES_ACTION: 'Action', GAMES_ADVENTURE: 'Adventure', GAMES_BOARD: 'Board', GAMES_CARD: 'Card', GAMES_CASINO: 'Casino', GAMES_CASUAL: 'Casual',
GAMES_EDUCATIONAL: 'Educational', GAMES_MUSIC: 'Music', GAMES_PUZZLE: 'Puzzle', GAMES_RACING: 'Racing', GAMES_ROLE_PLAYING: 'Role Playing',
GAMES_SIMULATION: 'Simulation', GAMES_SPORTS: 'Sports', GAMES_STRATEGY: 'Strategy', GAMES_TRIVIA: 'Trivia', GAMES_WORD: 'Word',
BOOKS: 'Books & Reference', REFERENCE: 'Books & Reference', BUSINESS: 'Business', EDUCATION: 'Education', ENTERTAINMENT: 'Entertainment',
FINANCE: 'Finance', FOOD_AND_DRINK: 'Food & Drink', HEALTH_AND_FITNESS: 'Health & Fitness', LIFESTYLE: 'Lifestyle', MEDICAL: 'Medical',
MUSIC: 'Music & Audio', NAVIGATION: 'Maps & Navigation', NEWS: 'News & Magazines', PRODUCTIVITY: 'Productivity', SHOPPING: 'Shopping',
SOCIAL_NETWORKING: 'Social', SPORTS: 'Sports', TRAVEL: 'Travel & Local', UTILITIES: 'Tools', WEATHER: 'Weather', GRAPHICS_AND_DESIGN: 'Art & Design',
};
/** The Play App category and where it comes from: play.category, else an exact match of apple.info.categories (the
* first subcategory first, for games), else null. */
export function playCategoryOf(c: Pick<ShipConfig, 'play' | 'apple'>): { category: string; source: string } | null {
if (c.play?.category) return { category: c.play.category, source: 'play.category' };
const k = c.apple?.info?.categories;
for (const id of [k?.primarySubOne, k?.primary]) if (id && APPLE_TO_PLAY[id]) return { category: APPLE_TO_PLAY[id]!, source: `apple.info.categories (${id})` };
return null;
}
const DOMAIN_RE = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
@ -771,6 +910,43 @@ function validateConsole(c: ConsoleConfig): string[] {
for (const [k, v] of Object.entries(cr?.answers ?? {})) if (!(IARC_QUESTIONS as readonly string[]).includes(k) || typeof v !== 'boolean') e.push(`console.contentRating.answers.${k}: one of ${IARC_QUESTIONS.join(', ')}, true | false`);
for (const a of ta?.ages ?? []) if (!AGE_GROUPS.includes(a)) e.push(`console.targetAudience.ages: "${a}" is not one of ${AGE_GROUPS.join(', ')}`);
for (const k of ['appealsToChildren', 'containsAds'] as const) if (ta?.[k] !== undefined && typeof ta[k] !== 'boolean') e.push(`console.targetAudience.${k}: true | false`);
e.push(...validateIarcDetails(cr?.details));
const ac = c.appContent;
if (ac !== undefined) {
if (!ac || typeof ac !== 'object') e.push('console.appContent: an object');
else {
for (const k of ['government', 'advertisingId'] as const) if (ac[k] !== undefined && typeof ac[k] !== 'boolean') e.push(`console.appContent.${k}: true | false`);
for (const k of ['financialFeatures', 'healthFeatures'] as const) {
const v = ac[k];
if (v !== undefined && v !== false && !(Array.isArray(v) && v.length && v.every(x => typeof x === 'string' && x.trim())))
e.push(`console.appContent.${k}: false (none) or a list of the console's labels to tick`);
}
}
}
return e;
}
function validateIarcDetails(d: IarcDetails | undefined): string[] {
if (d === undefined) return [];
if (!d || typeof d !== 'object' || Array.isArray(d)) return ['console.contentRating.details: an object'];
const e: string[] = [];
const text = (x: unknown) => typeof x === 'string' && !!x.trim();
const isOther = (o: unknown) => !!o && typeof o === 'object' && !Array.isArray(o)
&& Object.entries(o).every(([k, v]) => k.trim() && (typeof v === 'boolean' || text(v) || (Array.isArray(v) && v.every(text))));
for (const [sec, body] of Object.entries(d)) {
const at = `console.contentRating.details.${sec}`;
const keys = (IARC_DETAIL_KEYS as Record<string, Record<string, string>>)[sec];
if (!keys) { e.push(`${at}: unknown section (${IARC_DETAIL_SECTIONS.join(', ')})`); continue; }
if (!body || typeof body !== 'object' || Array.isArray(body)) { e.push(`${at}: an object`); continue; }
for (const [k, v] of Object.entries(body as Record<string, unknown>)) {
if (k === 'other') { if (!isOther(v)) e.push(`${at}.other: { '<question as the console shows it>': true | false | '<option label>' | ['<labels>'] }`); continue; }
const kind = keys[k];
if (!kind) e.push(`${at}.${k}: unknown (${[...Object.keys(keys), 'other'].join(', ')})`);
else if (kind === 'boolean' && typeof v !== 'boolean') e.push(`${at}.${k}: true | false`);
else if (kind === 'label' && !text(v)) e.push(`${at}.${k}: the option label as the console shows it`);
else if (kind === 'labels' && !(Array.isArray(v) && v.every(text))) e.push(`${at}.${k}: a list of the console's checkbox labels ([] = none)`);
}
}
return e;
}
@ -832,6 +1008,10 @@ export function validateConfig(c: unknown): string[] {
e.push(`accounts.signUp: a list of ${ACCOUNT_SIGN_UPS.map(x => `'${x}'`).join(' | ')}`);
}
if (cfg.reviewAccess && typeof cfg.reviewAccess.signInRequired !== 'boolean') e.push('reviewAccess.signInRequired: true | false');
if (cfg.social !== undefined) {
if (!cfg.social || typeof cfg.social.interaction !== 'boolean') e.push('social.interaction: true | false');
else for (const k of ['block', 'report', 'moderation', 'friendsOnly', 'sharesLocation'] as const) if (cfg.social[k] !== undefined && typeof cfg.social[k] !== 'boolean') e.push(`social.${k}: true | false`);
}
const langs = cfg.languages;
if (!Array.isArray(langs) || !langs.length) e.push('languages: at least one language');
const ids = new Set<string>();
@ -848,6 +1028,16 @@ export function validateConfig(c: unknown): string[] {
e.push('play.reportsBucket: the Play reports bucket, e.g. "pubsite_prod_rev_<number>" (Play Console > Download reports > Copy Cloud Storage URI)');
if (cfg.play?.targetAges !== undefined && !(Array.isArray(cfg.play.targetAges) && cfg.play.targetAges.length && cfg.play.targetAges.every(a => PLAY_AGE_GROUPS.includes(a))))
e.push(`play.targetAges: a list of ${PLAY_AGE_GROUPS.join(' | ')}`);
if (cfg.play?.category !== undefined) {
const games: readonly string[] = PLAY_GAME_CATEGORIES, apps: readonly string[] = PLAY_APP_CATEGORIES;
const prof = cfg.app?.profile;
const list = prof === 'game' ? games : prof ? apps : [...games, ...apps];
if (typeof cfg.play.category !== 'string' || !list.includes(cfg.play.category))
e.push(`play.category: "${String(cfg.play.category)}" is not a Play ${prof === 'game' ? 'game' : prof ? 'app' : 'game or app'} category (${list.join(', ')})`);
}
if (cfg.play?.countries !== undefined && !(Array.isArray(cfg.play.countries) && cfg.play.countries.length && cfg.play.countries.every(c => typeof c === 'string' && /^[A-Z]{2}$/.test(c))))
e.push('play.countries: a list of ISO 3166-1 alpha-2 codes ("US", "GB"), or leave it out for all countries / regions');
if (cfg.play?.managedPublishing !== undefined && typeof cfg.play.managedPublishing !== 'boolean') e.push('play.managedPublishing: true | false (what Publishing overview > Managed publishing shows)');
if (cfg.play?.accountType !== undefined && cfg.play.accountType !== 'personal' && cfg.play.accountType !== 'organization') e.push('play.accountType: personal | organization');
if (cfg.play?.accountCreated !== undefined && !/^\d{4}-\d{2}-\d{2}$/.test(cfg.play.accountCreated)) e.push('play.accountCreated: a date, YYYY-MM-DD');
if (cfg.play?.billingPrograms !== undefined && !(Array.isArray(cfg.play.billingPrograms) && cfg.play.billingPrograms.every(b => (PLAY_BILLING_PROGRAMS as readonly string[]).includes(b))))
@ -895,6 +1085,7 @@ export function validateConfig(c: unknown): string[] {
if ([...(l.title ?? '')].length > 30) e.push(`${at}.listings.${lang}.title: over 30 characters (App Store limit)`);
if (!l.description) e.push(`${at}.listings.${lang}.description: missing`);
}
if (p.randomRewards !== undefined && typeof p.randomRewards !== 'boolean') e.push(`${at}.randomRewards: true | false`);
e.push(...validateIapOfferCodes(at, p.offerCodes, p.type));
for (const o of p.overrides ?? []) {
if (!/^[A-Z]{2}$/.test(o.region)) e.push(`${at}.overrides: region "${o.region}" must be ISO alpha-2 (TR)`);

View file

@ -16,13 +16,23 @@
// 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 { 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';
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): 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'] 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;
@ -79,8 +89,9 @@ export function formMeta(id: FormId, config: ShipConfig): FormMeta {
'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-content-rating': return { id, store: 'play', title: 'Play Console: Content rating (IARC)', url: PLAY, stopBefore: 'Save', fillable: true,
open: [pickApp, 'Policy > App content > Content ratings > Start (app-content/content-rating-overview > Start questionnaire)'],
notes: ['The IARC Terms of Use box on page 1 is an agreement: storebound never ticks it.', 'The final button on the Summary (per-authority ratings) is "Save", not "Submit" (verified 2026-10-12): it is the owner\'s.'] };
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'] };
}
@ -258,18 +269,96 @@ export const IARC_LABELS: Record<IarcQuestion, string> = {
};
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;
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.'));
const sells = (config.products?.length ?? 0) > 0 || (config.subscriptions?.length ?? 0) > 0;
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];
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 (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.'));
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}`;
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;
}

View file

@ -6,8 +6,8 @@
// The names are modeled on Apple's and Google's public help pages (sources in console-answers.ts), not recorded from a
// signed-in console: the exact App Privacy dialog titles, the IARC question wording and the Play category names could
// not be verified without an account, which is why every field has candidates and the engine falls back on a miss.
import { AGE_GROUPS, appleName, type ShipConfig } from './config';
import { AGE_LABELS, type Answer, APPLE_PURPOSES, type FormId } from './console-answers';
import { AGE_GROUPS, appleName, IARC_DETAIL_SECTIONS, type ShipConfig } from './config';
import { AGE_LABELS, type Answer, APPLE_PURPOSES, type FormId, iarcSectionName } from './console-answers';
/** An accessible name: a string matches exactly, a RegExp matches the name. */
export type Name = string | RegExp;
@ -22,6 +22,9 @@ export interface FieldDef {
options?: Record<string, Name[]>;
/** An agreement checkbox: located and highlighted, NEVER ticked (whatever the answer says). */
agreement?: boolean;
/** radio: the options count only inside the named group (no group on the page: the field is not there). The IARC
* follow-ups, whose Yes / No options would otherwise match every question of the page. */
scoped?: boolean;
}
export interface SectionDef {
@ -33,9 +36,37 @@ export interface SectionDef {
page?: boolean;
/** App Privacy: the data type this section belongs to (its dialog opens from the Data Types section). */
type?: string;
/** Shown only after an answer reveals it (the IARC follow-ups): never needed to finish the form. */
optional?: boolean;
fields: FieldDef[];
}
/** Candidate group names of the IARC yes / no follow-ups (modeled from the section structure recorded 2026-10-12). */
const DETAIL_NAMES: Record<string, Name[]> = {
childlike: [/(cartoon|childlike|child-like)/i], historicalWar: [/historical|\bwar\b/i], innocents: [/innocent|defen[cs]eless/i], fierceSounds: [/sound/i],
playerTrading: [/trad(e|ing)/i], block: [/\bblock/i], report: [/\breport/i], moderation: [/moderat/i], friendsOnly: [/friend/i],
naziSymbols: [/nazi/i], koreaHistory: [/korea/i], terrorism: [/terror/i], crimes: [/crime/i], setting: [/setting/i], blood: [/blood/i],
};
const NEVER: Name[] = [/^\u0000$/];
/** The field of one IARC follow-up answer (d:<section>:<key>[:<label>] or d:<section>:other:<question>[:<label>]). */
function detailField(a: Answer): FieldDef {
const [, , key = '', ...rest] = a.id.split(':');
const tail = rest.join(':');
if (key === 'humans') return { id: a.id, kind: 'checkbox', names: [/^(violence )?(against )?humans?\b/i] };
if (key === 'nonHumans') return { id: a.id, kind: 'checkbox', names: [/non-?humans?/i] };
if (key === 'followUps' || ((key === 'reactions' || key === 'presentation') && !tail)) return { id: a.id, kind: 'checkbox', names: NEVER };
if (key === 'reactions' || key === 'presentation') return { id: a.id, kind: 'checkbox', names: labelNames(tail) };
if (key === 'other') {
const [q = '', ...l] = rest, label = l.join(':');
if (label) return { id: a.id, kind: 'checkbox', names: labelNames(label) };
const group: Name[] = [new RegExp(`^${esc(q)}`, 'i')];
return typeof a.value === 'string' ? { id: a.id, kind: 'radio', scoped: true, names: group, options: { [a.value]: labelNames(a.value) } } : { id: a.id, kind: 'radio', scoped: true, names: group, options: yesNo() };
}
if (key === 'setting' || key === 'blood') return { id: a.id, kind: 'radio', scoped: true, names: DETAIL_NAMES[key], options: typeof a.value === 'string' ? { [a.value]: labelNames(a.value) } : { unknown: NEVER } };
return { id: a.id, kind: 'radio', scoped: true, names: DETAIL_NAMES[key] ?? NEVER, options: yesNo() };
}
export interface FormDef { id: FormId; sections: SectionDef[] }
// Console labels use typographic quotes ("Developer’s Advertising", "the user’s identity"): every pattern built from a
@ -126,8 +157,14 @@ export function formDef(id: FormId, answers: Answer[]): FormDef {
{ title: 'Email address and category', fields: [
{ id: 'email', kind: 'text', names: ['Email address', 'Email'] },
{ id: 'category', kind: 'radio', options: { 'Game': [/^Game\b/i], 'Social or communication': [/^Social/i], 'All other app types': [/^All other app types/i, /^Utility/i] } },
{ id: 'agree:iarcTerms', kind: 'checkbox', agreement: true, names: [/IARC Terms of Use/i, /terms of use/i] },
] },
{ title: 'Questionnaire', fields: answers.filter(a => a.id.startsWith('q:')).map(a => ({ id: a.id, kind: 'radio' as const, names: IARC_NAMES[a.id.slice(2)], options: yesNo() })) },
// follow-ups a Yes reveals on the same page (and Miscellaneous): optional, filled when they appear
...IARC_DETAIL_SECTIONS.flatMap(sec => {
const fields = answers.filter(a => a.id.startsWith(`d:${sec}:`)).map(a => detailField(a));
return fields.length ? [{ title: `Questionnaire: ${iarcSectionName(sec)} follow-ups`, page: true, optional: true, fields }] : [];
}),
] };
case 'play-target-audience': return { id, sections: [
{ title: 'Target age', fields: AGE_GROUPS.map(g => ({ id: `age:${g}`, kind: 'checkbox' as const, names: [AGE_LABELS[g], AGE_LABELS[g].replace(/^Ages /, '')] })) },
@ -163,7 +200,7 @@ export const forbiddenControl = (name: string): boolean => FORBIDDEN.test(name.t
* names carry help text: "Product InteractionSuch as ... saved place in a game"). What a tick must never do is accept an
* agreement: those are refused here.
*/
const AGREEMENT = /\b(?:i agree|agree to|accept(?:s|ed)?\b|terms of service|developer program polic|export laws?|declaration|consent)|kabul|onaylıyorum|beyan/iu;
const AGREEMENT = /\b(?:i agree|agree to|accept(?:s|ed)?\b|terms of service|terms of use|developer program polic|export laws?|declaration|consent)|kabul|onaylıyorum|beyan|kullanım koşulları/iu;
export const forbiddenTick = (name: string): boolean => AGREEMENT.test(name.toLocaleLowerCase('tr').normalize('NFC'));
const SIGN_IN_HOSTS = ['appleid.apple.com', 'idmsa.apple.com', 'accounts.google.com'];
@ -408,10 +445,11 @@ export const FORM_BUTTONS: Record<FormId, ButtonRule[]> = {
{ button: 'Create app (form)', class: 'final-submit', where: 'the Create app form: creates the app', auto: false },
],
'play-content-rating': [
{ button: 'Start / Start new questionnaire', class: 'navigation', where: 'App content: opens the questionnaire', auto: true },
{ button: 'Start / Start questionnaire', class: 'navigation', where: 'App content > Content ratings (content-rating-overview): opens the questionnaire', auto: true },
{ button: 'Next', class: 'navigation', where: 'after the category, and from the questions to the Summary', auto: true },
{ button: 'Save draft', class: 'draft-save', where: 'questionnaire: stores the answers as the one draft of the app; nothing goes to IARC before Submit', auto: true },
{ button: 'Submit', class: 'final-submit', where: 'Summary: sends the questionnaire to IARC', auto: false },
{ button: 'Save draft / Save (questionnaire page)', class: 'draft-save', where: 'questionnaire: stores the answers as the one draft of the app; nothing goes to IARC before the Summary\'s Save', auto: true },
{ button: 'IARC Terms of Use box', class: 'final-submit', where: 'page 1: an agreement, never ticked by the agent or storebound', auto: false },
{ button: 'Save (Summary page)', class: 'final-submit', where: 'Summary (per-authority ratings): sends the questionnaire to IARC and applies the ratings (verified 2026-10-12: "Save", not "Submit")', auto: false },
],
'play-target-audience': [
{ button: 'Start / Manage', class: 'navigation', where: 'App content: opens the section', auto: true },

View file

@ -5,9 +5,10 @@
// The scripted engine (`console fill`) uses the same answers and click rules and stays an optional fast path. Pure.
// UI facts marked "verified" were read on a real console (App Privacy, 2026-10-12, /apps/<id>/distribution/privacy);
// everything else is modeled on Apple's and Google's help pages (sources in console-answers.ts): the agent adapts.
import type { ShipConfig } from './config';
import { type Answer, type AnswerValue, consoleAnswers, type FormId, formMeta, type Store, valueText } from './console-answers';
import { FORM_BUTTONS, formDef, type Name, NEVER_PRESS, playAppDashboard, playAppList, playCreateApp, setUpTexts, typeLabels } from './console-forms';
import { playCategoryOf, type ShipConfig } from './config';
import { type Answer, type AnswerValue, consoleAnswers, type FormId, formMeta, isPlanOnly, type PlanFormId, type Store, valueText } from './console-answers';
import { APP_CONTENT_CAPTURE, APP_CONTENT_CARDS, appContentStart, PLAY_MATERIAL_FACT, playAppPage, playOnlyPart, startLabel } from './console-play-plans';
import { FORM_BUTTONS, formDef, type Name, NEVER_PRESS, playAppList, playCreateApp, setUpTexts, typeLabels } from './console-forms';
import { PLAY_CREATE_CAPTURE } from './console-verify';
import { APPLE_PURPOSES } from './console-answers';
@ -49,7 +50,7 @@ export interface PlanStep {
}
export interface ConsolePlan {
form: FormId;
form: PlanFormId;
store: Store;
title: string;
/** Where to start: a deep link when the ids are known, else the console and how to reach the form. */
@ -72,6 +73,9 @@ export interface ConsolePlan {
uiFacts: string[];
/** How to drive long flows from a browser tool. */
driving: string[];
/** A ready-made in-page helper (JavaScript as text): paste it once per page with one evaluate call, start a run, then
* poll window.__log in short calls (one evaluate call times out around 45 s on big console pages). */
inPageLoop: string;
verify: { command: string; source: 'page-text' | 'api' | 'api-or-page-text' | 'none'; capture: string; expect: string[] };
/** False: guide only (the API sets it; `storebound apple appinfo`). */
fillable: boolean;
@ -79,7 +83,11 @@ export interface ConsolePlan {
}
/** Ids known outside the config (an API lookup or the account cache): they make deep links. */
export interface PlanIds { appleAppId?: string | null; playDeveloperId?: string | null; playAppId?: string | null }
export interface PlanIds {
appleAppId?: string | null; playDeveloperId?: string | null; playAppId?: string | null;
/** reviewDemoAccount exists in .storebound/credentials.json (only whether it exists: the values are never read here). */
demoAccount?: boolean;
}
const ASC = 'https://appstoreconnect.apple.com';
const PLAY = 'https://play.google.com/console';
@ -153,13 +161,76 @@ const ids = (answers: PlanAnswer[], pred: (a: PlanAnswer) => boolean) => answers
const DRIVING = [
'Use your own browser tool (Claude in Chrome, a built-in browser, Playwright). Read the page as text or an accessibility tree first; click by visible text; adapt when the page differs from these steps (the console changes without notice), but never press anything in buttons.neverPress.',
'For a long flow, start an in-page async loop and poll it: one Runtime.evaluate / javascript call times out around 45 s. Pattern: window.__log = []; window.__run = (async () => { ... window.__log.push("step ..."); ... })(); then read window.__log every few seconds until it says done or stopped.',
'Long flows run INSIDE the page: one Runtime.evaluate / javascript call times out at about 45 s on big Play pages (the IARC questionnaire, App content), so never loop over many controls in one call. Paste plan.inPageLoop once per page (one short call; it defines window.__sb), start the job with window.__sb.run([{ role: "radio", label: "No", within: "<question text>" }, { role: "checkbox", label: "<box label>", want: true }, ...]) (it returns at once), then poll window.__log.slice(-20) and window.__done in short calls every 2-5 s until the log says done. The helper only picks radios and ticks boxes (never agreements); press Next / Save yourself afterwards, by their visible text.',
'Wait for each step to render (poll for the next heading or input, up to ~10 s) before acting; never act on a step you have not read.',
'An answer with status ask-owner: ask the owner in chat before filling it (AGENTS.md rule 5), or leave it empty and tell the owner. Never guess.',
'Stop before the final button (buttons.final) and tell the owner exactly what to press; then run the verify command.',
'Never sign in, type a password, accept an agreement or tick a declaration: the owner does that in the browser.',
];
/**
* The in-page helper of every plan (plan.inPageLoop), as text: defines window.__sb with labelOf (aria-label, else the
* nearest non-empty innerText: Play's labels sit in sibling nodes), scopeOf (the smallest block holding a question text
* and its controls), set (a JS click on the inner [role=radio] / [role=checkbox] of a material control, read back) and
* run (a logged async job over a list of steps). It never clicks buttons and refuses agreements. No template literals,
* so it reads the same in JSON and in a terminal.
*/
export const IN_PAGE_LOOP = [
'(() => {',
' const norm = (t) => (t || "").replace(/\\s+/g, " ").trim();',
' const log = (m) => { window.__log.push(new Date().toISOString().slice(11, 19) + " " + m); };',
' const sleep = (ms) => new Promise((r) => setTimeout(r, ms));',
' const labelOf = (el) => {',
' const own = norm(el.getAttribute("aria-label"));',
' if (own) return own;',
' for (let a = el; a && a !== document.body; a = a.parentElement) { const t = norm(a.innerText); if (t) return t; }',
' return "";',
' };',
' const NEVER = /terms of use|terms of service|i agree|agree to|declaration|developer program polic|export laws/i;',
' const CONTROLS = "[role=radio], [role=checkbox], input[type=radio], input[type=checkbox]";',
' const scopeOf = (text) => {',
' const want = norm(text).toLowerCase(); let best = null;',
' for (const el of document.querySelectorAll("section, fieldset, div, [role=radiogroup], [role=group]"))',
' if (norm(el.innerText).toLowerCase().includes(want) && el.querySelector(CONTROLS) && (!best || best.contains(el))) best = el;',
' return best;',
' };',
' const find = (role, label, scope) => {',
' const want = norm(label).toLowerCase();',
' const hits = [...(scope || document).querySelectorAll("[role=" + role + "], input[type=" + role + "]")]',
' .filter((el) => el.getClientRects().length && labelOf(el).toLowerCase().startsWith(want));',
' return hits.length === 1 ? hits[0] : null;',
' };',
' const set = async (s) => {',
' const scope = s.within ? scopeOf(s.within) : null;',
' if (s.within && !scope) return "no block with \\"" + s.within + "\\"";',
' const el = find(s.role, s.label, scope);',
' if (!el) return "not found or ambiguous";',
' if (NEVER.test(labelOf(el))) return "refused: an agreement (the owner ticks it)";',
' const host = el.closest("material-radio, material-checkbox");',
' const inner = el.matches("[role=radio], [role=checkbox]") ? el : (host && host.querySelector("[role=radio], [role=checkbox]")) || el;',
' const state = () => (inner.hasAttribute("aria-checked") ? inner.getAttribute("aria-checked") === "true" : !!inner.checked);',
' const want = s.want !== false;',
' if (state() !== want) inner.click();',
' await sleep(150);',
' return state() === want ? "ok" : "did not change (read the control, try the <input> itself)";',
' };',
' window.__log = []; window.__done = false;',
' window.__sb = { log, sleep, labelOf, scopeOf, find, set };',
' window.__sb.run = (steps) => {',
' window.__done = false;',
' window.__run = (async () => {',
' for (let i = 0; i < steps.length; i++) {',
' const s = steps[i], tag = (i + 1) + "/" + steps.length + " " + s.role + " \\"" + s.label + "\\"" + (s.within ? " in \\"" + s.within + "\\"" : "");',
' try { log(tag + ": " + await set(s)); } catch (e) { log(tag + ": error " + e.message); }',
' }',
' log("done"); window.__done = true;',
' })();',
' return "started " + steps.length + " steps: poll window.__log";',
' };',
' return "storebound helper ready: window.__sb.run([...]), then poll window.__log.slice(-20) and window.__done";',
'})()',
].join('\n');
function appPrivacyPlan(answers: PlanAnswer[], appId: string | null | undefined): Pick<ConsolePlan, 'steps' | 'uiFacts' | 'verify' | 'start'> {
const types = answers.filter(a => a.id.startsWith('type:'));
const collected = types.filter(a => a.value === true).map(a => a.id.slice(5));
@ -176,7 +247,7 @@ function appPrivacyPlan(answers: PlanAnswer[], appId: string | null | undefined)
add({ type: t, text: `"${t}" steps 3 and 4: two information pages ("Let’s define two important terms…", then "To help put tracking into context, here are a few examples:"), no inputs: press Next on each.`, click: ['Next', 'Next'] });
add({ type: t, text: `"${t}" step 5, tracking: pick the radio, then Save (a draft). Never "Definitions and Examples". The dialog closes; the row shows Edit and the summary.`, click: ['Save'], answers: [`tracking:${t}`] });
}
add({ text: 'Stop. Do NOT press Publish: tell the owner to review the summary on the App Privacy page and press Publish (it puts the answers on the App Store product page).' });
add({ text: 'Stop. Do NOT press Publish: tell the owner to review the summary on the App Privacy page and press Publish, then Publish again in the "Publish Your App Privacy Responses?" dialog (it puts the answers on the App Store product page). Afterwards the page shows "Published … ago by …".' });
add({ text: 'Verify: read the Data Types section as text (document.querySelector("main").innerText) and pipe it to `storebound console verify apple-app-privacy --from-text -`.' });
return {
start: appId ? { url: `${ASC}/apps/${appId}/distribution/privacy`, deepLink: true, reach: [] }
@ -192,7 +263,7 @@ function appPrivacyPlan(answers: PlanAnswer[], appId: string | null | undefined)
'Step 5, tracking: 2 radios, "Yes, we use <type> data for tracking purposes" / "No, we do not …". Buttons: "Definitions and Examples" (help: never press), Back, Cancel, Save.',
'Save closes the dialog. The type then shows "Edit" and a summary: "Used for <purposes>", "Linked to the user’s identity" (or not), "Used for tracking purposes" (or not). That summary is what `console verify` reads.',
'Labels use typographic quotes (’): match straight and curly quotes alike.',
'The "?" buttons ("More information") are help only. Publish (top right) is the owner\'s.',
'The "?" buttons ("More information") are help only. Publish (top right) is the owner\'s: it opens a <dialog> "Publish Your App Privacy Responses?" with Cancel / Publish (the owner\'s final button); after it the page shows "Published a few seconds ago by …" (verified 2026-10-12), which `console verify` reads as the published signal.',
],
verify: {
command: 'storebound console verify apple-app-privacy --from-text -',
@ -202,6 +273,7 @@ function appPrivacyPlan(answers: PlanAnswer[], appId: string | null | undefined)
`Every collected type (${collected.join(', ') || 'none'}) shows Edit and a summary, no "Set Up <type>" left.`,
'Each summary lists the purposes of the answer sheet ("Used for …"), linked and tracking as answered.',
'No other data type is listed. The App Store Connect API has no App Privacy endpoint, so the page text is the only source.',
'After the owner\'s Publish: a "Published … ago by …" line (the published signal).',
],
},
};
@ -215,7 +287,8 @@ function playStart(form: FormId, config: ShipConfig, p: PlanIds): ConsolePlan['s
return dev ? { url: playCreateApp(PLAY, dev), deepLink: true, reach: [] }
: { url: PLAY, deepLink: false, reach: ['Pick the developer account on the chooser (set play.developerId for a deep link)', 'then /console/u/0/developers/<developer id>/create-new-app, or Home (All apps) > Create app'] };
}
if (dev && app) return { url: playAppDashboard(PLAY, dev, app), deepLink: true, reach: ['Policy and programs > App content', `${entry} > Start (or Manage)`] };
// verified 2026-10-12: each App content card has its own page
if (dev && app) return { url: playAppPage(dev, app, form === 'play-content-rating' ? 'app-content/content-rating-overview' : 'app-content/target-audience-content'), deepLink: true, reach: [] };
return {
url: dev ? playAppList(PLAY, dev) : PLAY, deepLink: !!dev,
reach: [...(dev ? [] : ['Pick the developer account if asked (set play.developerId for a deep link)']),
@ -273,55 +346,131 @@ function otherPlan(form: FormId, config: ShipConfig, answers: PlanAnswer[], p: P
'After Create app the app lives at /console/u/0/developers/<developerId>/app/<appId>/app-dashboard (verified): the app id deep-links the other Play forms (play.appId; storebound also remembers it).',
`A service account's app-level permissions do NOT cover apps created later: the owner adds the new app (${PLAY_SA_PATH}). Agents never do this (a permission grant; auto mode blocks it too).`,
'The package name is fixed for good once the app is created. Free can never become Paid.',
PLAY_MATERIAL_FACT,
],
verify: { command: 'storebound console verify play-app-record --from-text -', source: 'page-text', capture: PLAY_CREATE_CAPTURE,
expect: ['App name and App package name equal the answer sheet.', 'Game / App and Free / Paid are aria-checked="true" as answered.', 'No "Select …" hint text is left on the form.', '"Package name available" after Check availability.'] },
};
case 'play-content-rating':
add({ text: 'Open the app > Policy and programs > App content > Content rating > Start (or Start new questionnaire).', click: ['Start'] });
add({ text: 'Page 1: type the email address and pick the category. Save draft, then Next.', click: ['Save draft', 'Next'], answers: ['email', 'category'] });
add({ text: 'Questionnaire: answer each question Yes or No as the sheet says; leave ask-owner questions to the owner. Save draft, then Next.', click: ['Save draft', 'Next'], answers: all(a => a.id.startsWith('q:')) });
add({ text: 'Summary: stop. Do NOT press Submit: tell the owner to review the summary and press Submit (it sends the questionnaire to IARC).' });
case 'play-content-rating': {
const section = (sec: string) => all(a => a.id.startsWith(`d:${sec}:`));
add({ text: 'Open App content > Content ratings (/app-content/content-rating-overview), then "Start questionnaire": it opens /app-content/content-rating-iarc-questionnaire.', click: ['Start questionnaire'] });
add({ text: 'Page 1: type the email address and pick the category (material radio: JS click on the inner [role=radio]). The IARC Terms of Use checkbox is an agreement: never tick it; the owner ticks it (it is in the batch). Then Next.', click: ['Next'], answers: ['email', 'category', 'agree:iarcTerms'] });
add({ text: 'The questionnaire is ONE long page of sections (Violence, Fear, Sexuality, Gambling, Language, Controlled Substance, Crude Humor, Digital Purchases, Miscellaneous). Answer each section\'s Yes / No as the sheet says; each Yes reveals follow-ups right below it, so answer top to bottom and re-read the page after every Yes. Run it as one in-page job (plan.inPageLoop) and poll window.__log.', answers: all(a => a.id.startsWith('q:')) });
add({ text: 'Violence follow-ups (after Yes): the humans / non-humans checkboxes, then setting, childlike style, reactions, presentation checkboxes, blood level, historical war setting, innocent characters, fierce sounds.', answers: section('violence') });
add({ text: 'Fear, Sexuality, Gambling, Language, Controlled Substance, Crude Humor follow-ups (after Yes): answer the questions shown as the sheet says (details.<section>.other).', answers: ['fear', 'sexuality', 'gambling', 'language', 'controlledSubstances', 'crudeHumor'].flatMap(section) });
add({ text: 'Digital Purchases follow-ups (after "purchases of digital goods" Yes): the random items (loot box) question, then trading between players.', answers: section('digitalPurchases') });
add({ text: 'Miscellaneous: user interaction (after Yes: block, report, moderation, friends only), location sharing, Nazi symbols, Korea history, terrorism, crimes.', answers: [...section('userInteraction'), ...section('miscellaneous')] });
add({ text: 'Press Save (stores the draft; nothing goes to IARC yet), then Next: the Summary shows the rating per authority (PEGI, ESRB, USK, ClassInd, Google Play ...).', click: ['Save', 'Next'] });
add({ text: 'Summary: stop. Do NOT press its Save (the final button here is "Save", not "Submit"): tell the owner the ratings shown and let them press Save (it applies the IARC ratings).' });
add({ text: 'Verify: read the Summary page as text and pipe it to `storebound console verify play-content-rating --from-text -`.' });
return {
start: playStart(form, config, p), steps,
uiFacts: ['Modeled on Google\'s "Content ratings" help page, not recorded: the IARC question wording differs per category; match questions by their topic (violence, fear, gambling …) and adapt.', '"Save draft" stores the one draft of the app: nothing goes to IARC before Submit.', 'The email address field gets the full app.contactEmail from the config (printed masked here).', PLAY_DASHBOARD_FACT],
verify: { command: 'storebound console verify play-content-rating --from-text -', source: 'page-text', capture: PAGE_TEXT, expect: ['The Summary shows each question with the Yes / No of the answer sheet.'] },
uiFacts: [
'Verified 2026-10-12 (an agent with a browser tool on a real game):',
'App content > Content ratings is /app-content/content-rating-overview; "Start questionnaire" opens /app-content/content-rating-iarc-questionnaire.',
'Page 1: email address, the category radio, and an IARC Terms of Use checkbox: an agreement, the owner ticks it, never the agent.',
'The questionnaire is one long page of sections; each Yes reveals follow-ups (Violence: humans / non-humans checkboxes, then setting, childlike style, reactions, presentation checkboxes, blood level, historical war setting, innocent characters, fierce sounds; Digital Purchases: digital goods, then loot boxes, then player trading; Miscellaneous: user interaction then block / report / moderation / friends-only, location sharing, Nazi symbols, Korea history, terrorism, crimes). Follow-up wording here is storebound\'s: match by topic and the console\'s own labels.',
'Save on the questionnaire stores it; Next goes to the Summary with per-authority ratings. The Summary\'s final button is "Save" (not "Submit"): the owner\'s.',
'Example result (the game of that run): PEGI 7, ESRB E10+, USK 12, ClassInd 18 (paid random items alone made it 18), Google Play 7+.',
'The email address field gets the full app.contactEmail from the config (printed masked here).',
PLAY_MATERIAL_FACT, PLAY_DASHBOARD_FACT,
],
verify: { command: 'storebound console verify play-content-rating --from-text -', source: 'page-text', capture: PAGE_TEXT, expect: ['The Summary shows each question with the Yes / No of the answer sheet, and a rating per authority.'] },
};
case 'play-target-audience':
add({ text: 'Open the app > Policy and programs > App content > Target audience and content > Start (or Manage).', click: ['Start'] });
add({ text: 'Target age: tick exactly the age groups of the sheet. Next.', click: ['Next'], answers: all(a => a.id.startsWith('age:')) });
if (answers.some(a => a.id === 'appeal')) add({ text: 'Appeal to children: pick the answer. Next.', click: ['Next'], answers: ['appeal'] });
add({ text: 'Ads (also App content > Ads): pick whether the app contains ads.', answers: ['ads'] });
add({ text: 'Stop. Do NOT press the last Save: tell the owner to review and press Save (Google reviews the declaration).' });
}
case 'play-target-audience': {
const teens = answers.filter(a => a.id.startsWith('age:')).every(a => a.status === 'certain' && (a.value !== true || ['age:13-15', 'age:16-17', 'age:18-over'].includes(a.id)));
add({ text: 'Open App content > Target audience and content (/app-content/target-audience-content) > Start (or Manage).', click: ['Start'] });
add({ text: 'Target age: tick exactly the age groups of the sheet (material checkboxes: JS click on the inner [role=checkbox]; read aria-checked back). Next.', click: ['Next'], answers: all(a => a.id.startsWith('age:')) });
if (answers.some(a => a.id === 'appeal')) add({ text: `Appeal to children: pick the answer, then Next.${teens ? ' With 13+ age groups only the page skipped this question and went straight to the Summary (verified 2026-10-12): answer it only if it shows.' : ''}`, click: ['Next'], answers: ['appeal'] });
add({ text: 'Ads (also App content > Ads, /app-content/ads-declaration): pick whether the app contains ads, if this section shows it.', answers: ['ads'] });
add({ text: 'Summary: stop. Do NOT press Save: tell the owner to review and press Save (Google reviews the declaration).' });
add({ text: 'Verify: read the section\'s summary as text and pipe it to `storebound console verify play-target-audience --from-text -`.' });
return {
start: playStart(form, config, p), steps,
uiFacts: ['Modeled on Google\'s "Target audience and content" help page, not recorded: adapt to the page.', 'Any age group under 13 brings the Families policy.', 'Save on the last page is final (Google reviews it).', PLAY_DASHBOARD_FACT],
uiFacts: ['Verified 2026-10-12: /app-content/target-audience-content; age groups are checkboxes; with 13+ age groups only it went straight to the Summary (no appeal-to-children question); Save there is the final button.', 'Any age group under 13 brings the Families policy.', PLAY_MATERIAL_FACT, PLAY_DASHBOARD_FACT],
verify: { command: 'storebound console verify play-target-audience --from-text -', source: 'page-text', capture: PAGE_TEXT, expect: ['The target age groups and the ads answer equal the answer sheet.'] },
};
}
case 'apple-app-privacy': throw new Error('unreachable');
}
}
export function consolePlan(form: FormId, config: ShipConfig, p: PlanIds = {}): ConsolePlan {
/** The owner's batch: every answer a plan cannot fill from the config, the owner checks and the agreements. */
function askOwnerOf(answers: PlanAnswer[], prefix = ''): string[] {
return answers.filter(a => a.status === 'ask-owner' || a.status === 'owner-check' || a.status === 'agreement')
.map(a => `${prefix}${a.question}: ${a.status === 'agreement' ? 'an agreement, the owner ticks it' : a.status === 'owner-check' ? `${a.text} (owner check: ${a.note ?? ''})` : `ask the owner${a.note ? ` (${a.note})` : ''}`}`);
}
const ruleText = (draftSaves: string[], final: string) => `Press only navigation (Next, Back, Get Started, Edit, Set Up, Start / Manage links) and draft saves (${draftSaves.join(', ') || 'none in this form'}). Never press ${final} or anything that publishes, submits, creates, sends or accepts; stop and tell the owner.`;
/** The App content overview: every card in the page's order, its page, its plan, and one batch of owner questions. */
function appContentPlan(cfg: ShipConfig, p: PlanIds): ConsolePlan {
const cards = APP_CONTENT_CARDS.map(card => ({ card, plan: card.form ? consolePlan(card.form, cfg, p) : null }));
const steps: PlanStep[] = [];
const add = (s: Omit<PlanStep, 'n'>) => steps.push({ n: steps.length + 1, ...s });
const answers: PlanAnswer[] = cards.flatMap(({ card, plan }) => (plan ? plan.answers.map(a => ({ ...a, id: `${plan.form}/${a.id}`, where: `${card.name}${a.where ? `: ${a.where}` : ''}` })) : []));
add({ text: 'Before the run: ask the owner every question of askOwner in ONE batch and write the answers into storebound.config.ts (rerun this plan: they turn certain).' });
add({ text: 'Open App content (/app-content/overview). Each card has a button aria-label="Start <Name> declaration"; a card already done shows its answers and a Manage / Edit link instead.' });
for (const { card, plan } of cards) {
if (!plan) { add({ text: `${card.name}: ${card.via}. Dry run first, then the real run; then read the card back.` }); continue; }
add({ text: `${card.name}: open /app-content/${card.slug} (the button "${startLabel(card.name)}") and follow \`storebound console plan ${plan.form}\`: ${plan.steps.length} steps, final button ${plan.buttons.final}.`, click: [startLabel(card.name)], answers: answers.filter(a => a.id.startsWith(`${plan.form}/`)).map(a => a.id) });
}
add({ text: 'Stop before each card\'s final Save (the content rating Summary\'s Save too): list the filled cards for the owner, who presses them (or says yes in chat for the listed cards; then the agent presses them).' });
add({ text: 'Verify: run the capture snippet (verify.capture) on the overview and pipe its JSON to `storebound console verify play-app-content --from-text -`.' });
const neverPress = [...new Set(cards.flatMap(c => c.plan?.buttons.neverPress ?? []))];
return {
form: 'play-app-content', store: 'play', title: 'Play Console: App content (every declaration)',
start: appContentStart(cfg, p), steps, answers,
askOwner: cards.flatMap(({ card, plan }) => (plan ? plan.askOwner.map(l => `${card.name}: ${l}`) : [])),
buttons: {
neverPress, final: 'Save (each card; the content rating Summary\'s Save)',
allowed: [{ button: 'Start <Name> declaration', class: 'navigation', where: 'App content overview: opens the card' },
...cards.flatMap(c => c.plan?.buttons.allowed ?? []).filter((b, i, l) => l.findIndex(x => x.button === b.button) === i)],
rule: ruleText(['Save draft / Save on the IARC questionnaire page', 'Save in Store settings'], 'a card\'s final Save'),
},
uiFacts: [
'Verified 2026-10-12 on /console/u/0/developers/<developerId>/app/<appId>/app-content/overview: each card has a button aria-label="Start <Name> declaration". The cards, in order:',
...APP_CONTENT_CARDS.map((c, i) => `${i + 1}. ${c.name}: /app-content/${c.slug}${c.verified ? '' : ' (slug modeled, not recorded)'}; ${c.via}.`),
'The App category is not an App content card: Store settings (`storebound console plan play-store-settings`).',
PLAY_MATERIAL_FACT,
],
driving: DRIVING, inPageLoop: IN_PAGE_LOOP,
verify: { command: 'storebound console verify play-app-content --from-text -', source: 'page-text', capture: APP_CONTENT_CAPTURE, expect: ['No card still shows its "Start <Name> declaration" button.'] },
fillable: true,
guidance: ['Open: Policy and programs > App content', ...APP_CONTENT_CARDS.map(c => `${c.name}: ${c.via}`)],
};
}
export function consolePlan(form: PlanFormId, config: ShipConfig, p: PlanIds = {}): ConsolePlan {
const cfg = p.appleAppId && config.apple && !config.apple.appId ? { ...config, apple: { ...config.apple, appId: p.appleAppId } } : config;
if (form === 'play-app-content') return appContentPlan(cfg, p);
if (isPlanOnly(form)) {
const part = playOnlyPart(form, cfg, p);
return {
form, store: 'play', title: part.title, start: part.start, steps: part.steps, answers: part.answers, askOwner: askOwnerOf(part.answers),
buttons: { neverPress: part.neverPress, final: part.final, allowed: part.allowed, rule: ruleText(part.allowed.filter(b => b.class === 'draft-save').map(b => b.button), part.final) },
uiFacts: part.uiFacts, driving: DRIVING, inPageLoop: IN_PAGE_LOOP, verify: part.verify, fillable: true, guidance: part.guidance,
};
}
const meta = formMeta(form, cfg);
const raw = consoleAnswers(form, cfg);
const answers = sheet(form, raw);
const part = form === 'apple-app-privacy' ? appPrivacyPlan(answers, cfg.apple?.appId) : otherPlan(form, cfg, answers, p);
const rules = FORM_BUTTONS[form];
const neverPress = [...new Set([...rules.filter(r => !r.auto && r.class === 'final-submit').flatMap(r => r.button.replace(/ \((?:form|and its confirmation dialog)\)$/, '').split(' / ')), ...NEVER_PRESS])];
const askOwner = answers.filter(a => a.status === 'ask-owner' || a.status === 'owner-check' || a.status === 'agreement')
.map(a => `${a.question}: ${a.status === 'agreement' ? 'an agreement, the owner ticks it' : a.status === 'owner-check' ? `${a.text} (owner check: ${a.note ?? ''})` : `ask the owner${a.note ? ` (${a.note})` : ''}`}`);
// the content rating's questionnaire Save is a draft save; only the Summary's Save is final (verified 2026-10-12)
const final = form === 'play-content-rating' ? 'Save (Summary page)' : meta.stopBefore;
const teens = form === 'play-target-audience' && raw.filter(a => a.id.startsWith('age:')).every(a => a.certain && (a.value !== true || ['age:13-15', 'age:16-17', 'age:18-over'].includes(a.id)));
const askOwner = askOwnerOf(answers).map(l => (teens && l.startsWith('Could your app unintentionally appeal') ? `${l} (only if the page asks it: with 13+ age groups only it did not, 2026-10-12)` : l));
return {
form, store: meta.store, title: meta.title, ...part, answers, askOwner,
buttons: {
neverPress, final: meta.stopBefore,
neverPress, final,
allowed: rules.filter(r => r.auto).map(r => ({ button: r.button, class: r.class, where: r.where })),
rule: `Press only navigation (Next, Back, Get Started, Edit, Set Up, Start / Manage links) and draft saves (${rules.filter(r => r.class === 'draft-save').map(r => r.button).join(', ') || 'none in this form'}). Never press ${meta.stopBefore} or anything that publishes, submits, creates, sends or accepts; stop and tell the owner.`,
rule: ruleText(rules.filter(r => r.class === 'draft-save').map(r => r.button), final),
},
driving: DRIVING,
driving: DRIVING, inPageLoop: IN_PAGE_LOOP,
fillable: meta.fillable,
guidance: [...meta.open.map(o => `Open: ${o}`), ...(meta.notes ?? []).map(n => `Note: ${n}`)],
};
@ -337,7 +486,26 @@ export function planText(pl: ConsolePlan): string {
L.push(` ${tag} ${a.question}: ${a.text}${a.label ? ` -> ${a.action ?? ''} "${a.label}"${a.curlyQuotes ? ' (curly quotes)' : ''}` : ''}${a.where ? ` @ ${a.where}` : ''}`);
if (a.status !== 'certain' && a.note) L.push(` ${a.note}`);
}
L.push('', `Never press: ${pl.buttons.neverPress.map(b => `"${b}"`).join(', ')}`, `Final button (the owner's): ${pl.buttons.final}`, `Rule: ${pl.buttons.rule}`, '', 'UI facts:', ...pl.uiFacts.map(f => ` - ${f}`), '', 'Driving:', ...pl.driving.map(d => ` - ${d}`), '', `Verify: ${pl.verify.command}`, ...pl.verify.expect.map(e => ` - ${e}`));
L.push('', `Never press: ${pl.buttons.neverPress.map(b => `"${b}"`).join(', ')}`, `Final button (the owner's): ${pl.buttons.final}`, `Rule: ${pl.buttons.rule}`, '', 'UI facts:', ...pl.uiFacts.map(f => ` - ${f}`), '', 'Driving:', ...pl.driving.map(d => ` - ${d}`),
'', 'In-page helper (plan.inPageLoop: paste it once per page in one short call, then window.__sb.run([...]) and poll window.__log):', ...pl.inPageLoop.split('\n').map(l => ` ${l}`),
'', `Verify: ${pl.verify.command}`, ...pl.verify.expect.map(e => ` - ${e}`));
if (pl.askOwner.length) L.push('', 'Ask the owner first:', ...pl.askOwner.map(a => ` - ${a}`));
return L.join('\n');
}
/**
* What `storebound check` says about the Play console steps that have no API (pure): the App category (a warning when
* the config has none: Play shows "Not selected" until an agent sets it in Store settings; a hint when it has one,
* since it is still a console step) and how many App content answers the owner has to give in the one batch.
*/
export function playConsoleReminders(config: ShipConfig): { warnings: string[]; hints: string[] } {
const warnings: string[] = [], hints: string[] = [];
if (!config.play) return { warnings, hints };
const cat = playCategoryOf(config);
if (!cat) warnings.push('play.category is not set: Play\'s App category stays "Not selected" (the Play API cannot set it, so `play listing` does not). Set play.category (a Play category name, e.g. "Strategy"), then set it in Store settings with `storebound console plan play-store-settings`');
else hints.push(`Play App category "${cat.category}" (${cat.source}) is a console step, not an API one: \`storebound console plan play-store-settings\``);
const ask = consolePlan('play-app-content', config).askOwner;
if (ask.length) warnings.push(`${ask.length} Play App content answer(s) are not in the config: ask the owner in one batch at the start (\`storebound console plan play-app-content\` lists them), then write them into the config`);
if (config.products?.some(p => p.randomRewards)) hints.push('products with randomRewards (loot boxes): ClassInd (Brazil) rated such a game 18 on a real IARC run; other authorities barely moved');
return { warnings, hints };
}

View file

@ -0,0 +1,418 @@
// Console plans for the Play Console pages that have no API and no scripted fill (`storebound console plan <form>`):
// Store settings (the App category: the Play Developer API's edits.details has no category field, so `play listing`
// leaves it "Not selected") and the App content declarations next to Content rating and Target audience: Privacy
// policy, Ads, Sign in details (App access), Advertising ID, Government apps, Financial features, Health apps, plus the
// App content overview that lists every card in order. Pure: answers come from the config only (AGENTS.md rule 5:
// anything it does not prove is ask-owner, collected in the one batch at the start).
// Verified on a real Play Console by an agent with a browser tool (2026-10-12):
// /developers/<devId>/app/<appId>/store-settings: the first Edit opens App category; Category is a dropdown whose
// entries are [role=option] with the exact category name; Save stores it.
// /developers/<devId>/app/<appId>/app-content/overview: each card has a button aria-label="Start <Name> declaration".
// Card pages: app-content/privacy-policy, ads-declaration, testing-credentials (Sign in details), ad-id-declaration,
// government-apps, finance (2 steps; the last has "My app doesn't provide any financial features"), health (2 steps;
// the last step's "Other" box was the no-health-features answer on that run), target-audience-content,
// content-rating-overview > "Start questionnaire" > content-rating-iarc-questionnaire.
// The Data safety card's slug was not recorded (modeled); storebound answers Data safety through the API anyway.
import type { ShipConfig } from './config';
import { PLAY_APP_CATEGORIES, PLAY_GAME_CATEGORIES, playCategoryOf, profileOf } from './config';
import type { PlanFormId, PlanOnlyFormId } from './console-answers';
import { NEVER_PRESS, playAppList } from './console-forms';
import type { ConsolePlan, PlanAnswer, PlanIds, PlanStatus, PlanStep } from './console-plan';
const PLAY = 'https://play.google.com/console';
/** Play Console's material radios and checkboxes (verified 2026-10-12 on Create app, App content and the IARC
* questionnaire): what every Play plan tells the agent. */
export const PLAY_MATERIAL_FACT = 'Play Console radios and checkboxes are <material-radio> / <material-checkbox>: computer-tool clicks and setting the native input (form_input) can leave the Angular model unchanged. Click the inner [role=radio] / [role=checkbox] (or the <input> itself) with a JS el.click(), then read aria-checked (or input.checked). Their labels sit in sibling nodes, not in an aria-label: walk up from the control to the nearest parent whose innerText is not empty and match by "starts with" (verified 2026-10-12).';
/** One App content card on /app-content/overview, in the page's order. */
export interface AppContentCard {
/** The card's name; its button is aria-label="Start <name> declaration". */
name: string;
/** The page under /app-content/. */
slug: string;
/** The plan that fills it, or null when an API command covers it. */
form: PlanFormId | null;
/** How storebound covers it. */
via: string;
/** The slug was read on a real console (2026-10-12). */
verified: boolean;
}
export const APP_CONTENT_CARDS: AppContentCard[] = [
{ name: 'Privacy policy', slug: 'privacy-policy', form: 'play-privacy-policy', via: 'console plan play-privacy-policy', verified: true },
{ name: 'Ads', slug: 'ads-declaration', form: 'play-ads', via: 'console plan play-ads', verified: true },
{ name: 'Sign in details', slug: 'testing-credentials', form: 'play-sign-in-details', via: 'console plan play-sign-in-details', verified: true },
{ name: 'Content ratings', slug: 'content-rating-overview', form: 'play-content-rating', via: 'console plan play-content-rating (Start questionnaire > content-rating-iarc-questionnaire)', verified: true },
{ name: 'Target audience and content', slug: 'target-audience-content', form: 'play-target-audience', via: 'console plan play-target-audience', verified: true },
{ name: 'Data safety', slug: 'data-privacy-security', form: null, via: 'storebound play data-safety (API; no console form to fill)', verified: false },
{ name: 'Advertising ID', slug: 'ad-id-declaration', form: 'play-advertising-id', via: 'console plan play-advertising-id', verified: true },
{ name: 'Government apps', slug: 'government-apps', form: 'play-government-apps', via: 'console plan play-government-apps', verified: true },
{ name: 'Financial features', slug: 'finance', form: 'play-financial-features', via: 'console plan play-financial-features', verified: true },
{ name: 'Health apps', slug: 'health', form: 'play-health-apps', via: 'console plan play-health-apps', verified: true },
];
/** The aria-label of a card's start button on the overview. */
export const startLabel = (card: string): string => `Start ${card} declaration`;
const devOf = (c: ShipConfig, p: PlanIds) => p.playDeveloperId ?? c.play?.developerId ?? null;
const appOf = (c: ShipConfig, p: PlanIds) => p.playAppId ?? c.play?.appId ?? null;
/** /console/u/0/developers/<dev>/app/<app>/<path>. */
export const playAppPage = (dev: string, app: string, path: string): string => `${PLAY}/u/0/developers/${encodeURIComponent(dev)}/app/${encodeURIComponent(app)}/${path}`;
function start(config: ShipConfig, p: PlanIds, path: string, reach: string[]): ConsolePlan['start'] {
const dev = devOf(config, p), app = appOf(config, p);
if (dev && app) return { url: playAppPage(dev, app, path), deepLink: true, reach: [] };
return {
url: dev ? playAppList(PLAY, dev) : PLAY, deepLink: false,
reach: [...(dev ? [] : ['Pick the developer account if asked (set play.developerId for a deep link)']),
`the app (${config.play?.packageName ?? config.app.name}) in the app list (set play.appId, the digits after /app/, for a deep link to /${path})`, ...reach],
};
}
const appContentReach = (card: string) => ['Policy and programs > App content (/app-content/overview)', `the "${card}" card: the button aria-label="${startLabel(card)}" (or its Manage / Edit link)`];
interface Part {
title: string;
start: ConsolePlan['start'];
steps: PlanStep[];
answers: PlanAnswer[];
final: string;
neverPress: string[];
allowed: ConsolePlan['buttons']['allowed'];
uiFacts: string[];
verify: ConsolePlan['verify'];
guidance: string[];
}
const PAGE_TEXT = 'document.querySelector("main")?.innerText ?? document.body.innerText';
const pageVerify = (form: PlanFormId, expect: string[]): ConsolePlan['verify'] => ({ command: `storebound console verify ${form} --from-text -`, source: 'page-text', capture: PAGE_TEXT, expect });
const ans = (id: string, question: string, value: string | boolean | null, status: PlanStatus, source: string, extra: Partial<PlanAnswer> = {}): PlanAnswer => ({
id, question, value: status === 'ask-owner' || status === 'agreement' ? null : value,
text: status === 'agreement' ? 'the owner ticks it (an agreement)' : value === null || status === 'ask-owner' ? '(you decide)' : value === true ? 'Yes' : value === false ? 'No' : value,
status, source, ...extra,
});
const yesNo = (v: boolean, yes: string, no: string) => (v ? yes : no);
function steps(list: (Omit<PlanStep, 'n'> | false | undefined)[]): PlanStep[] {
return list.filter((s): s is Omit<PlanStep, 'n'> => !!s).map((s, i) => ({ n: i + 1, ...s }));
}
/** A declaration's Save: stores the answer, which Google reviews: the owner's (or the agent's after a clear yes in chat). */
const DECLARATION_SAVE = 'Stop before Save: show the owner the filled card and let them press Save (or press it after the owner\'s clear yes in chat; one yes can cover the cards listed by `console plan play-app-content`). Google reviews the declaration with the next submission.';
const declarationButtons = (where: string, extra: ConsolePlan['buttons']['allowed'] = []) => ({
final: 'Save',
neverPress: ['Save', 'Send changes for review', ...NEVER_PRESS],
allowed: [{ button: 'Start <card> declaration / Manage', class: 'navigation', where: 'App content overview: opens the card' }, ...extra, { button: 'Back', class: 'navigation', where }],
});
// ---- Store settings: the App category (no API) ----
function storeSettings(config: ShipConfig, p: PlanIds): Part {
const prof = config.app.profile, game = profileOf(config) === 'game';
const type = prof ? ans('appType', 'App or game', game ? 'Game' : 'App', 'certain', 'app.profile', { control: 'radio', action: 'pick', label: game ? 'Game' : 'App', where: 'App category (after the first Edit)' })
: ans('appType', 'App or game', null, 'ask-owner', 'app.profile', { note: 'Set app.profile (game | app | subscription-app).', control: 'radio', where: 'App category' });
const cat = playCategoryOf(config);
const list = (game ? PLAY_GAME_CATEGORIES : PLAY_APP_CATEGORIES).join(', ');
const category = cat
? ans('category', 'App category', cat.category, cat.source === 'play.category' ? 'certain' : 'owner-check', cat.source, {
control: 'select', action: 'choose', label: cat.category, where: 'App category > Category dropdown: the [role=option] with exactly this text',
...(cat.source === 'play.category' ? {} : { note: `Mapped from the App Store category; set play.category to make it explicit (one of ${list}).` }) })
: ans('category', 'App category', null, 'ask-owner', 'play.category', { control: 'select', action: 'choose', where: 'App category > Category dropdown', note: `Set play.category to one of: ${list}.` });
return {
title: 'Play Console: Store settings (App category)',
start: start(config, p, 'store-settings', ['Grow users > Store presence > Store settings']),
steps: steps([
{ text: 'Open Store settings of the app (/app/<appId>/store-settings; deep link when play.appId is known).' },
{ text: 'Press the FIRST "Edit" on the page (the App category section at the top; the other Edits are contact details and tags).', click: ['Edit'] },
{ text: 'App or game: pick the material radio (JS click on the inner [role=radio]); the category list depends on it.', answers: ['appType'] },
{ text: 'Category: open the dropdown, then click the [role=option] whose text is exactly the category of the sheet (e.g. "Strategy"). Read the dropdown back: it must show that name, not "Select a category".', answers: ['category'] },
{ text: 'Press Save (stores the setting; on an app that is not published nothing is public). Read the section back: App category shows the type and the category, no "Not selected".', click: ['Save'] },
{ text: 'Verify: read the page text and pipe it to `storebound console verify play-store-settings --from-text -`.' },
]),
answers: [type, category],
final: 'Send changes for review',
neverPress: ['Send changes for review', ...NEVER_PRESS],
allowed: [
{ button: 'Edit (the first one: App category)', class: 'navigation', where: 'Store settings: opens the App category section' },
{ button: 'Save', class: 'draft-save', where: 'Store settings: stores the category. On a published app the change goes out with Google\'s review (with managed publishing: Publishing overview > Send changes for review, the owner\'s)' },
],
uiFacts: [
'Verified 2026-10-12: `play listing` does not set the App category (the Play API\'s edits.details has no category field), so a new app shows "Not selected" until it is set here.',
'URL: /console/u/0/developers/<developerId>/app/<appId>/store-settings. The first Edit on the page opens App category; Category is a dropdown whose entries are [role=option] elements with the exact category name.',
'Contact details (email, website, phone) on the same page come from the API (`storebound play listing`): leave them.',
PLAY_MATERIAL_FACT,
],
verify: pageVerify('play-store-settings', ['The App category section shows the app type and the category of the sheet; no "Not selected".']),
guidance: ['Open: Grow users > Store presence > Store settings > App category > Edit'],
};
}
// ---- App content declarations ----
function privacyPolicy(config: ShipConfig, p: PlanIds): Part {
const url = config.app.privacyPolicyUrl;
return {
title: 'Play Console: Privacy policy (App content)',
start: start(config, p, 'app-content/privacy-policy', appContentReach('Privacy policy')),
steps: steps([
{ text: 'Open App content > Privacy policy (/app-content/privacy-policy).' },
{ text: 'Type the privacy policy URL into the Privacy policy URL field (replace what is there).', answers: ['url'] },
{ text: DECLARATION_SAVE },
{ text: 'Verify: read the page text and pipe it to `storebound console verify play-privacy-policy --from-text -`.' },
]),
answers: [url ? ans('url', 'Privacy policy URL', url, 'certain', 'app.privacyPolicyUrl', { control: 'text', action: 'type', label: 'Privacy policy URL', where: 'Privacy policy' })
: ans('url', 'Privacy policy URL', null, 'ask-owner', 'app.privacyPolicyUrl', { note: 'Set app.privacyPolicyUrl (privacy-policy skill).' })],
...declarationButtons('Privacy policy'),
uiFacts: ['Verified 2026-10-12: the card\'s page is /app-content/privacy-policy (one URL field).', 'The URL must be public (no PDF, no sign-in): `storebound validate` checks it.', PLAY_MATERIAL_FACT],
verify: pageVerify('play-privacy-policy', ['The field shows app.privacyPolicyUrl.']),
guidance: ['Open: Policy and programs > App content > Privacy policy'],
};
}
/** The ads answer (the same one the target audience plan uses). */
function adsValue(config: ShipConfig): { v: boolean; src: string } | null {
const ta = config.console?.targetAudience;
if (ta?.containsAds !== undefined) return { v: ta.containsAds, src: 'console.targetAudience.containsAds' };
if (config.ads?.appAdsTxt?.length) return { v: true, src: 'ads.appAdsTxt' };
return null;
}
function adsDeclaration(config: ShipConfig, p: PlanIds): Part {
const a = adsValue(config);
const yes = 'Yes, my app contains ads', no = 'No, my app does not contain ads';
return {
title: 'Play Console: Ads (App content)',
start: start(config, p, 'app-content/ads-declaration', appContentReach('Ads')),
steps: steps([
{ text: 'Open App content > Ads (/app-content/ads-declaration).' },
{ text: 'Pick the radio (JS click on the inner [role=radio]); read aria-checked back.', answers: ['ads'] },
{ text: DECLARATION_SAVE },
{ text: 'Verify: read the page text and pipe it to `storebound console verify play-ads --from-text -`.' },
]),
answers: [a ? ans('ads', 'Does your app contain ads?', a.v, 'certain', a.src, { control: 'radio', action: 'pick', label: yesNo(a.v, yes, no), where: 'Ads' })
: ans('ads', 'Does your app contain ads?', null, 'ask-owner', 'console.targetAudience.containsAds', { control: 'radio', where: 'Ads', note: 'Any ad SDK (banner, rewarded, house ads of other apps) is Yes; set console.targetAudience.containsAds.' })],
...declarationButtons('Ads'),
uiFacts: ['Verified 2026-10-12: /app-content/ads-declaration.', 'Rewarded ads count as ads.', PLAY_MATERIAL_FACT],
verify: pageVerify('play-ads', ['The selected radio matches the sheet.']),
guidance: ['Open: Policy and programs > App content > Ads'],
};
}
function signInDetails(config: ShipConfig, p: PlanIds): Part {
const ra = config.reviewAccess;
const all = 'All functionality in my app is available without any access restrictions', some = 'All or some functionality in my app is restricted';
const out: PlanAnswer[] = [ra ? ans('access', 'Is any functionality restricted (sign-in, membership, location)?', ra.signInRequired, 'certain', 'reviewAccess.signInRequired', { control: 'radio', action: 'pick', label: ra.signInRequired ? some : all, where: 'Sign in details (App access)' })
: ans('access', 'Is any functionality restricted (sign-in, membership, location)?', null, 'ask-owner', 'reviewAccess.signInRequired', { control: 'radio', where: 'Sign in details', note: 'Set reviewAccess { signInRequired } (review-access skill).' })];
if (ra?.signInRequired) {
out.push(ra.instructions ? ans('instructions', 'Instructions for the reviewer', ra.instructions, 'certain', 'reviewAccess.instructions', { control: 'text', action: 'type', label: 'Any other information required to access your app', where: 'Add instructions' })
: ans('instructions', 'Instructions for the reviewer', null, 'ask-owner', 'reviewAccess.instructions', { note: 'Set reviewAccess.instructions (how to sign in and reach the main features).' }));
out.push(ans('credentials', 'Username and password of the demo account', null, 'ask-owner', '.storebound/credentials.json reviewDemoAccount', {
control: 'text', where: 'Add instructions: Username, Password',
note: `The owner types them from .storebound/credentials.json (reviewDemoAccount: ${p.demoAccount === undefined ? 'not checked' : p.demoAccount ? 'present' : 'MISSING, add it first'}). storebound never prints them; an agent never types a password.` }));
}
return {
title: 'Play Console: Sign in details (App access)',
start: start(config, p, 'app-content/testing-credentials', appContentReach('Sign in details')),
steps: steps([
{ text: 'Open App content > Sign in details (/app-content/testing-credentials).' },
{ text: 'Pick whether any functionality is restricted (material radio: JS click on the inner [role=radio]).', answers: ['access'] },
ra?.signInRequired && { text: 'Restricted: press "Add instructions", type the instructions; leave Username and Password to the owner (the demo account in .storebound/credentials.json).', click: ['Add instructions'], answers: ['instructions', 'credentials'] },
{ text: DECLARATION_SAVE },
{ text: 'Verify: read the page text and pipe it to `storebound console verify play-sign-in-details --from-text -`.' },
]),
answers: out,
...declarationButtons('Sign in details', [{ button: 'Add instructions', class: 'navigation', where: 'Sign in details: opens the instructions dialog' }]),
uiFacts: ['Verified 2026-10-12: the Sign in details card (App access) is /app-content/testing-credentials.', 'Credentials are never in the config or in chat: the owner types them from .storebound/credentials.json.', PLAY_MATERIAL_FACT],
verify: pageVerify('play-sign-in-details', ['The selected radio matches reviewAccess.signInRequired; with a sign-in, one set of instructions is listed.']),
guidance: ['Open: Policy and programs > App content > Sign in details (App access)'],
};
}
/** Play's advertising ID purposes (the Data safety purpose names). */
const AD_ID_PURPOSES: Record<string, string> = {
functionality: 'App functionality', analytics: 'Analytics', communications: 'Developer communications', advertising: 'Advertising or marketing',
fraud: 'Fraud prevention, security, and compliance', personalization: 'Personalization', account: 'Account management',
};
function advertisingId(config: ShipConfig, p: PlanIds): Part {
const ac = config.console?.appContent, dev = config.dataSafety?.data?.PSL_DEVICE_ID;
const uses: { v: boolean; src: string } | null = ac?.advertisingId !== undefined ? { v: ac.advertisingId, src: 'console.appContent.advertisingId' }
: config.ads?.appAdsTxt?.length ? { v: true, src: 'ads.appAdsTxt (ad SDKs read the advertising ID)' }
: dev ? { v: true, src: 'dataSafety.data.PSL_DEVICE_ID' } : null;
const out: PlanAnswer[] = [uses ? ans('uses', 'Does your app use advertising ID?', uses.v, 'certain', uses.src, { control: 'radio', action: 'pick', label: uses.v ? 'Yes' : 'No', where: 'Advertising ID' })
: ans('uses', 'Does your app use advertising ID?', null, 'ask-owner', 'console.appContent.advertisingId', { control: 'radio', where: 'Advertising ID', note: 'Yes when the app or any SDK (ads, analytics, attribution) reads it; set console.appContent.advertisingId.' })];
if (uses?.v) {
if (dev) {
const purposes = new Set([...dev.collect, ...(dev.shared ? dev.share ?? [] : [])]);
for (const [k, label] of Object.entries(AD_ID_PURPOSES)) out.push(ans(`purpose:${k}`, `Advertising ID purpose: ${label}`, purposes.has(k as never), 'certain', 'dataSafety.data.PSL_DEVICE_ID', { control: 'checkbox', action: purposes.has(k as never) ? 'tick' : 'untick', label, where: 'Advertising ID: why it is used' }));
} else out.push(ans('purposes', 'Advertising ID purposes', null, 'ask-owner', 'dataSafety.data.PSL_DEVICE_ID', { control: 'checkbox', where: 'Advertising ID', note: 'Add dataSafety.data.PSL_DEVICE_ID with its purposes (store-declarations skill): the same purposes answer this card.' }));
}
return {
title: 'Play Console: Advertising ID (App content)',
start: start(config, p, 'app-content/ad-id-declaration', appContentReach('Advertising ID')),
steps: steps([
{ text: 'Open App content > Advertising ID (/app-content/ad-id-declaration).' },
{ text: 'Pick Yes or No (material radio: JS click on the inner [role=radio]).', answers: ['uses'] },
uses?.v && { text: 'Tick exactly the purposes of the sheet (material checkboxes: JS click on the inner [role=checkbox]; read aria-checked back).', answers: out.filter(a => a.id.startsWith('purpose')).map(a => a.id) },
{ text: DECLARATION_SAVE },
{ text: 'Verify: read the page text and pipe it to `storebound console verify play-advertising-id --from-text -`.' },
]),
answers: out,
...declarationButtons('Advertising ID'),
uiFacts: ['Verified 2026-10-12: /app-content/ad-id-declaration.', 'Yes needs the com.google.android.gms.permission.AD_ID permission in the merged manifest (ad SDKs usually add it); `storebound scan` reports the SDKs.', PLAY_MATERIAL_FACT],
verify: pageVerify('play-advertising-id', ['The selected radio and the ticked purposes match the sheet.']),
guidance: ['Open: Policy and programs > App content > Advertising ID'],
};
}
function government(config: ShipConfig, p: PlanIds): Part {
const v = config.console?.appContent?.government;
return {
title: 'Play Console: Government apps (App content)',
start: start(config, p, 'app-content/government-apps', appContentReach('Government apps')),
steps: steps([
{ text: 'Open App content > Government apps (/app-content/government-apps).' },
{ text: 'Pick whether the app is developed by or on behalf of a government (material radio).', answers: ['government'] },
{ text: DECLARATION_SAVE },
{ text: 'Verify: read the page text and pipe it to `storebound console verify play-government-apps --from-text -`.' },
]),
answers: [v !== undefined ? ans('government', 'Is the app developed by or on behalf of a government?', v, 'certain', 'console.appContent.government', { control: 'radio', action: 'pick', label: v ? 'Yes' : 'No', where: 'Government apps' })
: ans('government', 'Is the app developed by or on behalf of a government?', null, 'ask-owner', 'console.appContent.government', { control: 'radio', where: 'Government apps', note: 'Set console.appContent.government (true only for a government body or its contractor).' })],
...declarationButtons('Government apps'),
uiFacts: ['Verified 2026-10-12: /app-content/government-apps.', PLAY_MATERIAL_FACT],
verify: pageVerify('play-government-apps', ['The selected answer matches the sheet.']),
guidance: ['Open: Policy and programs > App content > Government apps'],
};
}
/** Financial features / Health apps: two steps, the features on the last one (or the none box). */
function featureCard(config: ShipConfig, p: PlanIds, kind: 'finance' | 'health'): Part {
const fin = kind === 'finance';
const form: PlanOnlyFormId = fin ? 'play-financial-features' : 'play-health-apps';
const name = fin ? 'Financial features' : 'Health apps';
const key = fin ? 'financialFeatures' : 'healthFeatures';
const v = config.console?.appContent?.[key];
const none = fin ? 'My app doesn\'t provide any financial features' : 'Other';
const out: PlanAnswer[] = v === false
? [ans('none', fin ? 'No financial features' : 'No health features', true, 'certain', `console.appContent.${key}`, { control: 'checkbox', action: 'tick', label: none, curlyQuotes: false, where: `${name}, last step`,
...(fin ? {} : { note: 'On the verified run (2026-10-12) the no-health-features answer was the last step\'s "Other" box; when the page offers an explicit none option, tick that instead.' }) })]
: Array.isArray(v) ? v.map(l => ans(`feature:${l}`, `${name}: ${l}`, true, 'certain', `console.appContent.${key}`, { control: 'checkbox', action: 'tick', label: l, where: `${name}, last step` }))
: [ans('features', `${name}: which features does the app provide?`, null, 'ask-owner', `console.appContent.${key}`, { control: 'checkbox', where: `${name}, last step`, note: `Set console.appContent.${key}: false for none, or the console's labels to tick.` })];
return {
title: `Play Console: ${name} (App content)`,
start: start(config, p, `app-content/${kind}`, appContentReach(name)),
steps: steps([
{ text: `Open App content > ${name} (/app-content/${kind}).` },
{ text: 'Step 1: read it and press Next.', click: ['Next'] },
{ text: `Last step: tick exactly the boxes of the sheet (material checkboxes: JS click on the inner [role=checkbox]; read aria-checked back)${fin ? '; no financial features: "My app doesn\'t provide any financial features"' : '; no health features: "Other" (as on the verified run) or the none option the page offers'}. Then Next if the page shows one.`, click: ['Next'], answers: out.map(a => a.id) },
{ text: DECLARATION_SAVE },
{ text: `Verify: read the page text and pipe it to \`storebound console verify ${form} --from-text -\`.` },
]),
answers: out,
...declarationButtons(name, [{ button: 'Next', class: 'navigation', where: `${name}: between the two steps` }]),
uiFacts: [`Verified 2026-10-12: /app-content/${kind}, two steps; the last step holds ${fin ? '"My app doesn\'t provide any financial features"' : 'the health feature boxes, "Other" among them'}; then Next and Save.`, PLAY_MATERIAL_FACT],
verify: pageVerify(form, ['The ticked boxes match the sheet.']),
guidance: [`Open: Policy and programs > App content > ${name}`],
};
}
// ---- the first production release (after `play release --production draft`) ----
/** The managed publishing warning (Play's default is off: approved means live). */
export function managedPublishingNote(config: ShipConfig): string {
return config.play?.managedPublishing === true
? 'Managed publishing is on (play.managedPublishing): after Google approves, the owner publishes the changes in Publishing overview.'
: `Managed publishing is ${config.play?.managedPublishing === false ? 'off' : 'off by default (play.managedPublishing not set)'}: the release goes live automatically once Google approves it. To control the moment, the owner turns on Publishing overview > Managed publishing first (and sets play.managedPublishing: true).`;
}
const regionName = (code: string): string => { try { return new Intl.DisplayNames(['en'], { type: 'region' }).of(code) ?? code; } catch { return code; } };
function productionCountries(config: ShipConfig, p: PlanIds): Part {
const list = config.play?.countries;
const answers: PlanAnswer[] = list?.length
? list.map(c => ans(`country:${c}`, `Country / region: ${regionName(c)} (${c})`, true, 'certain', 'play.countries', { control: 'checkbox', action: 'tick', label: regionName(c), where: 'Edit countries / regions: the row of that country' }))
: [ans('all', 'All countries / regions', true, 'certain', 'default (play.countries not set)', { control: 'checkbox', action: 'tick', label: 'Select all rows', where: 'Edit countries / regions: the mat-checkbox aria-label="Select all rows"', note: 'Set play.countries (ISO codes) to limit them.' })];
return {
title: 'Play Console: production countries / regions',
start: start(config, p, 'tracks/production', ['Test and release > Production']),
steps: steps([
{ text: 'Open the production track (/app/<appId>/tracks/production) after `storebound play release --production draft` made the draft release.' },
{ text: 'Open the "Countries / regions" tab and press "Edit countries / regions".', click: ['Countries / regions', 'Edit countries / regions'] },
{ text: list?.length ? 'Tick exactly the rows of play.countries (material checkboxes: JS click on the inner [role=checkbox], read aria-checked back); untick the rest.' : 'Tick the mat-checkbox aria-label="Select all rows" (every country / region).', answers: answers.map(a => a.id) },
{ text: 'Press Save. A "Go to Publishing overview?" dialog follows: it only navigates; go on with `storebound console plan play-production-release`.', click: ['Save'] },
{ text: 'Verify: read the tab\'s text and pipe it to `storebound console verify play-production-countries --from-text -` (or run `storebound validate --play`: it reads the production track\'s countries through the API).' },
]),
answers,
final: 'Send changes for review',
neverPress: ['Send changes for review', ...NEVER_PRESS],
allowed: [
{ button: 'Countries / regions (tab), Edit countries / regions', class: 'navigation', where: 'production track: opens the country list' },
{ button: 'Save', class: 'draft-save', where: 'country list: stores the countries of the draft production release; nothing is public before the owner sends the changes for review' },
{ button: 'Go to Publishing overview / its dismiss button', class: 'navigation', where: 'the dialog after Save: navigation only' },
],
uiFacts: [
'Verified 2026-10-12: the API\'s edits.countryAvailability is read-only, so the countries are a console step. tracks/production > Countries / regions tab > Edit countries / regions > a mat-checkbox aria-label="Select all rows" (or one per row) > Save > a "Go to Publishing overview?" dialog.',
'A production track with 0 countries cannot reach anyone: `storebound validate` reports it.',
PLAY_MATERIAL_FACT,
],
verify: pageVerify('play-production-countries', ['The tab lists the countries / regions of the sheet (all of them by default); not 0.']),
guidance: ['Open: Test and release > Production > Countries / regions > Edit countries / regions'],
};
}
function productionRelease(config: ShipConfig, p: PlanIds): Part {
const mp = managedPublishingNote(config);
return {
title: 'Play Console: send the first production release for review',
start: start(config, p, 'tracks/production', ['Test and release > Production']),
steps: steps([
{ text: 'Before: the production countries are set (`storebound console plan play-production-countries`) and every App content card is done (`console plan play-app-content`).' },
{ text: 'Production track > Releases tab > "Edit release" on the draft that `play release --production draft` made.', click: ['Releases', 'Edit release'] },
{ text: 'Press Next: the review page lists errors and warnings. A missing deobfuscation file is only a warning. Any error: stop and fix it (it names the page).', click: ['Next'] },
{ text: 'Press Save (stores the release; nothing is sent yet).', click: ['Save'] },
{ text: 'Open Publishing overview (left menu). It lists "Changes not yet sent for review" and a "Submit N changes for review" button.' },
{ text: `Stop. The owner presses "Submit N changes for review" and then "Send changes for review" in the "Send N changes for review?" dialog (a real mouse click: a JS .click() did nothing on it). ${mp}`, answers: ['managedPublishing'] },
{ text: 'Verify: read Publishing overview as text and pipe it to `storebound console verify play-production-release --from-text -`; `storebound play status` shows the production release.' },
]),
answers: [ans('managedPublishing', 'Managed publishing on?', config.play?.managedPublishing ?? false, config.play?.managedPublishing === undefined ? 'owner-check' : 'certain', config.play?.managedPublishing === undefined ? 'Play default (off)' : 'play.managedPublishing',
{ note: mp, where: 'Publishing overview > Managed publishing (only read; the owner switches it)' })],
final: 'Send changes for review',
neverPress: ['Submit N changes for review', 'Send changes for review', ...NEVER_PRESS],
allowed: [
{ button: 'Releases (tab), Edit release, Next', class: 'navigation', where: 'production track: opens the draft release and its review page' },
{ button: 'Save', class: 'draft-save', where: 'the release review page: stores the draft release; nothing is sent' },
],
uiFacts: [
'Verified 2026-10-12 on a first production release: Releases tab > Edit release > Next (review page; a missing deobfuscation file is only a warning) > Save; then Publishing overview > "Submit N changes for review" > dialog "Send N changes for review?" > "Send changes for review" (the owner\'s final button; a JS .click() did nothing on it, it needs a real click).',
mp,
PLAY_MATERIAL_FACT,
],
verify: pageVerify('play-production-release', ['Publishing overview no longer lists changes not yet sent for review (they are "In review").']),
guidance: ['Open: Test and release > Production > Releases > Edit release; then Publishing overview'],
};
}
export function playOnlyPart(form: Exclude<PlanOnlyFormId, 'play-app-content'>, config: ShipConfig, p: PlanIds): Part {
switch (form) {
case 'play-production-countries': return productionCountries(config, p);
case 'play-production-release': return productionRelease(config, p);
case 'play-store-settings': return storeSettings(config, p);
case 'play-privacy-policy': return privacyPolicy(config, p);
case 'play-ads': return adsDeclaration(config, p);
case 'play-sign-in-details': return signInDetails(config, p);
case 'play-advertising-id': return advertisingId(config, p);
case 'play-government-apps': return government(config, p);
case 'play-financial-features': return featureCard(config, p, 'finance');
case 'play-health-apps': return featureCard(config, p, 'health');
}
}
/** The overview's start (the App content page itself). */
export const appContentStart = (config: ShipConfig, p: PlanIds): ConsolePlan['start'] => start(config, p, 'app-content/overview', ['Policy and programs > App content']);
/** Capture for `console verify play-app-content`: the cards still to start (their aria-labels) and the page text. */
export const APP_CONTENT_CAPTURE = 'JSON.stringify({ start: [...document.querySelectorAll(\'[aria-label^="Start "][aria-label$=" declaration"]\')].map(b => b.getAttribute("aria-label")), text: (document.querySelector("main") ?? document.body).innerText })';
export type { Part as PlayPart };

View file

@ -12,7 +12,8 @@
// play-content-rating, play-target-audience page text (summary pages: a label line, its answer on the same or the
// next line). Modeled, not recorded: rows that cannot be found are "not found", never a match.
import type { ShipConfig } from './config';
import { type Answer, consoleAnswers, type FormId, valueText } from './console-answers';
import { type Answer, consoleAnswers, type FormId, type PlanFormId, type PlanOnlyFormId, valueText } from './console-answers';
import { APP_CONTENT_CARDS, playOnlyPart, startLabel } from './console-play-plans';
import { APPLE_PRIVACY_TYPES } from './app-privacy';
import { typeLabels } from './console-forms';
import { APPLE_PURPOSES } from './console-answers';
@ -20,7 +21,7 @@ import { APPLE_PURPOSES } from './console-answers';
export type RowStatus = 'match' | 'mismatch' | 'not-found' | 'ask-owner';
export interface VerifyRow { item: string; expected: string; seen: string; status: RowStatus; note?: string }
export interface VerifyResult {
form: FormId;
form: PlanFormId;
source: 'page-text' | 'inputs' | 'api';
rows: VerifyRow[];
mismatches: number;
@ -34,7 +35,7 @@ const lines = (text: string): string[] => text.normalize('NFC').split(/\r?\n/).m
/** Straight and curly quotes as one, case and spaces aside: for comparing labels. */
export const fold = (s: string): string => s.normalize('NFC').replace(/[‘’]/g, "'").replace(/[“”]/g, '"').replace(/\s+/g, ' ').trim().toLowerCase();
function result(form: FormId, source: VerifyResult['source'], rows: VerifyRow[], notes: string[] = []): VerifyResult {
function result(form: PlanFormId, source: VerifyResult['source'], rows: VerifyRow[], notes: string[] = []): VerifyResult {
const mismatches = rows.filter(r => r.status === 'mismatch').length, matched = rows.filter(r => r.status === 'match').length;
return { form, source, rows, mismatches, matched, ok: mismatches === 0 && matched > 0, notes };
}
@ -122,9 +123,16 @@ export function verifyAppPrivacy(config: ShipConfig, text: string): VerifyResult
else rows.push({ item: `${t}: ${label}`, expected: yn(x.value as boolean), seen: yn(v), status: v === null ? 'not-found' : v === x.value ? 'match' : 'mismatch', ...(inferred ? { note: inferred } : {}) });
}
}
// after the owner's Publish (verified 2026-10-12): the page shows "Published <when> by <who>"; the name is never echoed
const published = appPrivacyPublished(text);
rows.push({ item: 'Published (the owner\'s Publish)', expected: '"Published … ago by …"', seen: published ? 'Published' : 'no "Published …" line yet', status: published ? 'match' : 'not-found',
...(published ? {} : { note: 'a draft until the owner presses Publish (and Publish in the "Publish Your App Privacy Responses?" dialog)' }) });
return result('apple-app-privacy', 'page-text', rows, notes);
}
/** App Privacy was published: the "Published … ago by …" line is on the page (the owner pressed Publish). */
export const appPrivacyPublished = (text: string): boolean => /\bpublished\b.{0,60}\bago\b|\bpublished (on|at)\b/i.test(text.replace(/\s+/g, ' '));
// ---- Play Create app ----
/** The input states the plan's capture snippet returns. */
@ -299,8 +307,76 @@ export function verifyAgeRatingApi(config: ShipConfig, attrs: Record<string, unk
return result('apple-age-rating', 'api', rows);
}
/**
* The App content overview: the capture JSON ({ start: the aria-labels of the "Start <Name> declaration" buttons still
* on the page, text }) or the plain page text. A card whose start button is still there is not done.
*/
export function verifyAppContent(text: string): VerifyResult {
let st: { start: string[] } | null = null;
try { const j = JSON.parse(text) as unknown; if (j && typeof j === "object" && Array.isArray((j as { start?: unknown }).start)) st = j as { start: string[] }; } catch { /* page text */ }
const pending = new Set((st?.start ?? []).map(s => fold(String(s))));
const rows: VerifyRow[] = APP_CONTENT_CARDS.map(c => {
const open = pending.has(fold(startLabel(c.name)));
return { item: c.name, expected: 'declared (no Start button)', seen: st ? (open ? `"${startLabel(c.name)}" still shown` : 'no Start button') : 'not readable from page text: pass the plan capture JSON', status: st ? (open ? 'mismatch' : 'match') : 'not-found' };
});
return result('play-app-content', st ? 'inputs' : 'page-text', rows, st ? [] : ['The start buttons are aria-labels, not page text: pass the JSON of the plan\'s capture snippet.']);
}
/**
* Store settings and the single App content declarations, from the page text after Save: text answers (the category,
* the privacy policy URL) and option labels must appear; the App category's "Not selected" is a mismatch. Yes / No
* radios cannot be told apart in page text: reported, never counted as a match.
*/
export function verifyPlayDeclaration(form: Exclude<PlanOnlyFormId, 'play-app-content'>, config: ShipConfig, text: string): VerifyResult {
const part = playOnlyPart(form, config, {});
const blob = fold(text);
const rows: VerifyRow[] = [];
for (const a of part.answers) {
if (a.status === 'agreement') continue;
if (a.status === 'ask-owner' || a.value === null) { rows.push({ item: a.question, expected: '(owner decides)', seen: '-', status: 'ask-owner' }); continue; }
const want = a.action === 'type' || a.action === 'choose' ? String(a.value) : a.label ?? '';
const plain = !want || /^(yes|no)$/i.test(want) || a.action === 'untick';
if (plain) { rows.push({ item: a.question, expected: a.text, seen: 'not readable from page text', status: 'not-found', note: 'read the control (aria-checked) in the page' }); continue; }
const shown = blob.includes(fold(want));
const unset = a.id === 'category' && /not selected|select a category/.test(blob);
rows.push({ item: a.question, expected: want, seen: shown ? want : unset ? 'Not selected' : 'not found', status: shown && !unset ? 'match' : unset ? 'mismatch' : 'not-found' });
}
return result(form, 'page-text', rows, ['Page text after Save: Yes / No radios and unticked boxes are not readable from text and are never counted as a match.']);
}
/** The production track's Countries / regions tab: "<N> countries / regions" (0 is a mismatch) and, with
* play.countries, each country's English name on the page. */
export function verifyProductionCountries(config: ShipConfig, text: string): VerifyResult {
const m = /(\d+)\s+(countries|countries\s*\/\s*regions|regions)\b/i.exec(text);
const n = m ? Number(m[1]) : null;
const want = config.play?.countries;
const rows: VerifyRow[] = [{ item: 'Countries / regions', expected: want?.length ? String(want.length) : 'all (more than 0)', seen: n === null ? 'not found' : String(n),
status: n === null ? 'not-found' : n === 0 || (want?.length && n !== want.length) ? 'mismatch' : 'match' }];
const blob = fold(text);
for (const c of want ?? []) {
let name = c; try { name = new Intl.DisplayNames(['en'], { type: 'region' }).of(c) ?? c; } catch { /* the code */ }
rows.push({ item: `${name} (${c})`, expected: 'listed', seen: blob.includes(fold(name)) ? 'listed' : 'not found', status: blob.includes(fold(name)) ? 'match' : 'not-found' });
}
return result('play-production-countries', 'page-text', rows);
}
/** Publishing overview after the owner sent the changes: nothing left "not yet sent for review". */
export function verifyProductionRelease(text: string): VerifyResult {
const f = fold(text);
const pending = /not yet sent for review|submit \d+ changes? for review/.test(f);
const inReview = /in review|changes in review|sent for review/.test(f);
const rows: VerifyRow[] = [{ item: 'Changes sent for review', expected: 'sent (in review)', seen: pending ? 'changes not yet sent for review' : inReview ? 'in review' : 'not found', status: pending ? 'mismatch' : inReview ? 'match' : 'not-found',
...(pending ? { note: 'the owner presses "Submit N changes for review", then "Send changes for review" (a real click)' } : {}) }];
return result('play-production-release', 'page-text', rows);
}
/** Page-text verification of any form that has one. */
export function verifyText(form: FormId, config: ShipConfig, text: string): VerifyResult {
export function verifyText(form: PlanFormId, config: ShipConfig, text: string): VerifyResult {
if (form === 'play-app-content') return verifyAppContent(text);
if (form === 'play-production-countries') return verifyProductionCountries(config, text);
if (form === 'play-production-release') return verifyProductionRelease(text);
if (form === 'play-store-settings' || form === 'play-privacy-policy' || form === 'play-ads' || form === 'play-sign-in-details' || form === 'play-advertising-id'
|| form === 'play-government-apps' || form === 'play-financial-features' || form === 'play-health-apps') return verifyPlayDeclaration(form, config, text);
switch (form) {
case 'apple-app-privacy': return verifyAppPrivacy(config, text);
case 'play-app-record': return verifyPlayAppRecord(config, text);

View file

@ -104,6 +104,12 @@ export interface PlaySnapshot {
subscriptions?: { productId: string; basePlans: { basePlanId: string; state?: string }[] }[];
expectedSubscriptions?: { id: string; basePlanId: string }[];
profile?: ProfileFacts;
/** The Play App category the config gives (play.category or the App Store mapping), null when none: the API cannot
* read or set it, so validate only reminds that Store settings is a console step. */
category?: { category: string; source: string } | null;
/** Countries / regions of the production track (edits.countryAvailability GET; -1 = rest of world too), null when
* it could not be read. */
productionCountries?: number | null;
}
const EDITABLE = ['PREPARE_FOR_SUBMISSION', 'DEVELOPER_REJECTED', 'REJECTED', 'METADATA_REJECTED', 'INVALID_BINARY'];
@ -226,6 +232,16 @@ export function playRules(s: PlaySnapshot): Finding[] {
} else add('warn', 'app-ads', 'app-ads.txt reachable; set ads.appAdsTxt in the config to check its lines');
}
if (s.category !== undefined) {
if (s.category) add('ok', 'category', `App category ${s.category.category} (${s.category.source}): the Play API cannot read it; check Store settings shows it`, 'storebound console plan play-store-settings');
else add('warn', 'category', 'no play.category: Store settings > App category stays "Not selected" (the Play API cannot set it)', 'set play.category, then storebound console plan play-store-settings');
}
if (s.productionCountries === 0) {
const prod = s.tracks.some(t => t.track === 'production' && t.releases.length);
add(prod ? 'blocker' : 'warn', 'countries', 'production has 0 countries / regions (the API cannot set them)', 'storebound console plan play-production-countries');
} else if (typeof s.productionCountries === 'number') add('ok', 'countries', `production: ${s.productionCountries < 0 ? 'every country / region' : `${s.productionCountries} countries / regions`}`);
const anyRelease = s.tracks.some(t => t.releases.length);
if (!anyRelease) add('blocker', 'tracks', 'no release on any track', 'storebound play release <aab>');
for (const t of s.tracks) for (const r of t.releases) {