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

@ -89,7 +89,7 @@ need to ...", do it instead, unless it is one of the owner steps in point 2.
- `skills/appstore-first-release/SKILL.md`: Step-by-step first (and later) App Store release of a mobile app or game
- `skills/aso-basics/SKILL.md`: App store optimization basics for a mobile app's or game's text
- `skills/build-and-sign/SKILL.md`: Sign, build and upload a mobile app or game from the terminal
- `skills/console-autofill/SKILL.md`: Fill App Store Connect and Google Play Console forms that have no API (New App, App Privacy, Play Create app, the IARC content rating, Target audience and content plus Ads; the App Store age rating is guidance because the API sets it). Agent-first by default
- `skills/console-autofill/SKILL.md`: Fill App Store Connect and Google Play Console forms that have no API (New App, App Privacy, Play Create app, IARC content rating, every Play App content card, Play Store settings category, first production release countries and send-for-review). Agent-first by default
- `skills/export-compliance/SKILL.md`: Answer App Store export compliance (encryption) for a mobile app or game correctly
- `skills/listing-localization/SKILL.md`: Translate the store listing of a mobile app or game into more languages
- `skills/migrate-from-fastlane/SKILL.md`: Move a mobile app or game from fastlane (deliver, supply, snapshot / screengrab, Appfile, Deliverfile) to storebound with `storebound import fastlane`

View file

@ -91,7 +91,7 @@ need to ...", do it instead, unless it is one of the owner steps in point 2.
| `appstore-first-release` | Step-by-step first (and later) App Store release of a mobile app or game |
| `aso-basics` | App store optimization basics for a mobile app's or game's text |
| `build-and-sign` | Sign, build and upload a mobile app or game from the terminal |
| `console-autofill` | Fill App Store Connect and Google Play Console forms that have no API (New App, App Privacy, Play Create app, the IARC content rating, Target audience and content plus Ads; the App Store age rating is guidance because the API sets it). Agent-first by default |
| `console-autofill` | Fill App Store Connect and Google Play Console forms that have no API (New App, App Privacy, Play Create app, IARC content rating, every Play App content card, Play Store settings category, first production release countries and send-for-review). Agent-first by default |
| `export-compliance` | Answer App Store export compliance (encryption) for a mobile app or game correctly |
| `listing-localization` | Translate the store listing of a mobile app or game into more languages |
| `migrate-from-fastlane` | Move a mobile app or game from fastlane (deliver, supply, snapshot / screengrab, Appfile, Deliverfile) to storebound with `storebound import fastlane` |

View file

@ -90,7 +90,7 @@ need to ...", do it instead, unless it is one of the owner steps in point 2.
- `skills/appstore-first-release/SKILL.md`: Step-by-step first (and later) App Store release of a mobile app or game
- `skills/aso-basics/SKILL.md`: App store optimization basics for a mobile app's or game's text
- `skills/build-and-sign/SKILL.md`: Sign, build and upload a mobile app or game from the terminal
- `skills/console-autofill/SKILL.md`: Fill App Store Connect and Google Play Console forms that have no API (New App, App Privacy, Play Create app, the IARC content rating, Target audience and content plus Ads; the App Store age rating is guidance because the API sets it). Agent-first by default
- `skills/console-autofill/SKILL.md`: Fill App Store Connect and Google Play Console forms that have no API (New App, App Privacy, Play Create app, IARC content rating, every Play App content card, Play Store settings category, first production release countries and send-for-review). Agent-first by default
- `skills/export-compliance/SKILL.md`: Answer App Store export compliance (encryption) for a mobile app or game correctly
- `skills/listing-localization/SKILL.md`: Translate the store listing of a mobile app or game into more languages
- `skills/migrate-from-fastlane/SKILL.md`: Move a mobile app or game from fastlane (deliver, supply, snapshot / screengrab, Appfile, Deliverfile) to storebound with `storebound import fastlane`

View file

@ -2,6 +2,31 @@
## Unreleased
- Play console steps without an API, from a real first release (2026-10-12; pitfalls 27 and 28). New agent-only
`console plan` / `console verify` forms (also in the MCP `console_plan` / `console_verify` lists):
`play-store-settings` (the App category: `play listing` cannot set it, so a new app showed "Not selected"; the
first Edit > App or game > the Category `[role=option]` > Save), `play-app-content` (every card of
`/app-content/overview` in order with its page, its "Start <Name> declaration" button, its plan and one batch of
owner questions) and the cards `play-privacy-policy`, `play-ads`, `play-sign-in-details` (the demo account is
typed by the owner, never printed), `play-advertising-id`, `play-government-apps`, `play-financial-features`,
`play-health-apps`; `play-production-countries` (edits.countryAvailability is read-only) and
`play-production-release` (Edit release > Next > Save; the owner's "Send changes for review", a real click).
New config: `play.category` (checked against Play's game / app lists; default from an exact App Store category
match), `play.countries`, `play.managedPublishing`, `console.contentRating.details` (the IARC follow-ups each Yes
reveals), `console.appContent`, `social`, `products[].randomRewards` (loot boxes Yes / No from the catalog).
`check` warns without a Play category and counts the App content answers still owed; `validate` adds the category
reminder and "production has 0 countries"; `play status` shows the category as a console step; `play release`
warns that an approved production release goes live by itself while managed publishing is off.
- IARC content rating plan: page 1's IARC Terms of Use box is an agreement (never pressed, never ticked: the click
gate now refuses "terms of use"); the questionnaire is one long page whose Yes answers reveal follow-ups; the
Summary's final button is "Save", not "Submit"; App content forms deep-link to their card pages.
- Every console plan ships `inPageLoop`, a ready-made in-page helper (window.__sb.run, polled through window.__log):
one evaluate call times out at about 45 s on big Play pages. Play plans and `console fill` click the inner
`[role=checkbox]` of material checkboxes like the material radios.
- App Store: appstore-first-release and store-iap-setup send first IAPs with `apple iap-submit --with-version`
(verified on a real first release), not the console; `console verify apple-app-privacy` reads "Published … ago
by …" as the published signal.
- Fix (`play data-safety`): `PSL_SUPPORT_DATA_DELETION_BY_USER` is Play's general "Do you provide a way for users to
request that their data is deleted?". storebound answered it from `partialDeletion` (data deletion without deleting
the account), so an app with a delete-account page declared "no". Now `accounts.deletionUrl` answers yes, with

View file

@ -90,7 +90,7 @@ need to ...", do it instead, unless it is one of the owner steps in point 2.
| `appstore-first-release` | Step-by-step first (and later) App Store release of a mobile app or game |
| `aso-basics` | App store optimization basics for a mobile app's or game's text |
| `build-and-sign` | Sign, build and upload a mobile app or game from the terminal |
| `console-autofill` | Fill App Store Connect and Google Play Console forms that have no API (New App, App Privacy, Play Create app, the IARC content rating, Target audience and content plus Ads; the App Store age rating is guidance because the API sets it). Agent-first by default |
| `console-autofill` | Fill App Store Connect and Google Play Console forms that have no API (New App, App Privacy, Play Create app, IARC content rating, every Play App content card, Play Store settings category, first production release countries and send-for-review). Agent-first by default |
| `export-compliance` | Answer App Store export compliance (encryption) for a mobile app or game correctly |
| `listing-localization` | Translate the store listing of a mobile app or game into more languages |
| `migrate-from-fastlane` | Move a mobile app or game from fastlane (deliver, supply, snapshot / screengrab, Appfile, Deliverfile) to storebound with `storebound import fastlane` |

View file

