// `storebound console plan
`: everything an AI agent needs to fill a console form that has no API with its OWN // browser tool (Claude in Chrome, a built-in browser, Playwright), adapting to whatever the page shows. storebound // supplies the knowledge (where the form is, the steps, the answer sheet from the config, the buttons never to press, // the UI facts verified on the real consoles) and the check afterwards (`console verify`); the agent drives the page. // 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//distribution/privacy); // everything else is modeled on Apple's and Google's help pages (sources in console-answers.ts): the agent adapts. 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'; export type PlanStatus = 'certain' | 'ask-owner' | 'owner-check' | 'agreement'; export interface PlanAnswer { id: string; /** The question in storebound's words. */ question: string; /** The answer (null: the owner decides; never filled by the agent). */ value: AnswerValue; /** Yes / No / the text, as said to the owner. */ text: string; status: PlanStatus; source: string; note?: string; control?: 'text' | 'select' | 'checkbox' | 'radio'; /** The control's label as the console shows it (curly quotes kept: "Developer’s Advertising or Marketing"). For a * radio, the label of the option to pick; for a checkbox, the box to tick or untick. "…" stands for any text. */ label?: string; /** checkbox: tick | untick; radio: pick; text / select: type / choose. */ action?: 'tick' | 'untick' | 'pick' | 'type' | 'choose'; /** Where on the page (the dialog and its step). */ where?: string; /** The label has a typographic quote (’ “ ”): match straight and curly quotes alike. */ curlyQuotes?: boolean; } export interface PlanStep { n: number; /** Plain language. */ text: string; /** Visible texts to click, in order (never anything from neverPress). */ click?: string[]; /** Answer ids set on this step (see answers). */ answers?: string[]; /** App Privacy: the data type this step belongs to. */ type?: string; } export interface ConsolePlan { 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. */ start: { url: string; deepLink: boolean; reach: string[] }; steps: PlanStep[]; answers: PlanAnswer[]; /** Answers the agent must ask the owner before filling (status ask-owner), and owner checks to point out. */ askOwner: string[]; buttons: { /** Never press these (whole visible text, any case): final buttons, help panels, agreements. */ neverPress: string[]; /** The final button the owner presses; the agent stops before it and tells the owner. */ final: string; /** Buttons the agent may press: navigation and draft saves (nothing public, nothing sent). */ allowed: { button: string; class: string; where: string }[]; /** The rule in one line. */ rule: string; }; /** Known UI facts of this form (verified ones say so). */ 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; guidance: string[]; } /** 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; /** 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'; const CURLY = /[‘’“”]/; /** A Name (string or the engine's RegExp) as a readable label: "Yes, … linked to the user". */ export function nameText(n: Name): string { if (typeof n === 'string') return n; return n.source .replace(/^\^/, '').replace(/\$$/, '') .replace(/\(\?![^)]*\)/g, '') .replace(/\['\\u2018\\u2019\]|\['‘’\]/g, '’') .replace(/\\b/g, ' ').replace(/\.\*/g, '…').replace(/\\s\*/g, ' ').replace(/\\s\+/g, ' ') .replace(/\(([^()|]*)\|[^()]*\)\??/g, '$1').replace(/\?/g, '') .replace(/\\(.)/g, '$1').replace(/\s+/g, ' ').replace(/ ,/g, ',').trim(); } /** App Privacy labels verified on the real dialog (2026-10-12). */ const linkedLabel = (t: string, yes: boolean) => `${yes ? 'Yes' : 'No'}, ${t.toLowerCase()} data collected from this app ${yes ? 'is' : 'is not'} linked to the user’s identity`; const trackingLabel = (t: string, yes: boolean) => (yes ? `Yes, we use ${t.toLowerCase()} data for tracking purposes` : `No, we do not use ${t.toLowerCase()} data for tracking purposes`); /** Purpose labels as the console shows them (curly ’ in Developer’s). */ const purposeLabel = (p: string) => p.replace(/'/g, '’'); function status(a: Answer): PlanStatus { if (a.agreement) return 'agreement'; if (!a.certain) return 'ask-owner'; return a.ownerCheck ? 'owner-check' : 'certain'; } function base(a: Answer): PlanAnswer { return { id: a.id, question: a.label, value: a.agreement ? null : a.value, text: a.agreement ? 'the owner ticks it (an agreement)' : valueText(a), status: status(a), source: a.source, ...(a.note ? { note: a.note } : {}) }; } function withLabel(p: PlanAnswer, label: string, extra: Partial): PlanAnswer { return { ...p, label, ...(CURLY.test(label) ? { curlyQuotes: true } : {}), ...extra }; } /** The answer sheet: every answer with the console label of its control and what to do with it. */ function sheet(form: FormId, answers: Answer[]): PlanAnswer[] { const def = formDef(form, answers); const fieldOf = new Map(def.sections.flatMap(s => s.fields.map(f => [f.id, { f, s }] as const))); return answers.map(a => { const p = base(a); if (form === 'apple-app-privacy') { const [kind, t, purpose] = a.id.split(':') as [string, string, string | undefined]; const v = a.value; if (a.id === 'collects') return withLabel(p, v === false ? 'No, we do not collect data from this app' : 'Yes, we collect data from this app', { control: 'radio', action: 'pick', where: 'the Data Collection question (Get Started)' }); if (kind === 'type') return withLabel(p, typeLabels(t)[typeLabels(t).length - 1]!, { control: 'checkbox', action: v === true ? 'tick' : 'untick', where: 'the Data Collection dialog (Edit next to Data Types): the data type list' }); if (kind === 'purpose') return withLabel(p, purposeLabel(purpose!), { control: 'checkbox', action: v === true ? 'tick' : 'untick', where: `the "${t}" dialog, step 1 (purposes)` }); if (kind === 'linked') return withLabel(p, linkedLabel(t, v !== false), { control: 'radio', action: 'pick', where: `the "${t}" dialog, step 2 (linked to the user)` }); if (kind === 'tracking') return withLabel(p, trackingLabel(t, v !== false), { control: 'radio', action: 'pick', where: `the "${t}" dialog, step 5 (tracking)` }); return p; } const hit = fieldOf.get(a.id); if (!hit) return p; const { f, s } = hit; const first = (ns: Name[] | undefined) => (ns?.length ? nameText(ns.find(n => typeof n === 'string') ?? ns[0]!) : undefined); if (f.kind === 'radio') { const opt = a.value === null ? undefined : f.options?.[String(a.value)] ?? f.options?.[a.value === true ? 'true' : a.value === false ? 'false' : String(a.value).toLowerCase()]; const group = first(f.names); const label = opt ? first(opt)! : group ?? a.label; return withLabel(p, label, { control: 'radio', action: 'pick', where: `${s.title}${group ? ` (${group})` : ''}` }); } const label = first(f.names) ?? a.label; if (f.kind === 'checkbox') return withLabel(p, label, { control: 'checkbox', action: a.agreement || f.agreement ? 'untick' : a.value === true ? 'tick' : 'untick', where: s.title }); return withLabel(p, label, { control: f.kind, action: f.kind === 'text' ? 'type' : 'choose', where: s.title }); }); } const ids = (answers: PlanAnswer[], pred: (a: PlanAnswer) => boolean) => answers.filter(pred).map(a => a.id); 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.', '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: "" }, { role: "checkbox", 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 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 { const types = answers.filter(a => a.id.startsWith('type:')); const collected = types.filter(a => a.value === true).map(a => a.id.slice(5)); const steps: PlanStep[] = []; const add = (s: Omit) => steps.push({ n: steps.length + 1, ...s }); add({ text: appId ? 'Open the App Privacy page of the app (deep link).' : 'Open App Store Connect > Apps > the app > App Privacy (sidebar).' }); add({ text: 'First time: click "Get Started" and answer the Data Collection question; then Next.', click: ['Get Started'], answers: ['collects'] }); add({ text: 'Data types: when the Data Types section does not list exactly the collected types below, click the "Edit" next to the "Data Types" heading (never the Privacy Policy Edit), tick exactly these types and untick the rest, then Save (a draft: nothing is public before Publish). Console labels: Purchase History shows as "Purchases", Other Data Types as "Other Data".', click: ['Edit', 'Save'], answers: ids(answers, a => a.id.startsWith('type:')) }); for (const t of collected) { const set = setUpTexts(t).filter(x => !/ ayarla$/.test(x)); add({ type: t, text: `"${t}": click the text "${set.join('" or "')}" in the Data Types section (a plain

with a click handler, not a button). A dialog opens whose h2 is "${typeLabels(t).join('" or "')}". (Set up before: its row shows Edit and a summary; open it with that row's Edit only when the summary differs from the answers.)`, click: set }); add({ type: t, text: `"${t}" step 1, purposes: tick and untick the 6 checkboxes as answered, then Next.`, click: ['Next'], answers: ids(answers, a => a.id.startsWith(`purpose:${t}:`)) }); add({ type: t, text: `"${t}" step 2, linked to the user: pick the radio, then Next.`, click: ['Next'], answers: [`linked:${t}`] }); 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, 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: [] } : { url: `${ASC}/apps`, deepLink: false, reach: ['Apps > the app (by name or bundle id) > App Privacy in the sidebar (set apple.appId, or give storebound App Store Connect API keys, for a deep link)'] }, steps, uiFacts: [ 'Verified 2026-10-12 on /apps//distribution/privacy:', 'Data Types section: each collected type has "Set Up ", a plain

inside a div with the click handler (no button or link role). Click it by its exact text. It opens a

whose h2 is the type name.', `Step 1, purposes: 6 checkboxes in this order: ${APPLE_PURPOSES.map(purposeLabel).join(', ')} (Developer’s with a curly ’). An input has no aria label; its label is the nearest non-empty parent text (the label and its help text run together, e.g. "AnalyticsSuch as ..."): match by "starts with the label". Buttons: Cancel, Next.`, 'Step 2, linked: 2 radios, "Yes, data collected from this app is linked to the user’s identity" / "No, …" (curly ’). Buttons: Back, Cancel, Next.', 'Step 3: an information page "Let’s define two important terms…" (Tracking / Third-Party Data). No inputs: press Next.', 'Step 4: an information page "To help put tracking into context, here are a few examples:". No inputs: press Next. (Two information steps in a row: keep pressing Next while the dialog shows no inputs.)', 'Step 5, tracking: 2 radios, "Yes, we use 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 ", "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: it opens a "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 -', source: 'page-text', capture: 'document.querySelector("main").innerText (the App Privacy page with every dialog closed; the Data Types section is enough)', expect: [ `Every collected type (${collected.join(', ') || 'none'}) shows Edit and a summary, no "Set Up " 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).', ], }, }; } function playStart(form: FormId, config: ShipConfig, p: PlanIds): ConsolePlan['start'] { const dev = p.playDeveloperId ?? config.play?.developerId ?? null; const app = p.playAppId ?? null; const entry = form === 'play-content-rating' ? 'Content rating' : 'Target audience and content'; if (form === 'play-app-record') { 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//create-new-app, or Home (All apps) > Create app'] }; } // 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)']), `the app (${config.play?.packageName ?? config.app.name}) in the app list`, 'Policy and programs > App content', `${entry} > Start (or Manage)`], }; } /** The service account permission path (verified 2026-10-12): an owner step after Create app. */ export const PLAY_SA_PERMISSIONS = ['View app information and download bulk reports (read-only)', 'Manage store presence', 'Manage production releases', 'Release to testing tracks', 'Manage testing tracks and edit tester lists', 'Manage policy declarations (Data safety)', 'Manage orders and subscriptions', 'View financial data, orders, and cancellation survey responses (sales / earnings reports only)']; const PLAY_SA_PATH = `Users and permissions > > Add app > the new app > Admin (all permissions), or at least: ${PLAY_SA_PERMISSIONS.join('; ')} > Apply > Save changes`; const PLAY_DASHBOARD_FACT = 'An app\'s Play Console home is /console/u/0/developers//app//app-dashboard (verified 2026-10-12); with play.appId (or the id storebound remembered) the plan starts there.'; const PAGE_TEXT = 'document.querySelector("main")?.innerText ?? document.body.innerText'; function otherPlan(form: FormId, config: ShipConfig, answers: PlanAnswer[], p: PlanIds): Pick { const steps: PlanStep[] = []; const add = (s: Omit) => steps.push({ n: steps.length + 1, ...s }); const all = (pred: (a: PlanAnswer) => boolean) => ids(answers, pred); switch (form) { case 'apple-app-record': add({ text: 'Open App Store Connect > Apps.' }); add({ text: 'Click the add button (+, labelled "Add Apps") at the top left, then "New App" in the menu. The New App dialog opens.', click: ['+', 'New App'] }); add({ text: 'Fill the New App dialog: tick iOS, type the Name, choose the Primary Language and the Bundle ID, type the SKU, pick User Access.', answers: all(() => true) }); add({ text: 'Stop. Do NOT press Create: tell the owner to review and press Create (it creates the app record for good; the bundle id and SKU cannot change).' }); add({ text: 'Verify: `storebound console verify apple-app-record` reads the new record from the App Store Connect API (or pass the App Information page text with --from-text -).' }); return { start: { url: `${ASC}/apps`, deepLink: false, reach: ['the + button > New App'] }, steps, uiFacts: ['Modeled on Apple\'s "Add a new app" help page, not recorded: adapt to the page.', 'User Access is a plain heading with two radios (Full Access, Limited Access), not an ARIA group.', 'The Bundle ID list only has ids registered in Certificates, Identifiers & Profiles (`storebound apple bundle` registers it).', 'The name must be unique on the App Store; "already being used": ask the owner for a variant and set apple.name.'], verify: { command: 'storebound console verify apple-app-record', source: 'api-or-page-text', capture: PAGE_TEXT, expect: ['An app with the bundle id exists, with the name, primary language and SKU of the answer sheet.'] }, }; case 'apple-age-rating': add({ text: 'Nothing to fill in the browser: `storebound apple appinfo --dry`, then (after the owner agrees) `storebound apple appinfo` sets every age rating answer through the API.' }); add({ text: 'Verify: `storebound console verify apple-age-rating` reads the age rating declaration from the API.' }); return { start: { url: config.apple?.appId ? `${ASC}/apps/${config.apple.appId}` : `${ASC}/apps`, deepLink: !!config.apple?.appId, reach: ['General > App Information > Age Ratings (only to look)'] }, steps, uiFacts: ['The age rating has an API (ageRatingDeclarations): never fill it in the console when storebound can set it.'], verify: { command: 'storebound console verify apple-age-rating', source: 'api', capture: '', expect: ['Every question of the API declaration equals the answer sheet.'] }, }; case 'play-app-record': add({ text: 'Open the Create app form: /console/u/0/developers//create-new-app (deep link), or the developer account\'s app list (Home / All apps) and its page-level "Create app" button (it only opens the form; inside the form the same words are the final submit).', click: p.playDeveloperId ?? config.play?.developerId ? [] : ['Create app'] }); add({ text: 'Fill App name (max 30) and App package name (max 150), then press "Check availability" (read-only): it should say "Package name available".', click: ['Check availability'], answers: all(a => a.id === 'name' || a.id === 'packageName') }); add({ text: 'Choose the Default language; pick App or game and Free or paid. These are elements: click the inner [role=radio] (a DOM click from a script works in a background window), then check that aria-checked is "true" and that the "Select app or game" / "Select free or paid" hint is gone.', answers: all(a => ['language', 'type', 'price'].includes(a.id)) }); add({ text: 'Leave the two declarations (Developer Program Policies, US export laws) unticked: they are agreements the owner reads and ticks.', answers: all(a => a.status === 'agreement') }); add({ text: 'Stop. Do NOT press Create app: tell the owner to tick the declarations, check Free or paid (a free app can never become paid) and press Create app.' }); add({ text: 'After Create app the app opens at /console/u/0/developers//app//app-dashboard: put that app id in play.appId (the other Play forms deep-link from it).' }); add({ text: `Owner step, never done by an agent or storebound (a permission grant): app-level permissions of the service account do NOT cover apps created later. The owner opens ${PLAY_SA_PATH}, then runs \`storebound apps check --store play\` to confirm the API sees the app.` }); add({ text: 'Verify before the owner presses Create app: run the capture snippet (verify.capture) in the page and pipe its JSON to `storebound console verify play-app-record --from-text -`.' }); return { start: playStart(form, config, p), steps, uiFacts: [ 'Verified 2026-10-12 (Claude in Chrome on the real form):', 'URL: /console/u/0/developers//create-new-app (after the account chooser).', 'Fields: App name (textbox, max 30); App package name (textbox, max 150) with a "Check availability" button (read-only, safe to press) that reports "Package name available"; Default language (dropdown, default "English (United States) – en-US"); App or game (material-radio App / Game); Free or paid (material-radio Free / Paid); Declarations: two checkboxes (Developer Program Policies, US export laws), owner only.', 'Gotcha: in a background window, computer-tool clicks on the material radios did nothing, and setting the native inputs (form_input) did not update the Angular model: the form kept showing "Select app or game". Only a click on the inner [role=radio] of each material-radio (via JS: el.click()) updated it. Then verify aria-checked="true" and that no "Select …" hint is left.', 'Text inputs set with form_input update the character counters correctly.', 'After Create app the app lives at /console/u/0/developers//app//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': { 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.
.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: [ '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': { 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: ['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'); } } /** 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) => 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 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 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//app//app-content/overview: each card has a button aria-label="Start 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 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])]; // 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, allowed: rules.filter(r => r.auto).map(r => ({ button: r.button, class: r.class, where: r.where })), rule: ruleText(rules.filter(r => r.class === 'draft-save').map(r => r.button), final), }, driving: DRIVING, inPageLoop: IN_PAGE_LOOP, fillable: meta.fillable, guidance: [...meta.open.map(o => `Open: ${o}`), ...(meta.notes ?? []).map(n => `Note: ${n}`)], }; } /** The plan as text for the terminal. */ export function planText(pl: ConsolePlan): string { const L: string[] = [`${pl.title}${pl.fillable ? '' : ' (guide only)'}`, `start: ${pl.start.url}${pl.start.deepLink ? ' (deep link)' : ''}`, ...pl.start.reach.map(r => ` then: ${r}`), '', 'Steps:']; for (const s of pl.steps) L.push(` ${s.n}. ${s.text}${s.click?.length ? ` [click: ${s.click.map(c => `"${c}"`).join(', ')}]` : ''}`); L.push('', 'Answer sheet:'); for (const a of pl.answers) { const tag = a.status === 'certain' ? '[ok] ' : a.status === 'owner-check' ? '[owner]' : a.status === 'agreement' ? '[you] ' : '[ask] '; 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}`), '', '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 }; }