storebound/cli/lib/console-play-plans.ts

492 lines
43 KiB
TypeScript

// 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, PLAY_NEVER_PRESS, playAppList } from './console-forms';
import type { ConsolePlan, PlanAnswer, PlanIds, PlanStatus, PlanStep } from './console-plan';
import { AD_ID_PERMISSION, type ProjectSignals } from './project-signals';
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; the page has Export to CSV / Import from CSV)', verified: true },
{ 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 }));
}
/** Publishing overview's button that sends every saved change to Google: the owner's (verified 2026-10-12). */
export const SEND_FOR_REVIEW = 'Send changes for review';
/** Where the owner presses it. */
export const SEND_FOR_REVIEW_WHERE = 'Publishing overview (left menu): "Submit N changes for review", then "Send changes for review" in the "Send N changes for review?" dialog (a real click)';
/**
* A declaration's Save (verified 2026-10-13): it stores the card; nothing goes to Google before the owner presses Send
* changes for review in Publishing overview. So the agent presses it, reads the card back and goes on.
*/
const DECLARATION_SAVE = `Press Save yourself (the agent's button: it stores the declaration; Google gets it only with the next "${SEND_FOR_REVIEW}" in Publishing overview, which stays the owner's). Read the card back.`;
const declarationButtons = (where: string, extra: ConsolePlan['buttons']['allowed'] = []) => ({
final: SEND_FOR_REVIEW,
neverPress: [...new Set([...PLAY_NEVER_PRESS, ...NEVER_PRESS])],
allowed: [{ button: 'Start <card> declaration / Manage', class: 'navigation', where: 'App content overview: opens the card' }, ...extra, { button: 'Back', class: 'navigation', where },
{ button: 'Save', class: 'declaration-save', where: `${where}: stores the declaration; Google gets it only with the owner's ${SEND_FOR_REVIEW} (verified 2026-10-13)` }],
});
// ---- 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: [...PLAY_NEVER_PRESS, ...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: ${start(config, p, 'app-content/privacy-policy', []).url}` },
{ 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: ${start(config, p, 'app-content/privacy-policy', []).url}`],
};
}
/** 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: ${start(config, p, 'app-content/ads-declaration', []).url}` },
{ 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: ${start(config, p, 'app-content/ads-declaration', []).url}`],
};
}
/** Play's Add instructions dialog: "Any other information required to access your app" holds at most 500 characters. */
export const PLAY_ACCESS_INFO_MAX = 500;
/** The name of the one entry a guest-play app declares (no account, no credentials). */
export const GUEST_ENTRY_NAME = 'Guest play (no account needed)';
/** The Add instructions dialog's required box: an agreement, ticked by the owner only. */
export const PLAY_FULL_ACCESS_BOX = 'Sign in details in this declaration provide full access to all the features and content within this app, including premium or paid content';
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 info = 'Any other information required to access your app';
// a guest-play app with reviewer steps (signInRequired false + instructions): Play rejected such a game as a login
// wall ("provide valid login credentials") while it was declared unrestricted, so it is declared restricted with
// one entry that has no credentials and says how to get in as a guest
const guest = !!ra && !ra.signInRequired && !!ra.instructions?.trim();
const restricted = !!ra && (ra.signInRequired || guest);
const q = 'Is any functionality restricted (sign-in, membership, location)?';
const out: PlanAnswer[] = [ra ? ans('access', q, restricted, 'certain', guest ? 'reviewAccess.instructions (guest steps, no sign-in)' : 'reviewAccess.signInRequired', { control: 'radio', action: 'pick', label: restricted ? some : all, where: 'Sign in details (App access)',
...(guest ? { note: 'No sign-in, but guest steps: answered restricted so the reviewer reads them (Play rejected a guest-play game declared unrestricted as a login wall).' } : {}) })
: ans('access', q, null, 'ask-owner', 'reviewAccess.signInRequired', { control: 'radio', where: 'Sign in details', note: 'Set reviewAccess { signInRequired } (review-access skill).' })];
const long = (t: string) => t.length > PLAY_ACCESS_INFO_MAX ? { note: `${t.length} characters: Play takes at most ${PLAY_ACCESS_INFO_MAX}; shorten reviewAccess.instructions.` } : {};
if (guest) {
const steps = ra!.instructions!.trim();
out.push(ans('entryName', 'Name of the instructions entry', GUEST_ENTRY_NAME, 'certain', 'reviewAccess.signInRequired false', { control: 'text', action: 'type', label: GUEST_ENTRY_NAME, where: 'Add instructions: Name' }));
out.push(ans('credentials', 'Username and password', null, 'certain', 'reviewAccess.signInRequired false', { control: 'text', where: 'Add instructions: Username, Password', text: 'leave both empty (no account)', note: 'Guest play: no credentials exist; type nothing into Username or Password.' }));
out.push(ans('instructions', 'Instructions for the reviewer', steps, steps.length > PLAY_ACCESS_INFO_MAX ? 'owner-check' : 'certain', 'reviewAccess.instructions', { control: 'text', action: 'type', label: info, where: 'Add instructions: Any other information', ...long(steps) }));
} else if (ra?.signInRequired) {
out.push(ra.instructions ? ans('instructions', 'Instructions for the reviewer', ra.instructions, 'certain', 'reviewAccess.instructions', { control: 'text', action: 'type', label: info, where: 'Add instructions', ...long(ra.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).' }));
// with reviewDemoAccount in .storebound/credentials.json the agent types it (never printed: not in chat, logs, the
// plan or a commit); without it the owner types the username and password (or adds it there first)
out.push(p.demoAccount
? ans('credentials', 'Username and password of the demo account', null, 'certain', '.storebound/credentials.json reviewDemoAccount', {
control: 'text', action: 'type', where: 'Add instructions: Username, Password',
text: 'from .storebound/credentials.json (never printed)',
note: 'reviewDemoAccount: present. The agent types reviewDemoAccount.username and .password from .storebound/credentials.json straight into the two fields (click, type), without echoing them in chat, logs, the plan or a commit.' })
: ans('credentials', 'Username and password of the demo account', null, 'ask-owner', '.storebound/credentials.json reviewDemoAccount', {
control: 'text', where: 'Add instructions: Username, Password',
note: `reviewDemoAccount: ${p.demoAccount === undefined ? 'not checked' : 'MISSING'}. The owner adds it to .storebound/credentials.json before the run (then the agent types it, never printed), or types the username and password into the card themselves. storebound never prints them.` }));
}
// the dialog's required declaration box: an agreement, so the owner ticks it (agents never tick declarations)
if (restricted) out.push(ans('fullAccess', 'Do these sign in details give full access to every feature and content, including premium or paid content?', null, 'agreement', 'Play Add instructions dialog (required)', { control: 'checkbox', label: PLAY_FULL_ACCESS_BOX, where: 'Add instructions dialog, required box' }));
const add = 'Add instructions';
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: ${start(config, p, 'app-content/testing-credentials', []).url}` },
{ text: 'Pick whether any functionality is restricted (material radio: JS click on the inner [role=radio]).', answers: ['access'] },
guest && { text: `Guest steps (no sign-in): press "${add}", type the Name "${GUEST_ENTRY_NAME}", leave Username and Password empty, and type reviewAccess.instructions into "${info}" (at most ${PLAY_ACCESS_INFO_MAX} characters).`, click: [add], answers: ['entryName', 'credentials', 'instructions'] },
ra?.signInRequired && { text: p.demoAccount
? `Restricted: press "${add}", type the instructions, then type the Username and Password from .storebound/credentials.json (reviewDemoAccount; click each field and type: never echo them in chat or logs).`
: `Restricted: press "${add}", type the instructions; the Username and Password are the owner's (no reviewDemoAccount in .storebound/credentials.json: in the owner batch).`, click: [add], answers: ['instructions', 'credentials'] },
restricted && { text: `Leave the required box "${PLAY_FULL_ACCESS_BOX}" unticked and stop: the owner ticks it (a declaration). Then press "Add" (closes the dialog; the entry is listed).`, click: ['Add'], answers: ['fullAccess'] },
{ 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, class: 'navigation', where: 'Sign in details: opens the instructions dialog' },
...(restricted ? [{ button: 'Add', class: 'navigation', where: 'Add instructions dialog: lists the entry on the card (nothing is stored before Save)' }] : [])]),
uiFacts: ['Verified 2026-10-12: the Sign in details card (App access) is /app-content/testing-credentials.', 'Credentials are never in the config, the plan or chat: the agent types them from .storebound/credentials.json (reviewDemoAccount) without printing them; without that entry the owner types them.',
`The Add instructions dialog: Name, Username, Password, "${info}" (at most ${PLAY_ACCESS_INFO_MAX} characters) and the required box "${PLAY_FULL_ACCESS_BOX}" (the owner's).`,
'A guest-play game declared "no restrictions" was rejected as a login wall (empty name field, Continue disabled, an "I have a code" link on the first screen): with guest steps the card says restricted with one entry without credentials.', PLAY_MATERIAL_FACT],
verify: pageVerify('play-sign-in-details', [guest ? `Restricted is selected; one entry "${GUEST_ENTRY_NAME}" with no username or password is listed.` : '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): ${start(config, p, 'app-content/testing-credentials', []).url}`],
};
}
/** 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',
};
/**
* "Does your app use advertising ID?" from real signals: console.appContent.advertisingId, ad / attribution SDKs (scan),
* ads.appAdsTxt, the AD_ID permission in the source or merged AndroidManifest (or its tools:node="remove"). A Data
* safety Device ID (PSL_DEVICE_ID) alone is not the advertising ID (a random install id is not). No signal: No
* (certain) when the project was scanned; signals that disagree: ask.
*/
export function advertisingIdUse(config: ShipConfig, s: ProjectSignals | undefined): { v: boolean | null; src: string; note?: string } {
const flag = config.console?.appContent?.advertisingId;
const yes: string[] = [], no: string[] = [];
if (flag === true) yes.push('console.appContent.advertisingId'); else if (flag === false) no.push('console.appContent.advertisingId');
if (config.ads?.appAdsTxt?.length) yes.push('ads.appAdsTxt (ad SDKs read the advertising ID)');
if (s) {
if (s.adSdks.length || s.attributionSdks.length) yes.push(`ad / attribution SDKs: ${[...s.adSdks, ...s.attributionSdks].join(', ')}`);
if (s.adIdRemoved) no.push(`AD_ID removed (tools:node="remove") at ${s.adIdRemoved}`);
else if (s.adIdPermission) yes.push(`${AD_ID_PERMISSION} at ${s.adIdPermission}`);
}
if (yes.length && no.length) return { v: null, src: [...yes, ...no].join('; '), note: `The signals disagree (yes: ${yes.join('; ')}; no: ${no.join('; ')}). Decide and set console.appContent.advertisingId (an SDK that reads it needs the AD_ID permission in the merged manifest).` };
if (yes.length) return { v: true, src: yes.join('; ') };
if (no.length) return { v: false, src: no.join('; ') };
if (s) return { v: false, src: `no ad / attribution SDK, no ${AD_ID_PERMISSION} in the manifests, no ads.appAdsTxt (a Device ID in dataSafety is not the advertising ID)` };
return { v: null, src: 'console.appContent.advertisingId' };
}
function advertisingId(config: ShipConfig, p: PlanIds): Part {
const dev = config.dataSafety?.data?.PSL_DEVICE_ID;
const uses = advertisingIdUse(config, p.signals);
const out: PlanAnswer[] = [uses.v !== null ? 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', uses.src, { control: 'radio', where: 'Advertising ID', note: uses.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: ${start(config, p, 'app-content/ad-id-declaration', []).url}` },
{ text: 'Pick Yes or No (material radio: JS click on the inner [role=radio]).', answers: ['uses'] },
uses.v === true && { 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: ${start(config, p, 'app-content/ad-id-declaration', []).url}`],
};
}
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: ${start(config, p, 'app-content/government-apps', []).url}` },
{ 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: ${start(config, p, 'app-content/government-apps', []).url}`],
};
}
/** 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}: ${start(config, p, `app-content/${kind}`, []).url}` },
{ 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}: ${start(config, p, `app-content/${kind}`, []).url}`],
};
}
// ---- 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: [...PLAY_NEVER_PRESS, ...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'],
};
}
/** The dialog Publishing overview shows when changes are submitted while a review is running (seen 2026-10-12). */
export const RESTART_DIALOG = 'Do you want to restart your review?';
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: `While a review is already running, "Submit N changes for review" opens "${RESTART_DIALOG}" instead, with "Restart review": that sends the new changes and starts the running review over. It is the owner's button as well (tell the owner a review is running and what restarting means); never the agent's. Cancel closes the dialog and nothing changes.` },
{ 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', 'Restart 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' },
{ button: 'Cancel', class: 'navigation', where: `the "${RESTART_DIALOG}" dialog: closes it; the running review goes on unchanged` },
],
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).',
`Seen 2026-10-12: pressing "Submit N changes for review" while a review is running opens the dialog "${RESTART_DIALOG}" with "Restart review" (the owner\'s final button: the running review starts over with the new changes) and Cancel. Known dialog; agents never press Restart review.`,
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 };