@ -509,6 +509,12 @@ a terminal first and are never offered over MCP.
stops before the final button and tells you what to press. `storebound console verify <form> [--from-text -]` then
checks the page against the config (App Privacy per-type summaries, Play Create app input states, summaries, or the
App Store Connect API) and exits 1 on a mismatch. Both are read-only MCP tools (`console_plan`, `console_verify`).
Besides the forms of `console fill`, plans cover the Play pages an agent drives alone: `play-store-settings` (the
App category, which the Play API cannot set), `play-app-content` (every App content card in order with one batch
of owner questions) and its cards (`play-privacy-policy`, `play-ads`, `play-sign-in-details`,
`play-advertising-id`, `play-government-apps`, `play-financial-features`, `play-health-apps`), and the first
production release (`play-production-countries`, `play-production-release`). Each plan ships a ready-made in-page
helper (`inPageLoop`) for long flows.
- **Scripted fast path:** `storebound console fill <form>` (`apple-app-record`,
`apple-age-rating`, `apple-app-privacy`, `play-app-record`, `play-content-rating`, `play-target-audience`) opens a
visible browser, goes to the form by itself (Apps > + > New App, the app's App Privacy, Play All apps > Create app,

View file

@ -206,6 +206,7 @@ export const WRAPPED: { apple: Record<string, string>; play: Record<string, stri
'androidpublisher.edits.bundles.list': 'play/next-version-code',
'androidpublisher.edits.bundles.upload': 'play/release play/upload',
'androidpublisher.edits.commit': 'play/client',
'androidpublisher.edits.countryavailability.get': 'validate',
'androidpublisher.edits.delete': 'apps play/client validate',
'androidpublisher.edits.details.get': 'apps play/listing play/status validate',
'androidpublisher.edits.details.update': 'play/listing',

View file

@ -10,6 +10,7 @@ import { appPrivacyMismatches } from './lib/app-privacy';
import { type AsoFinding, auditAso } from './lib/aso';
import { draftLocales } from './lib/aso-intel';
import { appleName, playCodes } from './lib/config';
import { playConsoleReminders } from './lib/console-plan';
import { APPLE_IAP_LIMITS, appleNameIssues, len, parseAppStoreListing, parsePlayListing } from './lib/copy';
import type { Ctx } from './lib/ctx';
import { dataSafetyIssues, dataSafetyRows } from './lib/data-safety';
@ -134,6 +135,10 @@ export async function run(ctx: Ctx): Promise<void> {
for (const i of profileIssues(config,{ demoAccount: !!reviewDemoAccount(ctx.loaded.root) })) {
if (i.level === 'error') problems.push(i.msg); else warn('check', i.msg);
}
// Play steps without an API: the App category (Store settings) and the App content answers the owner still owes
const pc = playConsoleReminders(config);
for (const w of pc.warnings) note(w);
for (const h of pc.hints) { hints.push(h); log('check', `hint: ${h}`); }
const subs = allSubscriptions(config.subscriptions);
if (subs.length) log('check', `subscriptions: ${subs.length} in ${config.subscriptions!.length} group(s)`);
const files = [config.icons?.composite, config.icons?.foreground, config.icons?.background, config.shots?.logo,

View file

@ -122,6 +122,7 @@ async function resolve(root: Locator, f: FieldDef): Promise<Resolved> {
// App Store Connect labels "User Access" with a plain heading, not an ARIA group: then the options are looked up
// in the whole dialog (their own names, e.g. "Full Access", are unique there)
if (g.state === 'ok') { scope = g.loc; seen = true; }
else if (f.scoped) return { field: f, ok: false, seen: false };
}
const options: Record<string, Locator> = {};
let ok = true;
@ -181,7 +182,11 @@ export async function highlight(loc: Locator, text = CHECK): Promise<void> {
async function setField(r: Resolved, v: string | boolean): Promise<boolean> {
const f = r.field;
if (f.kind === 'text') { await r.loc.fill(String(v)); return true; }
if (f.kind === 'checkbox') return safeClick(r.loc, await accName(r.loc), v ? 'check' : 'uncheck');
if (f.kind === 'checkbox') {
const name = await accName(r.loc);
if (await inMaterial(r.loc, 'material-checkbox')) return forbiddenTick(name) ? safeClick(r.loc, name, 'check') : materialToggle(r.loc, v === true);
try { return await safeClick(r.loc, name, v ? 'check' : 'uncheck'); } catch (e) { if (!await inMaterial(r.loc, 'material-checkbox')) throw e; return materialToggle(r.loc, v === true); }
}
if (f.kind === 'radio') {
const o = r.options?.[String(v)];
if (!o) return false;
@ -209,8 +214,24 @@ async function setField(r: Resolved, v: string | boolean): Promise<boolean> {
return true;
}
/** The radio sits in Play Console's <material-radio> (or is a [role=radio] itself). */
const inMaterialRadio = (o: Locator): Promise<boolean> => o.evaluate((el: any) => !!el.closest('material-radio')).catch(() => false);
/** The control sits in one of Play Console's material elements (<material-radio>, <material-checkbox>). */
const inMaterial = (o: Locator, tag: string): Promise<boolean> => o.evaluate((el: any, t: string) => !!el.closest(t), tag).catch(() => false);
const inMaterialRadio = (o: Locator): Promise<boolean> => inMaterial(o, 'material-radio');
/**
* Play Console's <material-checkbox> (the same model problem as material-radio, seen on App content and the IARC
* questionnaire, 2026-10-12): a DOM click on the inner [role=checkbox] when its aria-checked differs from `want`.
* The name gate (agreements) runs before. true when the box ends up as wanted.
*/
export async function materialToggle(o: Locator, want: boolean): Promise<boolean> {
return o.evaluate((el: any, w: boolean) => {
const host = el.closest('material-checkbox');
const inner = el.getAttribute('role') === 'checkbox' ? el : host?.querySelector('[role="checkbox"]') ?? el;
const state = () => (inner.hasAttribute('aria-checked') ? inner.getAttribute('aria-checked') === 'true' : el.checked === true);
if (state() !== w) inner.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window }));
return state() === w;
}, want).catch(() => false);
}
/**
* Play Console's <material-radio> (verified 2026-10-12 on Create app): setting the native input, or a click that does
@ -1038,7 +1059,9 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis
progress(nav?.ok ? 'the form' : 'the form (opened by you)');
if (o.debugEveryStep && !b.isClosed()) await dump(active() ?? b.page, 'start');
const last = def.sections.length - 1;
// the IARC follow-ups (optional sections) appear only after a Yes: the form ends with its last required section
const required = def.sections.flatMap((x, i) => (x.optional ? [] : [i]));
const last = required.at(-1) ?? def.sections.length - 1;
const privacy = form === 'apple-app-privacy';
/** App Privacy: the data types storebound answers (their entries in the Data Types section open their questions). */
const types = [...new Set(def.sections.map(x => x.type).filter((t): t is string => !!t))];
@ -1056,7 +1079,7 @@ export async function fillSession(b: ConsoleBrowser, o: FillSessionOpts): Promis
/** App Privacy openers clicked (each at most twice: a click that opens nothing is a UI change). */
const opened = new Map<string, number>();
let askedCollects = false;
const finished = () => !pendingAdvance && !waitFor && (done.size === def.sections.length || (!privacy && done.has(last)));
const finished = () => !pendingAdvance && !waitFor && (required.every(i => done.has(i)) || (!privacy && done.has(last)));
/** The dialog of the last filled section when it is still visible, else null. */
const anchorDialog = async (): Promise<Locator | null> => {

View file

@ -7,7 +7,8 @@
// ~/.storebound/console-accounts.json) and the workspace's account ids; --lookup also asks the App Store Connect API
// for the app id by bundle id (a GET; needs the API key). Pure logic in cli/lib/console-plan.ts. MCP: console_plan.
import type { Ctx } from '../lib/ctx';
import { FORM_IDS, formMeta, isForm } from '../lib/console-answers';
import { formMeta, isForm, isPlanForm, PLAN_FORM_IDS } from '../lib/console-answers';
import { reviewDemoAccount } from '../lib/credentials';
import { consolePlan, type PlanIds, planText } from '../lib/console-plan';
import { printJson } from '../lib/log';
@ -16,18 +17,21 @@ export async function run(ctx: Ctx): Promise<void> {
const args = ctx.args, json = args.has('json');
const form = args._[0];
if (!form) {
const forms = FORM_IDS.map(id => { const m = formMeta(id, config); return { id, store: m.store, title: m.title, fillable: m.fillable }; });
const forms = PLAN_FORM_IDS.map(id => {
if (isForm(id)) { const m = formMeta(id, config); return { id, store: m.store, title: m.title, fillable: m.fillable, scripted: m.fillable }; }
return { id, store: 'play' as const, title: consolePlan(id, config).title, fillable: true, scripted: false };
});
if (json) return printJson({ forms });
console.log('storebound console plan <form> [--json] [--lookup] (then drive the page yourself; `storebound console verify <form>` checks it)\n');
for (const f of forms) console.log(` ${f.id.padEnd(22)} ${f.store.padEnd(6)} ${f.title}${f.fillable ? '' : ' (guide only)'}`);
for (const f of forms) console.log(` ${f.id.padEnd(26)} ${f.store.padEnd(6)} ${f.title}${f.fillable ? (f.scripted ? '' : ' (agent only, no console fill)') : ' (guide only)'}`);
return;
}
if (!isForm(form)) throw new Error(`console plan: unknown form "${form}" (one of ${FORM_IDS.join(', ')})`);
if (!isPlanForm(form)) throw new Error(`console plan: unknown form "${form}" (one of ${PLAN_FORM_IDS.join(', ')})`);
const { withAccountIds, readAccounts } = await import('./accounts');
const cfg = withAccountIds(config);
const pkg = cfg.play?.packageName;
const cached = pkg ? readAccounts().play[pkg] : undefined;
const ids: PlanIds = { playDeveloperId: cfg.play?.developerId ?? cached?.developerId ?? null, playAppId: cfg.play?.appId ?? cached?.appId ?? null };
const ids: PlanIds = { playDeveloperId: cfg.play?.developerId ?? cached?.developerId ?? null, playAppId: cfg.play?.appId ?? cached?.appId ?? null, demoAccount: !!reviewDemoAccount(root) };
if (form.startsWith('apple-') && !cfg.apple?.appId && cfg.apple?.bundleId) {
const { appleAppId } = await import('./app-id');
// without --lookup only the cache is read: the lookup refuses, so no request is made

View file

@ -2,12 +2,14 @@
// storebound console verify <form> [--from-text <file|->] [--json]
// apple-app-privacy: the page text of the App Privacy page (the Data Types section's per-type summaries; the App Store
// Connect API has no App Privacy endpoint). play-app-record: the JSON of the plan's capture snippet (input states) or
// the page text. play-content-rating, play-target-audience: the summary page text. apple-app-record: the App Store
// the page text. play-content-rating, play-target-audience: the summary page text. play-app-content: the plan's
// capture JSON (cards still showing "Start <Name> declaration"); play-store-settings, the other App content cards and
// play-production-countries / -release: the page text after Save. apple-app-record: the App Store
// Connect API (the app by bundle id; a GET) or, with --from-text, the page text. apple-age-rating: the API.
// Exit 1 on any mismatch, and when nothing could be matched. Pure logic in cli/lib/console-verify.ts. MCP: console_verify.
import { readFileSync } from 'node:fs';
import type { Ctx } from '../lib/ctx';
import { FORM_IDS, isForm } from '../lib/console-answers';
import { isPlanForm, PLAN_FORM_IDS } from '../lib/console-answers';
import { verifyAgeRatingApi, verifyAppRecordApi, type VerifyResult, verifyTable, verifyText } from '../lib/console-verify';
import { printJson } from '../lib/log';
@ -20,7 +22,7 @@ export async function run(ctx: Ctx): Promise<void> {
const { config, root } = ctx.loaded;
const args = ctx.args, json = args.has('json');
const form = args._[0];
if (!isForm(form)) throw new Error(`console verify <form> [--from-text <file|->]: one of ${FORM_IDS.join(', ')}`);
if (!isPlanForm(form)) throw new Error(`console verify <form> [--from-text <file|->]: one of ${PLAN_FORM_IDS.join(', ')}`);
const from = args.get('from-text'), inline = args.get('text');
let r: VerifyResult;
if (from !== undefined || inline !== undefined) {

View file

@ -288,7 +288,9 @@
"expo",
"flutter",
"godot",
"name",
"react-native",
"sku",
"storefront",
"unity"
],

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) {

View file

@ -32,6 +32,11 @@ const APP_NAMES = '^[^,\\x00-\\x1f]{1,120}(,[^,\\x00-\\x1f]{1,120}){0,99}$';
const opt = (flag: string, v: unknown): string[] => (v === undefined || v === null || v === '' ? [] : [`--${flag}`, String(v)]);
const flag = (name: string, v: unknown): string[] => (v === true ? [`--${name}`] : []);
/** Every form `console plan` / `console verify` know (cli/lib/console-answers.ts PLAN_FORM_IDS; a test keeps them equal). */
export const CONSOLE_PLAN_FORMS = ['apple-app-record', 'apple-age-rating', 'apple-app-privacy', 'play-app-record', 'play-content-rating', 'play-target-audience',
'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'];
export const TOOLS: ToolDef[] = [
{ name: 'check', title: 'Offline config check', command: ['check'], write: false, network: false,
description: 'Validate storebound.config.ts, both listing files, screenshot copy, Data safety answers, product texts and referenced files. Offline.' },
@ -391,12 +396,12 @@ export const TOOLS: ToolDef[] = [
description: 'Upload the event card (16:9, 1920x1080 to 3840x2160) and details page (9:16) images of one draft in-app event per locale, size-checked first.',
props: { name: { type: 'string', description: 'Reference name from apple.events' } }, required: ['name'], args: i => opt('name', i.name) },
{ name: 'console_plan', title: 'Console form plan (agent-first)', command: ['console', 'plan'], write: false, network: false,
description: 'Read-only plan for filling a console form that has no API with YOUR OWN browser tool (Claude in Chrome, a built-in browser, Playwright): start URL (deep link when the ids are known), ordered steps (what to click by visible text, what to answer), the answer sheet from the config (each answer certain or ask-owner, with the console\'s exact labels, curly quotes noted), the buttons never to press (Publish, Submit, Create, agreements, help panels), known UI facts per form, how to drive long flows (an in-page async loop writing window.__log) and how to verify. Drive the page yourself, stop before the final button and tell the owner what to press, then call console_verify.',
props: { form: { type: 'string', enum: ['apple-app-record', 'apple-age-rating', 'apple-app-privacy', 'play-app-record', 'play-content-rating', 'play-target-audience'] } },
description: 'Read-only plan for filling a console form that has no API with YOUR OWN browser tool (Claude in Chrome, a built-in browser, Playwright): start URL (deep link when the ids are known), ordered steps (what to click by visible text, what to answer), the answer sheet from the config (each answer certain or ask-owner, with the console\'s exact labels, curly quotes noted), the buttons never to press (Publish, Submit, Create, agreements, help panels), known UI facts per form (Play: material radios and checkboxes take a JS click on the inner [role=radio] / [role=checkbox]), a ready-made in-page helper (inPageLoop: one evaluate call times out around 45 s, so long flows run in the page and you poll window.__log) and how to verify. Forms: the App Store and Play forms of `console fill`, plus agent-only Play pages: play-store-settings (the App category, which the Play API cannot set), play-app-content (every App content card in order with its page and one batch of owner questions) and its cards play-privacy-policy, play-ads, play-sign-in-details, play-advertising-id, play-government-apps, play-financial-features, play-health-apps; and the first production release: play-production-countries (the API cannot set them) and play-production-release (its final Send changes for review is the owner\'s). Drive the page yourself, stop before the final button and tell the owner what to press, then call console_verify.',
props: { form: { type: 'string', enum: CONSOLE_PLAN_FORMS } },
required: ['form'], args: i => [String(i.form), '--json'] },
{ name: 'console_verify', title: 'Verify a console form', command: ['console', 'verify'], write: false, network: true,
description: 'Read-only check of a filled console form against the config. Pass the page text you read (App Privacy: the App Privacy page\'s innerText, whose Data Types section shows per type "Used for …", linked and tracking; Play Create app: the JSON of the plan\'s capture snippet; Play content rating / target audience: the summary page text). apple-app-record and apple-age-rating without text read the App Store Connect API. Returns a per-item match / mismatch table; exit code 1 on a mismatch.',
props: { form: { type: 'string', enum: ['apple-app-record', 'apple-age-rating', 'apple-app-privacy', 'play-app-record', 'play-content-rating', 'play-target-audience'] },
description: 'Read-only check of a filled console form against the config. Pass the page text you read (App Privacy: the App Privacy page\'s innerText, whose Data Types section shows per type "Used for …", linked and tracking; Play Create app: the JSON of the plan\'s capture snippet; Play content rating / target audience: the summary page text; play-app-content: the JSON of the plan\'s capture snippet; Play store settings and the other App content cards: the page text after Save). apple-app-record and apple-age-rating without text read the App Store Connect API. Returns a per-item match / mismatch table; exit code 1 on a mismatch.',
props: { form: { type: 'string', enum: CONSOLE_PLAN_FORMS },
text: { type: 'string', description: 'The page text (or the capture JSON) of the finished form' } },
required: ['form'], args: i => [String(i.form), ...(typeof i.text === 'string' && i.text ? [`--text=${i.text}`] : []), '--json'] },
{ name: 'apple_preorder_status', title: 'App Store pre-order status', command: ['apple', 'preorder', 'status'], write: false, network: true,

View file

@ -9,7 +9,7 @@
import { readFileSync, statSync } from 'node:fs';
import type { Ctx } from '../lib/ctx';
import { confirm, need } from '../lib/ctx';
import { done, log } from '../lib/log';
import { done, log, warn } from '../lib/log';
import { aabInfo } from '../lib/gradle';
import { playClient } from './client';
import { usedVersionCodes } from './next-version-code';
@ -86,5 +86,11 @@ export async function run(ctx: Ctx): Promise<void> {
throw new Error(`${(e as Error).message}\n\nThis app has never been published: the API may only create DRAFT releases. Re-run with --production draft, then send it from the Console (skill play-first-release).`);
throw e;
}
const prod = tracks.find(t => t.track === 'production');
if (prod) {
// Play's default: an approved release goes live by itself (managed publishing is a console-only setting)
if (config.play?.managedPublishing !== true) warn('play', `managed publishing is ${config.play?.managedPublishing === false ? 'off' : 'off by default (play.managedPublishing not set)'}: the release goes live automatically once Google approves it`);
if (prod.status === 'draft') log('play', 'next: `storebound console plan play-production-countries` (the API cannot set countries), then `storebound console plan play-production-release` (its final "Send changes for review" is the owner\'s)');
}
done('play', 'release done');
}

View file

@ -1,5 +1,6 @@
// Read-only overview: tracks and releases, listing languages, one-time products and their purchase option states.
// storebound play status [--json]
import { playCategoryOf } from '../lib/config';
import type { Ctx } from '../lib/ctx';
import { need } from '../lib/ctx';
import { printJson } from '../lib/log';
@ -16,8 +17,12 @@ export async function run(ctx: Ctx): Promise<void> {
const listings = (await play.call<{ listings?: { language: string }[] }>('GET', `${play.API}/edits/${id}/listings`)).listings ?? [];
const details = await play.call<Record<string, string>>('GET', `${play.API}/edits/${id}/details`);
const products = await listOneTimeProducts(play);
if (ctx.args.has('json')) { printJson({ details, tracks, listings, products }); return; }
// the App category is not in the API (edits.details has no category field): a console step
const cat = playCategoryOf(config);
const category = { config: cat?.category ?? null, source: cat?.source ?? null, api: false, plan: 'storebound console plan play-store-settings' };
if (ctx.args.has('json')) { printJson({ details, category, tracks, listings, products }); return; }
console.log(`${play.packageName}\n details: ${details.defaultLanguage} · ${details.contactEmail ?? '-'} · ${details.contactWebsite ?? '-'}`);
console.log(` category: ${cat ? `${cat.category} (${cat.source})` : 'not in the config (play.category)'} · not readable by the API: Store settings is a console step (${category.plan})`);
console.log(` listings (${listings.length}): ${listings.map(l => l.language).join(', ') || '-'}`);
for (const t of tracks) {
const rs = (t.releases ?? []).map(r => `${r.name ?? '?'} [${r.versionCodes?.join(',') ?? '-'}] ${r.status}${r.userFraction ? ' ' + r.userFraction * 100 + '%' : ''}`);

View file

@ -15,7 +15,7 @@
// bad behavior threshold is a blocker, near it a warning; a store without vitals access is only noted. --no-health skips.
import { ascClient, type J } from './apple/asc';
import { requiredDisplayTypes } from './apple/screenshots';
import { type Loaded, playCodes, profileOf, sellsOn } from './lib/config';
import { type Loaded, playCategoryOf, playCodes, profileOf, sellsOn } from './lib/config';
import { reviewDemoAccount } from './lib/credentials';
import { allSubscriptions, basePlanId } from './lib/subscriptions';
import type { Ctx } from './lib/ctx';
@ -132,6 +132,9 @@ export async function collectPlay(ctx: Ctx): Promise<PlaySnapshot> {
const tracks = ((await play.call<{ tracks?: { track: string; releases?: { status: string; versionCodes?: string[]; userFraction?: number }[] }[] }>('GET', `${play.API}/edits/${e.id}/tracks`)).tracks ?? [])
.map(t => ({ track: t.track, releases: (t.releases ?? []).map(r => ({ status: r.status, versionCodes: r.versionCodes ?? [], userFraction: r.userFraction })) }));
const products = (await listOneTimeProducts(play)).map(p => ({ productId: p.productId, states: (p.purchaseOptions ?? []).map(o => o.state ?? '') }));
// the production track's countries (read-only in the API: set in the console, `console plan play-production-countries`)
const productionCountries = await play.call<{ countries?: unknown[]; restOfWorld?: boolean }>('GET', `${play.API}/edits/${e.id}/countryAvailability/production`)
.then(r => (r.restOfWorld ? -1 : (r.countries ?? []).length)).catch(() => null);
const website = details.contactWebsite ?? null;
const appAdsLines = config.ads?.appAdsTxt ?? [];
const adsLikely = !!config.ads || Object.values(config.dataSafety?.data ?? {}).some(u => u.collect.includes('advertising'));
@ -155,7 +158,7 @@ export async function collectPlay(ctx: Ctx): Promise<PlaySnapshot> {
defaultLanguage: details.defaultLanguage ?? config.play?.defaultLanguage ?? 'en-US',
expectedLanguages: config.languages.flatMap(playCodes), listings, tracks, products,
expectedProducts: (config.products ?? []).filter(p => sellsOn(config, p, 'play')).map(p => p.id),
privacyUrl: await checkUrl(config.app.privacyPolicyUrl), website, appAds, appAdsLines, adsLikely,
privacyUrl: await checkUrl(config.app.privacyPolicyUrl), website, appAds, appAdsLines, adsLikely, category: playCategoryOf(config), productionCountries,
};
} finally {
await play.call('DELETE', `${play.API}/edits/${e.id}`).catch(() => {});

View file

@ -13,7 +13,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
| API | operations | GET | other | wrapped by a command | owner only |
|---|---:|---:|---:|---:|---:|
| App Store Connect API 4.5.1 | 1303 | 826 | 477 | 197 | 166 |
| Google Play Android Developer API v3 (revision 20261008) | 145 | 45 | 100 | 32 | 49 |
| Google Play Android Developer API v3 (revision 20261008) | 145 | 45 | 100 | 33 | 49 |
| Google Play Developer Reporting API v1beta1 (revision 20261008) | 25 | 15 | 10 | 6 | 0 |
## App Store Connect API 4.5.1
@ -1577,7 +1577,7 @@ Specs: Apple [App Store Connect API 4.5.1](https://developer.apple.com/sample-co
| `androidpublisher.edits.bundles.list` | GET | `androidpublisher/v3/applications/{packageName}/edits/{editId}/bundles` | read | play/next-version-code |
| `androidpublisher.edits.bundles.upload` | POST | `androidpublisher/v3/applications/{packageName}/edits/{editId}/bundles` | write (dry by default) | play/release, play/upload |
| **androidpublisher.edits.countryavailability** (1) | | | | |
| `androidpublisher.edits.countryavailability.get` | GET | `androidpublisher/v3/applications/{packageName}/edits/{editId}/countryAvailability/{track}` | read | |
| `androidpublisher.edits.countryavailability.get` | GET | `androidpublisher/v3/applications/{packageName}/edits/{editId}/countryAvailability/{track}` | read | validate |
| **androidpublisher.edits.deobfuscationfiles** (1) | | | | |
| `androidpublisher.edits.deobfuscationfiles.upload` | POST | `androidpublisher/v3/applications/{packageName}/edits/{editId}/apks/{apkVersionCode}/deobfuscationFiles/{deobfuscationFileType}` | write (dry by default) | |
| **androidpublisher.edits.details** (3) | | | | |

View file

@ -9,6 +9,8 @@ fails without the fix.
| # | date | lesson | guard |
|---|---|---|---|
| 28 | 2026-10 | First App Store release with IAPs: the console route failed an agent (the IAP page's "Add for Review" menu ignored scripted clicks; the version page no longer has an "In-App Purchases and Subscriptions" section), while `apple iap-submit --with-version --yes` added the IAPs to the draft submission that held 1.0.0 and submitted them first try. App Privacy's Publish opens a "Publish Your App Privacy Responses?" dialog and leaves a "Published … ago by …" line. | appstore-first-release and store-iap-setup point to `apple iap-submit --with-version` (the owner's yes: it submits), never the console. `console verify apple-app-privacy` reads the "Published …" line as the published signal; the plan names the dialog. `tests/console-play-plans.test.ts`. |
| 27 | 2026-10 | A real Play first release (an agent with a browser tool) found the steps no API does: the App category stayed "Not selected" (`play listing` cannot set it: edits.details has no category field); production had 0 countries (edits.countryAvailability is read-only); App content had ten cards with their own pages; the IARC questionnaire is one long page whose Yes answers reveal follow-ups, with an IARC Terms of Use agreement on page 1 and a final "Save" (not "Submit") on the Summary; Play's material checkboxes, like its radios, ignore native input; long evaluate calls timed out at 45 s; "Send changes for review" ignored a JS click; managed publishing is off by default, so approved means live. | Plans `play-store-settings`, `play-app-content` (+ one per card), `play-production-countries`, `play-production-release`; config `play.category` (Play's lists), `play.countries`, `play.managedPublishing`, `console.contentRating.details`, `console.appContent`, `social`, `products[].randomRewards`; every ask-owner answer in one batch; `check` warns without a category and counts the open App content answers; `validate` reports the category reminder and "production has 0 countries"; `play status` and `play release` say what is console-only; the IARC Terms box is never-press; material checkboxes get the inner [role=checkbox] click (plan and `console fill`); every plan ships `inPageLoop`. `tests/console-play-plans.test.ts`. |
| 26 | 2026-10 | An agent using storebound spent hours handing the owner one click, command or answer at a time ("press Next", "download the CSV template", "now run the upload", a compliance question in the middle of a run): skills said "let the owner do it" / "by hand" for navigation, exports and uploads. | The first rule "Do it, don't ask" heads `docs/agents-core.md` (every generated adapter), README and the store-pitfalls skill: the agent does API calls, uploads, builds, exports, console navigation and forms itself; the owner presses only the final public or irreversible buttons and answers the facts no file proves in one batch at the start. `tests/do-it-dont-ask.test.ts` checks the rule is there and fails on skill text that hands navigation, draft saves, exports, uploads or form filling to the owner. |
| 25 | 2026-10 | In a non-interactive shell `play build` stopped in Gradle with "SDK location not found" (no `ANDROID_HOME` there, `local.properties` gitignored) although the SDK was in `~/Library/Android/sdk`; `apple upload --no-wait --yes` sent the IPA for over 10 minutes without a line (progress every 10th part only, no per-part or per-call timeout). | `resolveAndroidSdk` (ANDROID_HOME, ANDROID_SDK_ROOT, `sdk.dir` in local.properties, the Android Studio default folder; only folders with platforms/ or platform-tools/) hands the SDK to Gradle in the environment, never writes local.properties, stops before Gradle with the places checked; `doctor` shows it (`cli/lib/tools.ts`, `cli/play/build.ts`, `cli/doctor.ts`). `apple upload` prints the size, a line per part and a 20 s heartbeat (api, altool, processing wait), per-part and per-call timeouts, `--timeout` exits 7 with the resume hint (`sendParts` in `cli/apple/upload.ts`, `cli/lib/progress.ts`; `tests/tools-path.test.ts`, `tests/build-sign.test.ts`, `tests/upload-progress.test.ts`). |
| 24 | 2026-10 | `flow run release --apply --yes` ran `apple upload --no-wait`, which printed "not a terminal: re-run with --yes to confirm" and declined, yet the flow logged "4/13 upload: ok" and went on to `apple testflight wait` for a build that was never uploaded: a declined confirmation exited 0, and the flow's `--yes` never reached the steps. | `confirm()` (`cli/lib/ctx.ts`): every decline (answered no, not a terminal without `--yes`, under serve) prints `declined: <reason>` and exits 3 (`DECLINED_EXIT`). `flow run` reports "step N declined: <reason>" (status `declined`) and stops; `--apply --yes` adds `--yes` to write steps only (`planStep`, `declinedReason`; `tests/flow.test.ts`). |

View file

@ -13,7 +13,7 @@ has one plain-English caption at the top, fully on screen for at least 3 s; belo
|---|---|---|
| 0-4.5 | the demo game's icon, "Done"; the stores' to-do list line by line; one rejection mail (Guideline 2.1) | Your app is done. The stores are not. |
| 4.5-9 | an empty folder; `bunx storebound init` is typed; the files it creates list in | storebound takes it from an empty folder. |
| 9-14 | the prompt "Get my app ready for both stores."; `storebound release plan 1.0.0`: 25 steps in fix order | You ask once. It plans every step. |
| 9-14 | the prompt "Get my app ready for both stores."; `storebound release plan 1.0.0`: 26 steps in fix order | You ask once. It plans every step. |
| 14-19 | the demo game's store text in 4 languages; its renders in 7 store sizes x 4 languages | Store text and screenshots. Every language, every size. |
| 19-23.5 | `play products --dry` (174 regions), then `catalog diff` (175 / 174 region prices, 0 mismatches) | In-app products on both stores, priced in 175 countries. |
| 23.5-28.5 | the scan's declarations proposal: detected answers with file:line, one "ask owner" line highlighted; 764 Data safety answers | The forms, answered from your code. It asks what it can't know. |

View file

@ -126,7 +126,7 @@ export const PLAN_LINES = [
' 20. BLOCKER no release on any track',
];
/** Steps in the whole plan (the numbered lines of the real output). */
export const PLAN_STEPS = 25;
export const PLAN_STEPS = 26;
export const PLAN_PILL = `*${PLAN_STEPS}* steps · both stores · in fix order`;
// ---------- 4 store: listing text in every language, screenshots in every size ----------

View file

@ -64,12 +64,17 @@ answers the compliance facts no file proves, all in one batch at the start.
`reviewContact`, or `--contact-from <Apple ID of another app of the team>`; notes from the config). Apps behind a
sign-in: the demo account from `.storebound/` (`review-access` skill). Apps with accounts: in-app deletion
(`account-deletion` skill).
11. **API + UI · In-app purchases and subscriptions.** `store-iap-setup` and `store-subscriptions` skills. First
IAPs and subscriptions go to review **with** this version.
11. **API · In-app purchases and subscriptions.** `store-iap-setup` and `store-subscriptions` skills. First
IAPs and subscriptions go to review **with** this version: `storebound apple iap-submit --with-version --dry`,
then (after the owner's yes, since it submits) `storebound apple iap-submit --with-version --yes`. On a real first
release (2026-10-12) it added the IAPs to the draft submission that already held version 1.0.0 and submitted
them, first try. Not the console: its IAP "Add for Review" menu did not react to scripted clicks, and the version
page no longer has an "In-App Purchases and Subscriptions" section.
12. **UI · App Privacy.** `store-declarations` skill (nutrition labels; you fill and save them), then the owner
presses **Publish**.
13. **Preflight.** `storebound validate --apple` until it shows no blocker (`store-preflight`).
14. **UI · Submit.** Version page > Add for Review (with the IAPs in the same draft submission) > Submit for Review
14. **Submit.** The owner's yes for `storebound apple iap-submit --with-version --yes` (version and IAPs in one
review submission; step 11), or Version page > Add for Review > Submit for Review when there are no IAPs
(owner). Release type MANUAL means the owner presses "Release" after approval.
15. **Verify + wait.** `storebound apple status`: version WAITING_FOR_REVIEW, IAPs WAITING_FOR_REVIEW, one review
submission. `storebound apple status --wait-review` polls until approved (exit 0) or rejected (exit 1,
@ -78,11 +83,13 @@ answers the compliance facts no file proves, all in one batch at the start.
## Gotchas
- The app record cannot be created via API; the bundle id can.
- First consumable / non-consumable IAPs must be submitted together with an app version, only from the IAP page
("Add for Review" puts them into the draft submission). The API (`inAppPurchaseSubmissions`) answers
`FIRST_*_MUST_BE_SUBMITTED_ON_VERSION`. If the version was already submitted without them: cancel the review
submission (`storebound apple status --cancel-submission <id>`, PATCH canceled: true), wait a minute, add the IAPs
from their page, then resubmit.
- First consumable / non-consumable IAPs must be submitted together with an app version. The deprecated
`inAppPurchaseSubmissions` answers `FIRST_*_MUST_BE_SUBMITTED_ON_VERSION`; `storebound apple iap-submit
--with-version` puts both into one review submission through `reviewSubmissionItems` (worked on a real first
release, 2026-10-12). The console route is broken for agents: the IAP "Add for Review" menu ignored scripted clicks
and the version page has no "In-App Purchases and Subscriptions" section any more. If the version was already
submitted without them: cancel the review submission (`storebound apple status --cancel-submission <id>`, the
owner's yes), wait a minute, then `apple iap-submit --with-version` (pitfall 28).
- App Privacy (data types, purposes, linked, tracking) is UI-only and must be **published**, not just saved.
- Export compliance unanswered = "Missing Compliance": `storebound apple encryption` (`export-compliance`).
- ITMS-91053 / 91061 privacy manifest emails after upload: `privacy-manifest` skill.

View file

@ -1,6 +1,6 @@
---
name: console-autofill
description: Fill App Store Connect and Google Play Console forms that have no API (New App, App Privacy, Play Create app, the IARC content rating, Target audience and content plus Ads; the App Store age rating is guidance because the API sets it). Agent-first by default - `storebound console plan <form> --json` (MCP console_plan) gives the start URL, steps, answer sheet with the console's exact labels, never-press list and verified UI facts; you drive the page with your own browser tool (Claude in Chrome, a built-in browser, Playwright), stop before the final button and tell the owner what to press, then `storebound console verify <form>` (MCP console_verify). `storebound console fill` is the optional scripted fast path. Answers come only from storebound.config.ts. Never sign in, submit, publish, accept agreements or grant permissions. Use when the user wants help filling one of these console forms, asks what to answer in them, or wants the answers as a checklist.
description: Fill App Store Connect and Google Play Console forms that have no API (New App, App Privacy, Play Create app, IARC content rating, every Play App content card, Play Store settings category, first production release countries and send-for-review). Agent-first by default - `storebound console plan <form> --json` (MCP console_plan) gives the start URL, steps, answer sheet with the console's exact labels, never-press list and verified UI facts; you drive the page with your own browser tool, stop before the final button and tell the owner what to press, then `storebound console verify <form>` (MCP console_verify). `storebound console fill` is the optional scripted fast path. Answers come only from storebound.config.ts. Never sign in, submit, publish, accept agreements or grant permissions. Use when the user wants help filling one of these console forms, asks what to answer in them, or wants the answers as a checklist.
---
# Console forms without an API
@ -30,7 +30,16 @@ the UI shows. This is the primary mode.
`label` (curly quotes as shown, `curlyQuotes: true`), `action` (tick / untick / pick / type / choose) and `where`;
- `buttons.neverPress` (Publish, Submit, Create, Create app, Send, Release, "Definitions and Examples", "?", the
final Save of a form ...), `buttons.final` and `buttons.allowed` (navigation and draft saves);
- `uiFacts` (verified facts per form, see below), `driving` and `verify` (the command and what to capture).
- `uiFacts` (verified facts per form, see below), `driving`, `inPageLoop` (a ready-made in-page helper, see 5) and
`verify` (the command and what to capture).
Forms: `apple-app-record`, `apple-age-rating`, `apple-app-privacy`, `play-app-record`, `play-content-rating`,
`play-target-audience` (these also have the scripted `console fill`), and agent-only Play pages:
`play-store-settings` (the App category: the Play API has no category field, so `play listing` leaves it "Not
selected"), `play-app-content` (every App content card in order, its page, its plan and ONE batch of owner
questions), its cards `play-privacy-policy`, `play-ads`, `play-sign-in-details`, `play-advertising-id`,
`play-government-apps`, `play-financial-features`, `play-health-apps`, and the first production release:
`play-production-countries` and `play-production-release`. `storebound console plan` lists them.
2. Collect the `ask-owner` answers of every form you will fill in ONE question to the owner before you start (AGENTS.md
rule 5) and write them into the config, so the plan has them. Never guess them; leave them empty if the owner
wants to answer in the page.
@ -38,16 +47,21 @@ the UI shows. This is the primary mode.
4. Follow `steps`. Read the page (text or accessibility tree) before each action, click by visible text, set each
answer as the sheet says, wait for the next step to render. If the page differs from the plan, adapt (the console
changes), but never press anything in `buttons.neverPress` and never tick an agreement.
5. Long flows (App Privacy with many data types): one evaluate call times out around 45 s, so start an in-page async
loop and poll it:
5. Long flows (App Privacy with many data types, the IARC questionnaire, App content): one Runtime.evaluate call
times out at about 45 s on big console pages, so never loop over many controls in one call. Paste the plan's
`inPageLoop` once per page (one short call; it defines `window.__sb`), start the job and poll in short calls:
```js
window.__log = []; window.__run = (async () => { /* for each step: wait for it, act, */ window.__log.push('Device ID: purposes set'); /* ... */ window.__log.push('done'); })().catch(e => window.__log.push('stopped: ' + e.message));
window.__sb.run([{ role: 'radio', label: 'No', within: '<question text>' }, { role: 'checkbox', label: '<box label>', want: true }])
// then, every 2-5 s: window.__log.slice(-20), window.__done
```
then read `window.__log` every few seconds until it says `done` or `stopped`. Never click past a step you have not
read; on an unknown step, stop the loop and look.
The helper finds each control by its label (aria-label, else the nearest parent with non-empty innerText), clicks
the inner `[role=radio]` / `[role=checkbox]` of a material control and reads it back; it never clicks buttons and
refuses agreements. Press Next / Save yourself afterwards. Never click past a step you have not read.
6. Stop before `buttons.final` and tell the owner exactly what to press (App Privacy: review the summary, press
Publish; Play Create app: tick the two declarations, press Create app; content rating: Submit; target audience:
Save; New App: Create).
Publish, then Publish in the "Publish Your App Privacy Responses?" dialog; Play Create app: tick the two
declarations, press Create app; content rating: the Summary's Save (not Submit); target audience and the other
App content cards: Save; Play production: "Submit N changes for review", then "Send changes for review"; New App:
Create).
7. `storebound console verify <form> --from-text -` (MCP: `console_verify` with `text`) with what `verify.capture`
says: App Privacy the page's `innerText`, Play Create app the JSON of the capture snippet. Exit 1 / mismatches:
fix them in the page (or the config), verify again. `apple-app-record` and `apple-age-rating` verify from the API
@ -72,6 +86,32 @@ the UI shows. This is the primary mode.
click the inner `[role=radio]` with a JS click, then check `aria-checked="true"` and that no "Select …" hint is
left. Text inputs set by `form_input` are fine. After Create app the app lives at
`/console/u/0/developers/<developerId>/app/<appId>/app-dashboard`: put the id in `play.appId`.
- **Play material radios and checkboxes** (Create app, App content, IARC, 2026-10-12): `<material-radio>` /
`<material-checkbox>`; click the inner `[role=radio]` / `[role=checkbox]` (or the `<input>`) with a JS click and read
`aria-checked` back. Their labels sit in sibling nodes: read the nearest parent with non-empty `innerText`.
- **Play Store settings** (2026-10-12): `/app/<appId>/store-settings` > the FIRST Edit (App category) > App or game >
the Category dropdown's `[role=option]` with exactly the category name (`play.category`) > Save.
- **Play App content** (2026-10-12): `/app-content/overview`; each card has a button aria-label="Start <Name>
declaration". Pages: 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"; Next, Save),
health (2 steps; the last step's "Other" box was the no-features answer on that run; Next, Save),
target-audience-content (age checkboxes; with 13+ only it went straight to the Summary, no appeal question; Save),
content-rating-overview > "Start questionnaire" > content-rating-iarc-questionnaire.
- **IARC questionnaire** (2026-10-12): page 1 has the email, the category radio and an IARC Terms of Use checkbox (an
agreement: the owner ticks it, never you). Then ONE long page of sections; each Yes reveals follow-ups (Violence:
humans / non-humans, setting, childlike style, reactions, presentation, blood level, historical war, 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). The answers come from `console.contentRating.answers` and `.details`, `products[].randomRewards`
and `social`. Save, then Next to the Summary with per-authority ratings; its final button is **Save**, not Submit
(the owner's). Example: PEGI 7, ESRB E10+, USK 12, ClassInd 18 (paid random items), Google Play 7+.
- **App Privacy Publish** (2026-10-12): a `<dialog>` "Publish Your App Privacy Responses?" with Cancel / Publish (the
owner's); afterwards the page shows "Published a few seconds ago by …", which `console verify` reads.
- **Play first production release** (2026-10-12): countries are console-only (`edits.countryAvailability` is
read-only): tracks/production > Countries / regions > Edit countries / regions > "Select all rows" > Save. Then
Releases > Edit release > Next > Save; Publishing overview > "Submit N changes for review" > "Send changes for
review" (the owner's; a JS `.click()` did nothing, it needs a real click). Managed publishing is off by default:
approved means live.
- **After Play Create app, an owner step:** a service account's app-level permissions do NOT cover apps created
later. Granting them is the owner's (a permission grant; auto mode blocks it too): give the exact path together
with the Create app button, Users and permissions > the service account > Add app > the new app > Admin (or the
@ -247,7 +287,7 @@ content", "Create and set up your app"):
| New App (App Store) | + and New App (Cancel only to leave a team without the bundle id) | **Create**: creates the app record for good |
| Age rating (guide only) | nothing (`storebound apple appinfo` sets it by API) | Set Up / Next / Done, if the owner wants to look |
| Create app (Play) | Create app on the app list (opens the form); Check availability next to the package name (read-only) | the two **declarations** (agreements, never ticked) and **Create app** in the form: creates the app |
| Content rating (Play) | Start; **Save draft** then Next on each page (a draft: IARC gets nothing before Submit; the owner agreed) | **Submit** on the Summary: sends the questionnaire to IARC |
| Content rating (Play) | Start; **Save draft** then Next on each page (a draft: IARC gets nothing before the Summary's Save; the owner agreed) | the **IARC Terms of Use** box (an agreement) and **Save** on the Summary (not "Submit", verified 2026-10-12): sends the questionnaire to IARC |
| Target audience (Play) | Start / Manage, Next between the pages | **Save** on the last page: stores the declaration, which Google then reviews |
Mid-way storebound stops only for an uncertain answer (see 6). A navigation button is never the owner's: when the
@ -257,8 +297,9 @@ console page changed so that storebound cannot find it, it says so and you click
Every `[check]` answer, linked-to-identity and tracking for each App Privacy data type (unless `appPrivacy.types`
answers them), Developer's Advertising or Marketing, the IARC answers, the target
ages, Play's Free or paid and its declarations, and pressing the final button (App Privacy Publish, IARC Submit,
Target audience Save, New App Create, Play Create app).
ages, Play's Free or paid and its declarations, the IARC Terms of Use box, the Sign in details username and password,
and pressing the final button (App Privacy Publish, the IARC Summary's Save, each App content card's Save, New App
Create, Play Create app, Play's Send changes for review).
## Out of scope

View file

@ -66,15 +66,34 @@ products) and answers the compliance facts no file proves, all in one batch at t
permission on any track first.
9. **API · Data safety.** Answers in `dataSafety` of the config, `storebound play data-safety`, then check the summary
in Policy and programs > App content > Data safety.
10. **UI · App content.** `store-declarations` skill: privacy policy, ads, app access, content rating (IARC), target
audience, advertising ID, government / financial / health / news, data safety review. Behind a sign-in: App access
with the demo account (`review-access`); with accounts: the delete-account URL (`account-deletion`); subscriptions:
`storebound play subscriptions` (`store-subscriptions`).
11. **UI · Store settings.** Category + tags, contact details.
10. **UI · App content.** `storebound console plan play-app-content --json` lists every card of
`/app-content/overview` in order (Privacy policy, Ads, Sign in details, Content ratings, Target audience and
content, Data safety, Advertising ID, Government apps, Financial features, Health apps), each card's page and
start button (aria-label "Start <Name> declaration"), its own plan (`console plan play-privacy-policy`,
`play-ads`, `play-sign-in-details`, `play-content-rating`, `play-target-audience`, `play-advertising-id`,
`play-government-apps`, `play-financial-features`, `play-health-apps`) and ONE batch of owner questions
(`askOwner`): ask them at the start, write the answers into the config (`console.contentRating.details`,
`console.appContent`, `social`, `products[].randomRewards`), rerun the plan. Data safety goes through the API
(step 9). The IARC Terms of Use box and each card's final Save are the owner's (`store-declarations` skill).
Behind a sign-in: the owner types the demo account in Sign in details (`review-access`); with accounts: the
delete-account URL (`account-deletion`); subscriptions: `storebound play subscriptions` (`store-subscriptions`).
11. **UI · Store settings (App category).** `play listing` cannot set it (the Play API has no category field), so a
new app shows "Not selected": set `play.category` (Play's name, e.g. "Strategy"; `storebound check` warns
without it), then `storebound console plan play-store-settings`: /store-settings > the FIRST Edit > App or game >
the Category dropdown's `[role=option]` with exactly that name > Save. Contact details come from `play listing`.
12. **API + UI · Production.** `storebound play release --version-code N --production draft`, then in the Console
(you, with your browser tool):
Production > Countries/regions > Add countries > select all > Save; Production > the draft release > Edit >
Next > Save; Publishing overview > **Send changes for review** (owner).
a) `storebound console plan play-production-countries`: tracks/production > Countries / regions tab > Edit
countries / regions > the mat-checkbox "Select all rows" (or `play.countries`) > Save; a "Go to Publishing
overview?" dialog follows. `edits.countryAvailability` is read-only in the API; `storebound validate --play`
reports "production has 0 countries".
b) `storebound console plan play-production-release`: Releases tab > Edit release > Next (a missing
deobfuscation file is only a warning) > Save.
c) Publishing overview > "Submit N changes for review" > dialog "Send N changes for review?" > **Send changes for
review**: the owner's final button, and it needs a real click (a JS `.click()` did nothing on it).
Managed publishing is off by default: the app goes live automatically once Google approves it. If the owner wants
to pick the moment, they turn on Publishing overview > Managed publishing first; record it in
`play.managedPublishing` (`play release` and the plan warn while it is off).
13. **Verify.** `storebound validate --play` (no blockers), `storebound play status` (tracks, listings, products), Console dashboard has no open tasks,
Publishing overview shows "In review".

View file

@ -26,10 +26,19 @@ CLI: `storebound` (or `bunx storebound` in a project that has it, or `bun <toolc
Play cannot read the form back: storebound keeps the hash of the last upload in `.storebound/data-safety.sha256`
and skips an unchanged CSV. After a hand edit in Play Console, `--force` sends the config's answers again.
Then check the summary in Play Console > Policy and programs > App content > Data safety.
3. **Play App content (UI, you drive it).** Policy and programs > App content, each card with `store/declarations.md`
(`storebound console plan play-content-rating` / `play-target-audience` for the forms it knows):
privacy policy, app access, ads, content rating (IARC questionnaire), target audience and content, news, COVID-19,
data safety, government apps, financial features, health, advertising ID.
3. **Play App content (UI, you drive it).** `storebound console plan play-app-content --json` lists every card of
`/app-content/overview` in order (Privacy policy, Ads, Sign in details, Content ratings, Target audience and
content, Data safety, Advertising ID, Government apps, Financial features, Health apps) with its page, start
button (aria-label "Start <Name> declaration") and its own plan, plus ONE batch (`askOwner`) of what the config
does not prove. Ask that batch at the start and write the answers into the config:
- `console.contentRating.answers` and `.details` (the IARC follow-ups each Yes reveals: violence details,
`other` for fear / sexuality / gambling / language / controlled substances / crude humor, player trading, the
user interaction follow-ups, the Miscellaneous questions: Nazi symbols, Korea history, terrorism, crimes);
- `products[].randomRewards` (loot boxes: proven per product; ClassInd rated such a game 18), `social` (chat,
block, report, moderation, friends only, location sharing);
- `console.appContent` (government, financialFeatures, healthFeatures, advertisingId), `reviewAccess`.
The IARC Terms of Use box is an agreement (the owner ticks it). Each card's final Save, and the IARC Summary's Save
(not "Submit"), are the owner's, or yours after the owner's clear yes in chat.
4. **Apple age rating (API).** `storebound apple appinfo` (all NONE except your overrides, e.g. `advertising: true`).
5. **Apple App Privacy (UI only, you drive it: `storebound console plan apple-app-privacy`).** App Store Connect >
the app > App Privacy > Get Started / Edit, from `store/app-privacy.md`; per data type: purposes > linked to

View file

@ -101,11 +101,12 @@ App Store only, a future YYYY-MM-DD).
- Apple: first IAPs must ship with an app version ("FIRST_*_MUST_BE_SUBMITTED_ON_VERSION" from the old
inAppPurchaseSubmissions). If the version is already in review without them: cancel the review submission
(`storebound apple status --cancel-submission <id>`, ask the owner), then `storebound apple iap-submit --with-version`
(or "Add for Review" in the UI) and resubmit.
- Not verified against a live account yet: adding the **first** IAP of a type next to the app version through
`reviewSubmissionItems` follows the API 4.4.1 release notes and the 4.5.1 spec (the item takes `appStoreVersion` and
`inAppPurchaseVersion` in one submission). If App Store Connect refuses it, `--no-submit` leaves the draft and the
UI "Add for Review" finishes the job. The deprecated `inAppPurchaseSubmissions` / `subscriptionSubmissions` are not used.
and resubmit.
- Verified on a real first release (2026-10-12): `storebound apple iap-submit --with-version --yes` (after the owner's
yes: it submits) added the first IAPs to the existing draft submission that already held version 1.0.0 and
submitted them, first try, through `reviewSubmissionItems`. Do not use the console for this: the IAP page's "Add for
Review" menu did not react to scripted clicks, and the version page no longer has an "In-App Purchases and
Subscriptions" section (pitfall 28). The deprecated `inAppPurchaseSubmissions` / `subscriptionSubmissions` are not used.
- Apple needs an exact existing price point for each manual price; the error lists the nearest ones.
- Apple IAP display name 2-30 characters, description max **45** (App Store Connect Help > Reference > "In-app
purchase information"; an older App Store marketing page says 55, but App Store Connect enforces 45).

View file

@ -348,3 +348,31 @@ that fails without the fix (see CONTRIBUTING.md).
GEMINI.md, Copilot and Cursor rules), of README and of this skill; `tests/do-it-dont-ask.test.ts` checks that and
fails when a skill or the rules hand navigation, draft saves, exports, uploads or form filling to the owner
(navigation "by hand", uploads or exports left to the owner, asking the owner to click through a form).
## 27. Play steps without an API: category, countries, App content, IARC
- **Symptom:** after a first Play release through the API the App category showed "Not selected", production had 0
countries, and an agent hunted for each App content card; the IARC questionnaire kept revealing new questions, an
evaluate call timed out at 45 s, material checkboxes ignored `form_input`, and "Send changes for review" ignored a
JS click.
- **Cause:** `play listing` cannot set the category (edits.details has no category field) and
edits.countryAvailability is read-only; the IARC questionnaire is one long page where each Yes reveals follow-ups;
Play's `<material-checkbox>` / `<material-radio>` keep their own model.
- **Do:** set `play.category`, `play.countries` (or all), `console.contentRating.details`, `console.appContent`,
`social` and `products[].randomRewards` from ONE batch of owner answers (`storebound console plan play-app-content`
lists them), then drive `console plan play-store-settings`, `play-app-content` and its cards,
`play-production-countries` and `play-production-release`. Click the inner `[role=radio]` / `[role=checkbox]`;
run long flows with the plan's `inPageLoop` and poll `window.__log`. Never tick the IARC Terms of Use box; the
Summary's final button is "Save" (the owner's). "Send changes for review" is the owner's real click; managed
publishing is off by default, so approved means live (`play.managedPublishing`).
- **Guard:** the plans above, `check` (category warning, open App content answers), `validate` (category reminder,
"production has 0 countries"), `play status` / `play release` notes; `tests/console-play-plans.test.ts`.
## 28. First App Store IAPs: `apple iap-submit --with-version`, not the console
- **Symptom:** the IAP page's "Add for Review" menu ignored scripted clicks, and the version page no longer has an
"In-App Purchases and Subscriptions" section.
- **Do:** `storebound apple iap-submit --with-version --dry`, then after the owner's yes (it submits)
`--with-version --yes`: on a real first release it added the IAPs to the draft submission that already held
version 1.0.0 and submitted them, first try. App Privacy: the owner's Publish opens "Publish Your App Privacy
Responses?" (Cancel / Publish); afterwards "Published … ago by …" shows, which `console verify apple-app-privacy`
reads as the published signal.
- **Guard:** appstore-first-release and store-iap-setup skills; `tests/console-play-plans.test.ts`.

View file

@ -327,7 +327,7 @@ describe.skipIf(!browser)('App Privacy: each data type set up from the real page
});
describe.skipIf(!browser)('the other forms: navigation by itself, final buttons and draft saves left to the owner', () => {
test('content rating: Save draft and Next by itself on each page, a stop at the uncertain questions until answered, the Summary; Submit never clicked', async () => {
test('content rating: Save draft and Next by itself on each page, a stop at the uncertain questions until answered, the Summary; its Save never clicked', async () => {
const b = await local();
const log = logLines();
const run = scope.run(fillSession(b, { form: 'play-content-rating', config: base0, startUrl: base + 'play-content-rating.html', ...fast(log) }));
@ -340,7 +340,7 @@ describe.skipIf(!browser)('the other forms: navigation by itself, final buttons
// the owner answers every outlined question
const open = b.page.locator('[role="radiogroup"][data-storebound-check]');
for (let i = 0, n = await open.count(); i < n; i++) await open.nth(i).getByRole('radio', { name: 'No' }).check();
await log.wait('the final line', l => l.some(m => m.includes('press Submit yourself')));
await log.wait('the final line', l => l.some(m => m.includes('press Save yourself')));
expect(await clicks(b)).toEqual(['Save draft', 'Next', 'Save draft', 'Next']);
expect(await b.page.getByRole('heading', { name: 'Summary' }).isVisible()).toBe(true);
expect(navAsked(log.lines)).toEqual([]);

View file

@ -99,8 +99,9 @@ describe('console plan: the JSON shape for every form', () => {
expect(consolePlan('play-app-record', { ...base, play: { packageName: 'com.example.game' } } as ShipConfig).start.deepLink).toBe(false);
});
test('Play App content forms deep-link from the app dashboard once the app id is known', () => {
expect(consolePlan('play-content-rating', base, { playAppId: '9876' }).start.url).toBe('https://play.google.com/console/u/0/developers/4444/app/9876/app-dashboard');
test('Play App content forms deep-link to their card page once the app id is known (verified 2026-10-12)', () => {
expect(consolePlan('play-content-rating', base, { playAppId: '9876' }).start.url).toBe('https://play.google.com/console/u/0/developers/4444/app/9876/app-content/content-rating-overview');
expect(consolePlan('play-target-audience', base, { playAppId: '9876' }).start.url).toBe('https://play.google.com/console/u/0/developers/4444/app/9876/app-content/target-audience-content');
expect(consolePlan('play-target-audience', base).start.url).toBe('https://play.google.com/console/u/0/developers/4444/app-list');
});

View file

@ -0,0 +1,310 @@
// The agent-only Play console plans (Store settings, the App content overview and its cards), the IARC follow-ups,
// the in-page helper and the config fields behind them. Pure; the facts were verified on a real Play Console
// (2026-10-12); every id here is a placeholder.
import { describe, expect, test } from 'bun:test';
import { type ShipConfig, validateConfig, validateExtras } from '../cli/lib/config';
import { consoleAnswers, PLAN_FORM_IDS, PLAN_ONLY_FORM_IDS } from '../cli/lib/console-answers';
import { formDef, forbiddenTick } from '../cli/lib/console-forms';
import { consolePlan, IN_PAGE_LOOP, playConsoleReminders, planText } from '../cli/lib/console-plan';
import { APP_CONTENT_CARDS, PLAY_MATERIAL_FACT, startLabel } from '../cli/lib/console-play-plans';
import { appPrivacyPublished, verifyAppContent, verifyPlayDeclaration, verifyText } from '../cli/lib/console-verify';
import { CONSOLE_PLAN_FORMS, TOOLS, validateInput } from '../cli/mcp-tools';
import { playRules, type PlaySnapshot } from '../cli/lib/validate-rules';
const base: ShipConfig = {
app: { name: 'Example Game', profile: 'game', privacyPolicyUrl: 'https://example.com/privacy', supportUrl: 'https://example.com/support', website: 'https://example.com', contactEmail: 'support@example.com' },
languages: [{ id: 'en', play: 'en-US', apple: 'en-US' }],
play: { packageName: 'com.example.game', developerId: '4444', appId: '9876', category: 'Strategy' },
ads: { appAdsTxt: ['example-network.com, pub-0000, DIRECT, abc123'] },
products: [{ id: 'com.example.game.chest', type: 'consumable', usd: 0.99, listings: { en: { title: 'Chest', description: 'A random chest' } }, randomRewards: true }],
dataSafety: { collectsData: true, encryptedInTransit: true, data: { PSL_DEVICE_ID: { collect: ['advertising', 'analytics'], shared: true, share: ['advertising'] } } },
console: { contentRating: { category: 'game', answers: { violence: false } }, targetAudience: { ages: ['13-15', '16-17', '18-over'], containsAds: true } },
} as ShipConfig;
const dev = 'https://play.google.com/console/u/0/developers/4444/app/9876';
describe('agent-only Play plans: shape, URLs, never-press lists, the owner batch', () => {
const slugs: Record<string, string> = {
'play-store-settings': 'store-settings', 'play-app-content': 'app-content/overview', 'play-privacy-policy': 'app-content/privacy-policy',
'play-ads': 'app-content/ads-declaration', 'play-sign-in-details': 'app-content/testing-credentials', 'play-advertising-id': 'app-content/ad-id-declaration',
'play-government-apps': 'app-content/government-apps', 'play-financial-features': 'app-content/finance', 'play-health-apps': 'app-content/health',
'play-production-countries': 'tracks/production', 'play-production-release': 'tracks/production',
};
for (const form of PLAN_ONLY_FORM_IDS) {
test(form, () => {
const p = consolePlan(form, base);
expect(p).toMatchObject({ form, store: 'play', fillable: true });
expect(p.start).toEqual({ url: `${dev}/${slugs[form]}`, deepLink: true, reach: [] });
p.steps.forEach((s, i) => { expect(s.n).toBe(i + 1); expect(s.text.length).toBeGreaterThan(10); });
expect(p.answers.length).toBeGreaterThan(0);
for (const a of p.answers) if (a.status === 'ask-owner' || a.status === 'agreement') expect(a.value).toBeNull();
// every answer an agent sets is in a step
const inSteps = new Set(p.steps.flatMap(s => s.answers ?? []));
for (const a of p.answers) expect({ id: a.id, listed: inSteps.has(a.id) }).toEqual({ id: a.id, listed: true });
// the owner batch holds exactly the answers the config cannot give
expect(p.askOwner).toHaveLength(p.answers.filter(a => a.status !== 'certain').length);
for (const b of ['Publish', 'Submit', 'Send changes for review']) expect(p.buttons.neverPress).toContain(b);
for (const b of p.buttons.allowed) expect(b.class).not.toBe('final-submit');
if (form.startsWith('play-') && !['play-store-settings', 'play-app-content', 'play-production-countries', 'play-production-release'].includes(form)) { expect(p.buttons.final).toBe('Save'); expect(p.buttons.neverPress).toContain('Save'); }
expect(p.uiFacts.join('\n')).toContain(PLAY_MATERIAL_FACT);
expect(p.verify.command).toBe(`storebound console verify ${form} --from-text -`);
expect(p.inPageLoop).toBe(IN_PAGE_LOOP);
expect(planText(p)).toContain('window.__sb.run');
expect(JSON.parse(JSON.stringify(p))).toEqual(p);
});
}
test('without the app id: the app list, then the card by its "Start <Name> declaration" button', () => {
const p = consolePlan('play-government-apps', { ...base, play: { packageName: 'com.example.game', developerId: '4444' } } as ShipConfig);
expect(p.start.deepLink).toBe(false);
expect(p.start.url).toBe('https://play.google.com/console/u/0/developers/4444/app-list');
expect(p.start.reach.join('\n')).toContain('aria-label="Start Government apps declaration"');
});
test('Store settings: the category from play.category, the first Edit, the [role=option], Save allowed', () => {
const p = consolePlan('play-store-settings', base);
expect(p.answers.find(a => a.id === 'category')).toMatchObject({ value: 'Strategy', status: 'certain', action: 'choose', label: 'Strategy' });
expect(p.answers.find(a => a.id === 'appType')).toMatchObject({ value: 'Game', status: 'certain' });
const text = p.steps.map(s => s.text).join('\n');
for (const f of ['FIRST "Edit"', '[role=option]', 'Save']) expect(text).toContain(f);
expect(p.uiFacts.join('\n')).toContain('edits.details has no category field');
expect(p.buttons.allowed.map(b => b.button)).toContain('Save');
expect(p.buttons.final).toBe('Send changes for review');
// mapped from the App Store category: filled, but an owner check
const mapped = consolePlan('play-store-settings', { ...base, play: { packageName: 'com.example.game' }, apple: { bundleId: 'com.example.game', info: { categories: { primary: 'GAMES', primarySubOne: 'GAMES_PUZZLE' } } } } as ShipConfig);
expect(mapped.answers.find(a => a.id === 'category')).toMatchObject({ value: 'Puzzle', status: 'owner-check', source: 'apple.info.categories (GAMES_PUZZLE)' });
// none: ask the owner, with Play's list
const none = consolePlan('play-store-settings', { ...base, play: { packageName: 'com.example.game' } } as ShipConfig);
expect(none.answers.find(a => a.id === 'category')).toMatchObject({ value: null, status: 'ask-owner' });
expect(none.askOwner.join('\n')).toContain('play.category');
});
test('App content overview: every card in order, its page and start button, one batch for the owner', () => {
const p = consolePlan('play-app-content', base);
expect(APP_CONTENT_CARDS.map(c => c.name)).toEqual(['Privacy policy', 'Ads', 'Sign in details', 'Content ratings', 'Target audience and content', 'Data safety', 'Advertising ID', 'Government apps', 'Financial features', 'Health apps']);
const facts = p.uiFacts.join('\n');
for (const s of ['privacy-policy', 'ads-declaration', 'testing-credentials', 'content-rating-overview', 'target-audience-content', 'ad-id-declaration', 'government-apps', 'finance', 'health']) expect(facts).toContain(`/app-content/${s}`);
expect(p.steps.flatMap(s => s.click ?? [])).toEqual(APP_CONTENT_CARDS.filter(c => c.form).map(c => startLabel(c.name)));
expect(p.steps.map(s => s.text).join('\n')).toContain('storebound play data-safety');
// answers carry their card; the batch holds every card's open questions, the IARC Terms agreement among them
expect(p.answers.some(a => a.id === 'play-content-rating/agree:iarcTerms' && a.status === 'agreement')).toBe(true);
expect(p.askOwner.some(l => l.startsWith('Content ratings: IARC Terms of Use: an agreement'))).toBe(true);
expect(p.askOwner.some(l => l.startsWith('Government apps: '))).toBe(true);
for (const b of ['IARC Terms of Use box', 'Save (Summary page)', 'Save', 'Submit', 'Create app']) expect(p.buttons.neverPress).toContain(b);
expect(p.verify.capture).toContain('[aria-label^="Start "]');
});
test('Sign in details: the demo account never printed, typed by the owner', () => {
const p = consolePlan('play-sign-in-details', { ...base, reviewAccess: { signInRequired: true, instructions: 'Sign in with the demo account.' } } as ShipConfig, { demoAccount: true });
expect(p.answers.find(a => a.id === 'access')).toMatchObject({ value: true, status: 'certain', label: 'All or some functionality in my app is restricted' });
expect(p.answers.find(a => a.id === 'instructions')).toMatchObject({ status: 'certain', action: 'type' });
const cred = p.answers.find(a => a.id === 'credentials')!;
expect(cred).toMatchObject({ status: 'ask-owner', value: null });
expect(cred.note).toContain('reviewDemoAccount: present');
expect(cred.note).toContain('never types a password');
expect(consolePlan('play-sign-in-details', base).answers.map(a => a.status)).toEqual(['ask-owner']);
});
test('Advertising ID from the ads config and the Data safety purposes; finance and health none boxes', () => {
const ad = consolePlan('play-advertising-id', base);
expect(ad.answers.find(a => a.id === 'uses')).toMatchObject({ value: true, status: 'certain' });
expect(ad.answers.filter(a => a.action === 'tick').map(a => a.label).sort()).toEqual(['Advertising or marketing', 'Analytics']);
const none = { ...base, console: { ...base.console, appContent: { financialFeatures: false, healthFeatures: false, government: false } } } as ShipConfig;
expect(consolePlan('play-financial-features', none).answers).toMatchObject([{ id: 'none', action: 'tick', label: 'My app doesn\'t provide any financial features', status: 'certain' }]);
expect(consolePlan('play-health-apps', none).answers).toMatchObject([{ id: 'none', action: 'tick', label: 'Other', status: 'certain' }]);
expect(consolePlan('play-government-apps', none).answers).toMatchObject([{ id: 'government', value: false, label: 'No', status: 'certain' }]);
expect(consolePlan('play-financial-features', base).answers).toMatchObject([{ id: 'features', status: 'ask-owner' }]);
});
});
describe('IARC content rating: verified flow, follow-ups, never-press', () => {
test('the IARC Terms box is an agreement, the Summary\'s Save is final (not Submit), the flow and example ratings are recorded', () => {
const p = consolePlan('play-content-rating', base);
expect(p.answers.find(a => a.id === 'agree:iarcTerms')).toMatchObject({ status: 'agreement', value: null, action: 'untick' });
expect(p.buttons.final).toBe('Save (Summary page)');
for (const b of ['IARC Terms of Use box', 'Save (Summary page)', 'Submit']) expect(p.buttons.neverPress).toContain(b);
expect(p.buttons.allowed.map(b => b.button)).toContain('Save draft / Save (questionnaire page)');
const facts = p.uiFacts.join('\n');
for (const f of ['content-rating-iarc-questionnaire', 'one long page', '"Save" (not "Submit")', 'PEGI 7, ESRB E10+, USK 12, ClassInd 18', 'Google Play 7+']) expect(facts).toContain(f);
expect(p.askOwner.some(l => l.startsWith('IARC Terms of Use: an agreement'))).toBe(true);
expect(forbiddenTick('I agree to the IARC Terms of Use')).toBe(true);
expect(forbiddenTick('IARC Terms of Use')).toBe(true);
});
test('facts the config proves: loot boxes from products, digital purchases, user interaction from social', () => {
const cfg = { ...base, social: { interaction: true, block: true, report: true } } as ShipConfig;
const a = new Map(consoleAnswers('play-content-rating', cfg).map(x => [x.id, x]));
expect(a.get('q:lootBoxes')).toMatchObject({ value: true, certain: true, source: 'products[].randomRewards (com.example.game.chest)' });
expect(a.get('q:lootBoxes')!.note).toContain('ClassInd');
expect(a.get('q:digitalPurchases')).toMatchObject({ value: true, certain: true });
expect(a.get('q:userInteraction')).toMatchObject({ value: true, certain: true, source: 'social.interaction' });
expect(a.get('d:userInteraction:block')).toMatchObject({ value: true, certain: true, source: 'social.block' });
expect(a.get('d:userInteraction:moderation')).toMatchObject({ value: null, certain: false });
expect(a.get('d:digitalPurchases:playerTrading')).toMatchObject({ certain: false });
// Miscellaneous is always asked; violence false reveals nothing
for (const k of ['naziSymbols', 'koreaHistory', 'terrorism', 'crimes']) expect(a.has(`d:miscellaneous:${k}`)).toBe(true);
expect([...a.keys()].some(k => k.startsWith('d:violence:'))).toBe(false);
// no products sold: no paid random items; no interaction: no trading
const plain = new Map(consoleAnswers('play-content-rating', { ...base, products: undefined, social: { interaction: false } } as ShipConfig).map(x => [x.id, x]));
expect(plain.get('q:lootBoxes')).toMatchObject({ value: false, certain: true });
expect(plain.get('q:userInteraction')).toMatchObject({ value: false, certain: true });
});
test('details: the owner\'s follow-up answers fill the sheet, the rest is in the one batch; the scripted fill has a field for each', () => {
const cfg = { ...base, console: { ...base.console, contentRating: { category: 'game', answers: { violence: true, fear: true },
details: { violence: { humans: false, nonHumans: true, setting: 'Fantasy', childlike: true, reactions: ['No reaction'], presentation: [], blood: 'No blood' },
fear: { other: { 'Does the app contain jump scares?': false } }, miscellaneous: { naziSymbols: false, koreaHistory: false, terrorism: false, crimes: false } } } } } as ShipConfig;
const ans = consoleAnswers('play-content-rating', cfg);
const a = new Map(ans.map(x => [x.id, x]));
expect(a.get('d:violence:nonHumans')).toMatchObject({ value: true, certain: true, source: 'console.contentRating.details.violence.nonHumans' });
expect(a.get('d:violence:setting')).toMatchObject({ value: 'Fantasy', certain: true });
expect(a.get('d:violence:reactions:No reaction')).toMatchObject({ value: true, certain: true });
expect([...a.keys()].some(k => k.startsWith('d:violence:presentation'))).toBe(false); // [] = tick none
expect(a.get('d:violence:historicalWar')).toMatchObject({ value: null, certain: false });
expect(a.get('d:fear:other:Does the app contain jump scares?')).toMatchObject({ value: false, certain: true });
expect(a.get('d:miscellaneous:crimes')).toMatchObject({ value: false, certain: true });
const p = consolePlan('play-content-rating', cfg);
expect(p.askOwner.some(l => l.startsWith('Violence: set in a historical war?: ask the owner'))).toBe(true);
const inSteps = new Set(p.steps.flatMap(s => s.answers ?? []));
for (const x of p.answers) expect({ id: x.id, listed: inSteps.has(x.id) }).toEqual({ id: x.id, listed: true });
const def = formDef('play-content-rating', ans);
const ids = new Set(def.sections.flatMap(s => s.fields.map(f => f.id)));
for (const x of ans) expect({ id: x.id, field: ids.has(x.id) }).toEqual({ id: x.id, field: true });
expect(def.sections.filter(s => s.title.endsWith('follow-ups')).every(s => s.optional && s.page)).toBe(true);
// an unanswered topic names its follow-ups in its note (so the batch asks them together)
const open = new Map(consoleAnswers('play-content-rating', { ...base, console: undefined } as ShipConfig).map(x => [x.id, x]));
expect(open.get('q:violence')!.note).toContain('details.violence.humans');
});
test('config validation of the follow-ups', () => {
expect(validateExtras({ console: { contentRating: { details: { violence: { humans: true, reactions: ['No reaction'], setting: 'Fantasy' }, fear: { other: { 'Q?': ['A', 'B'] } } } } } })).toEqual([]);
const e = validateExtras({ console: { contentRating: { details: { violence: { humans: 'yes', reactions: 'x', blood: '' }, nope: {}, fear: { scary: true, other: { 'Q?': 3 } } } as never } } }).join('\n');
for (const k of ['details.violence.humans', 'details.violence.reactions', 'details.violence.blood', 'details.nope', 'details.fear.scary', 'details.fear.other'])
expect(e).toContain(k);
});
});
describe('the in-page helper and the driving rules', () => {
test('one evaluate call starts a logged job; it never clicks buttons and refuses agreements', () => {
expect(() => new Function(`return ${IN_PAGE_LOOP}`)).not.toThrow(); // valid JavaScript
expect(IN_PAGE_LOOP).not.toContain('`'); // no template literals: the same text in JSON and a terminal
for (const s of ['window.__log', 'window.__done', 'window.__sb.run', '[role=radio]', '[role=checkbox]', 'innerText', 'terms of use', '.click()'])
expect(IN_PAGE_LOOP).toContain(s);
expect(IN_PAGE_LOOP).not.toMatch(/button/i);
const p = consolePlan('play-content-rating', base);
const d = p.driving.join('\n');
for (const s of ['45 s', 'window.__log', 'plan.inPageLoop', 'short calls']) expect(d).toContain(s);
expect(planText(p)).toContain('In-page helper');
});
test('every Play plan says how to click material radios and checkboxes and where their labels are', () => {
for (const f of PLAN_FORM_IDS.filter(x => x.startsWith('play-'))) {
const facts = consolePlan(f, base).uiFacts.join('\n');
expect({ f, radio: facts.includes('[role=radio]'), box: facts.includes('[role=checkbox]') }).toEqual({ f, radio: true, box: true });
}
expect(PLAY_MATERIAL_FACT).toContain('nearest parent whose innerText is not empty');
});
});
describe('the first production release: countries, managed publishing, the owner\'s Send changes for review', () => {
test('countries: all by default (Select all rows), or play.countries by name; Save is the agent\'s', () => {
const p = consolePlan('play-production-countries', base);
expect(p.answers).toMatchObject([{ id: 'all', action: 'tick', label: 'Select all rows', status: 'certain' }]);
expect(p.steps.map(s => s.text).join('\n')).toContain('Go to Publishing overview?');
expect(p.buttons.allowed.map(b => b.button)).toContain('Save');
expect(p.uiFacts.join('\n')).toContain('edits.countryAvailability is read-only');
const some = consolePlan('play-production-countries', { ...base, play: { ...base.play!, countries: ['US', 'TR'] } } as ShipConfig);
expect(some.answers.map(a => a.label)).toEqual(['United States', 'Türkiye']);
expect(verifyText('play-production-countries', base, 'Countries / regions\n177 countries / regions').ok).toBe(true);
expect(verifyText('play-production-countries', base, 'Countries / regions\n0 countries / regions').mismatches).toBe(1);
});
test('release: Edit release, Next, Save; Publishing overview\'s Submit and Send changes for review are the owner\'s (a real click); the managed publishing warning', () => {
const p = consolePlan('play-production-release', base);
for (const b of ['Submit N changes for review', 'Send changes for review']) expect(p.buttons.neverPress).toContain(b);
expect(p.buttons.final).toBe('Send changes for review');
const text = p.steps.map(s => s.text).join('\n');
for (const f of ['Edit release', 'deobfuscation file is only a warning', 'Send N changes for review?', 'a JS .click() did nothing', 'goes live automatically once Google approves it']) expect(text).toContain(f);
expect(p.answers[0]).toMatchObject({ id: 'managedPublishing', status: 'owner-check', value: false });
const managed = consolePlan('play-production-release', { ...base, play: { ...base.play!, managedPublishing: true } } as ShipConfig);
expect(managed.steps.map(s => s.text).join('\n')).not.toContain('goes live automatically');
expect(managed.askOwner).toEqual([]);
expect(verifyText('play-production-release', base, 'Publishing overview\nChanges not yet sent for review\nSubmit 3 changes for review').ok).toBe(false);
expect(verifyText('play-production-release', base, 'Publishing overview\nChanges in review').ok).toBe(true);
});
test('validate: production with 0 countries is a blocker with a release on the track, a warning without', () => {
const snap = { defaultLanguage: 'en-US', expectedLanguages: [], listings: [], privacyUrl: { url: 'https://example.com/privacy', status: 200 }, website: null, appAds: null, appAdsLines: [], adsLikely: false, tracks: [], products: [], expectedProducts: [] } as unknown as PlaySnapshot;
expect(playRules({ ...snap, productionCountries: 0 }).find(f => f.code === 'countries')).toMatchObject({ level: 'warn', msg: 'production has 0 countries / regions (the API cannot set them)', fix: 'storebound console plan play-production-countries' });
expect(playRules({ ...snap, productionCountries: 0, tracks: [{ track: 'production', releases: [{ status: 'draft', versionCodes: ['3'] }] }] }).find(f => f.code === 'countries')!.level).toBe('blocker');
expect(playRules({ ...snap, productionCountries: 12 }).find(f => f.code === 'countries')!.level).toBe('ok');
expect(playRules({ ...snap, productionCountries: null }).some(f => f.code === 'countries')).toBe(false);
});
test('config: play.countries and play.managedPublishing', () => {
expect(validateConfig({ ...base, play: { ...base.play!, countries: ['US', 'GB'], managedPublishing: false } })).toEqual([]);
const e = validateConfig({ ...base, play: { ...base.play!, countries: ['usa'], managedPublishing: 'no' as unknown as boolean } }).join('\n');
for (const k of ['play.countries', 'play.managedPublishing']) expect(e).toContain(k);
});
test('App Privacy: the "Published … ago by …" line is the published signal; the plan names the Publish dialog', () => {
expect(appPrivacyPublished('App Privacy\nPublished a few seconds ago by Example Owner')).toBe(true);
expect(appPrivacyPublished('App Privacy\nPublish')).toBe(false);
expect(consolePlan('apple-app-privacy', base).uiFacts.join('\n')).toContain('Publish Your App Privacy Responses?');
});
});
describe('verify, check, validate, config and MCP for the new forms', () => {
test('Store settings and declarations from the page text; the overview from the capture JSON', () => {
expect(verifyPlayDeclaration('play-store-settings', base, 'Store settings\nApp category\nApp or game\nGame\nCategory\nStrategy').ok).toBe(true);
const bad = verifyPlayDeclaration('play-store-settings', base, 'App category\nCategory\nNot selected');
expect(bad.ok).toBe(false);
expect(bad.rows.find(r => r.item === 'App category')).toMatchObject({ status: 'mismatch', seen: 'Not selected' });
expect(verifyText('play-privacy-policy', base, 'Privacy policy URL\nhttps://example.com/privacy').ok).toBe(true);
const done = JSON.stringify({ start: [], text: 'App content' });
expect(verifyAppContent(done).ok).toBe(true);
const open = verifyAppContent(JSON.stringify({ start: ['Start Health apps declaration'], text: '' }));
expect(open.ok).toBe(false);
expect(open.rows.filter(r => r.status === 'mismatch').map(r => r.item)).toEqual(['Health apps']);
expect(verifyAppContent('App content\nHealth apps').ok).toBe(false); // page text alone cannot show the buttons
});
test('check: the category warning, the console-step hint, the owner batch count', () => {
const none = playConsoleReminders({ ...base, play: { packageName: 'com.example.game' } } as ShipConfig);
expect(none.warnings.some(w => w.startsWith('play.category is not set'))).toBe(true);
const set = playConsoleReminders(base);
expect(set.warnings.some(w => w.startsWith('play.category'))).toBe(false);
expect(set.hints.some(h => h.includes('"Strategy" (play.category) is a console step'))).toBe(true);
expect(set.hints.some(h => h.includes('ClassInd'))).toBe(true);
expect(set.warnings.some(w => /Play App content answer\(s\) are not in the config/.test(w))).toBe(true);
expect(playConsoleReminders({ ...base, play: undefined } as ShipConfig)).toEqual({ warnings: [], hints: [] });
});
test('validate: the category reminder (ok with one, warn without), nothing when not passed', () => {
const snap = { defaultLanguage: 'en-US', expectedLanguages: [], listings: [], privacyUrl: { url: 'https://example.com/privacy', status: 200 }, website: null, appAds: null, appAdsLines: [], adsLikely: false, tracks: [], products: [], expectedProducts: [] } as unknown as PlaySnapshot;
expect(playRules({ ...snap, category: { category: 'Strategy', source: 'play.category' } }).find(f => f.code === 'category')).toMatchObject({ level: 'ok', fix: 'storebound console plan play-store-settings' });
expect(playRules({ ...snap, category: null }).find(f => f.code === 'category')).toMatchObject({ level: 'warn' });
expect(playRules(snap).some(f => f.code === 'category')).toBe(false);
});
test('config: play.category against Play\'s lists, social, randomRewards, appContent', () => {
const cfg = (over: object) => ({ ...base, ...over });
expect(validateConfig(base)).toEqual([]);
expect(validateConfig(cfg({ play: { packageName: 'com.example.game', category: 'Strategy Games' } })).join('\n')).toContain('play.category: "Strategy Games" is not a Play game category');
expect(validateConfig(cfg({ play: { packageName: 'com.example.game', category: 'Tools' } })).join('\n')).toContain('play.category');
expect(validateConfig({ ...cfg({ play: { packageName: 'com.example.game', category: 'Tools' } }), app: { ...base.app, profile: 'app' } })).toEqual([]);
expect(validateConfig(cfg({ social: { interaction: 'yes' } })).join('\n')).toContain('social.interaction');
expect(validateConfig(cfg({ social: { interaction: true, block: 1 } })).join('\n')).toContain('social.block');
expect(validateConfig(cfg({ products: [{ ...base.products![0], randomRewards: 'yes' }] })).join('\n')).toContain('products[0].randomRewards');
const e = validateExtras({ console: { appContent: { government: 'no', financialFeatures: true, healthFeatures: [] } as never } }).join('\n');
for (const k of ['appContent.government', 'appContent.financialFeatures', 'appContent.healthFeatures']) expect(e).toContain(k);
expect(validateExtras({ console: { appContent: { government: false, financialFeatures: false, healthFeatures: ['Other'], advertisingId: true } } })).toEqual([]);
});
test('MCP console_plan and console_verify list every plan form', () => {
expect(CONSOLE_PLAN_FORMS).toEqual([...PLAN_FORM_IDS]);
for (const n of ['console_plan', 'console_verify']) {
const t = TOOLS.find(x => x.name === n)!;
for (const f of PLAN_ONLY_FORM_IDS) expect(validateInput(t, { form: f })).toEqual([]);
}
});
});

View file

@ -1,7 +1,8 @@
<!doctype html>
<!-- MODELED, not recorded: a sanitized stand-in for Play Console > Policy > App content > Content rating (IARC), written
from Google's public help page "Content ratings" (email address, category, questionnaire, Save draft / Next /
Submit on the Summary). The question wording is modeled on the IARC topics, not copied from the console. -->
Save on the Summary: the final button was "Save", not "Submit", on a real run 2026-10-12; page 1 has an IARC
Terms of Use checkbox). The question wording is modeled on the IARC topics, not copied from the console. -->
<html lang="en"><head><meta charset="utf-8"><title>Content rating - Play Console</title><script src="_common.js"></script></head>
<body>
<h1>Content rating</h1>
@ -12,10 +13,11 @@
<label><input type="radio" name="cat"> Social or communication</label>
<label><input type="radio" name="cat"> All other app types</label>
</fieldset>
<p><label><input type="checkbox"> I agree to the IARC Terms of Use</label></p>
<button type="button">Save draft</button> <button type="button" data-next>Next</button>
</section>
<section data-step hidden id="q"><button type="button">Save draft</button> <button type="button" data-next>Next</button></section>
<section data-step hidden><h2>Summary</h2><p>Calculated ratings</p><button type="button">Submit</button></section>
<section data-step hidden><h2>Summary</h2><p>Calculated ratings</p><button type="button">Save</button></section>
<script>
const Q = ['Does the app contain any violence?', 'Does the app contain content that might scare or frighten (fear or horror)?',
'Does the app contain sexual content or nudity?', 'Does the app contain profanity or crude language?',