review access: guest-play apps rejected by Play as a login wall (empty name field, disabled Continue, a code link on the first screen): check warns for a Play app with a first-run gate or IAP and no reviewAccess.instructions; with guest steps the play-sign-in-details plan declares one entry without credentials, the dialog's required full-access box as the owner's step; store-pitfalls 73

This commit is contained in:
CRTkafa 2026-10-17 12:50:06 +08:00
parent d6070cc185
commit 824758179b
11 changed files with 202 additions and 15 deletions

View file

@ -23,7 +23,8 @@ import { dataSafetyIssues, dataSafetyRows } from './lib/data-safety';
import { credentialStatus, reviewDemoAccount } from './lib/credentials';
import { log, printJson, warn } from './lib/log';
import { profileIssues } from './lib/profile';
import { detectProject } from './lib/scan/project';
import { detectProject, grepOwn, where } from './lib/scan/project';
import { SIGNALS } from './lib/scan/rules';
import { reviewShotProblem } from './apple/iap-review';
import { allSubscriptions } from './lib/subscriptions';
import { type CopyFile, validateCopy } from './shots/lib';
@ -205,7 +206,12 @@ export async function run(ctx: Ctx): Promise<void> {
if (file) for (const i of versionSourceIssues(file)) note(`play: ${versionSourceMessage(i, relative(ctx.loaded.root, file), t.framework)}`);
} catch { /* no Gradle project here (yet): play build says so */ }
}
for (const i of profileIssues(config,{ demoAccount: !!reviewDemoAccount(ctx.loaded.root) })) {
// first-run gate (name / code entry) in a Play app without reviewer steps: read from the code only when it matters
let firstRunGate: string[] = [];
if (config.play && !config.reviewAccess?.signInRequired && !config.reviewAccess?.instructions?.trim()) {
try { const f = facts ?? detectProject(ctx.loaded.root); firstRunGate = grepOwn(f, SIGNALS.firstRunGate, 3).hits.map(where); } catch { /* no project sources here */ }
}
for (const i of profileIssues(config, { demoAccount: !!reviewDemoAccount(ctx.loaded.root), firstRunGate })) {
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

View file

@ -9,7 +9,7 @@ import { consoleLink, consoleUrl } from './console-links';
import { playCategoryOf, type ShipConfig } from './config';
import type { ProjectSignals } from './project-signals';
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, SEND_FOR_REVIEW, SEND_FOR_REVIEW_WHERE, startLabel } from './console-play-plans';
import { APP_CONTENT_CAPTURE, APP_CONTENT_CARDS, appContentStart, PLAY_FULL_ACCESS_BOX, PLAY_MATERIAL_FACT, playAppPage, playOnlyPart, SEND_FOR_REVIEW, SEND_FOR_REVIEW_WHERE, startLabel } from './console-play-plans';
import { FORM_BUTTONS, formDef, type Name, NEVER_PRESS, PLAY_NEVER_PRESS, playAppList, playCreateApp, setUpTexts, typeLabels } from './console-forms';
import { PLAY_CREATE_CAPTURE } from './console-verify';
import { APPLE_PURPOSES } from './console-answers';
@ -445,6 +445,8 @@ function ownerBatchOf(form: PlanFormId, cfg: ShipConfig, p: PlanIds, answers: Pl
const open = answers.filter(a => (a.status === 'ask-owner' || a.status === 'owner-check') && !a.id.endsWith('credentials'));
if (open.length) out.push({ when: 'before', kind: 'answers', text: `Answer ${open.length} question(s) in ONE batch (askOwner), then write the answers into storebound.config.ts and rerun the plan: ${open.map(a => a.question).join('; ')}` });
if (answers.some(a => a.status === 'ask-owner' && a.id.endsWith('credentials'))) out.push({ when: 'before', kind: 'credentials', text: 'Put the App Review demo account into .storebound/credentials.json (reviewDemoAccount: username, password) so the agent types it without printing it; or type the username and password into the Sign in details card yourself', where: `${consoleLink('play.app-access', '> Add instructions')}` });
if (answers.some(a => a.status === 'agreement' && /fullAccess$/.test(a.id)))
out.push({ when: 'after', kind: 'agreement', text: `When the agent stops in the Sign in details "Add instructions" dialog: tick the required box "${PLAY_FULL_ACCESS_BOX}" (the agent then presses Add and Save)`, where: `${consoleLink('play.app-access', '> Add instructions')}` });
const iarc = answers.some(a => a.status === 'agreement' && /iarcTerms$/.test(a.id));
if (iarc) out.push({ when: 'before', kind: 'agreement', text: IARC_TERMS, where: iarcWhere(cfg, p) });
if (form === 'play-app-record') {

View file

@ -209,13 +209,34 @@ function adsDeclaration(config: ShipConfig, p: PlanIds): Part {
};
}
/** 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 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' })
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)
@ -228,22 +249,30 @@ function signInDetails(config: ShipConfig, p: PlanIds): Part {
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 instructions", 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 instructions", type the instructions; the Username and Password are the owner\'s (no reviewDemoAccount in .storebound/credentials.json: in the owner batch).', click: ['Add instructions'], answers: ['instructions', 'credentials'] },
? `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 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, the plan or chat: the agent types them from .storebound/credentials.json (reviewDemoAccount) without printing them; without that entry the owner types them.', 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.']),
...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}`],
};
}

View file

@ -7,7 +7,9 @@ import { allSubscriptions } from './subscriptions';
export interface ProfileIssue { level: 'error' | 'warn'; msg: string }
export function profileIssues(config: ShipConfig, o: { demoAccount: boolean }): ProfileIssue[] {
/** firstRunGate: where the code shows a first-run gate (name entry, code / transfer entry, account transfer UI), from
* scan's SIGNALS.firstRunGate; check fills it for a Play app without reviewer instructions. */
export function profileIssues(config: ShipConfig, o: { demoAccount: boolean; firstRunGate?: string[] }): ProfileIssue[] {
const out: ProfileIssue[] = [];
const err = (msg: string) => out.push({ level: 'error', msg });
const warn = (msg: string) => out.push({ level: 'warn', msg });
@ -29,6 +31,15 @@ export function profileIssues(config: ShipConfig, o: { demoAccount: boolean }):
if (!o.demoAccount) err('App Review needs a demo account (sign-in required) · add reviewDemoAccount to .storebound/credentials.json · review-access skill');
if (!config.reviewAccess.instructions) warn('reviewAccess.instructions: tell the reviewer how to sign in and where the main features are');
} else if (accounts && config.reviewAccess === undefined) warn('the app has accounts: set reviewAccess { signInRequired } so App Review and Play App access get a demo account when needed');
// a guest-play app (no sign-in) rejected by Play as a login wall: an empty name field with Continue disabled and an
// "I have a code" link on the first screen read as a sign-in. Guest steps in reviewAccess.instructions turn Play's
// Sign in details into a "restricted" entry without credentials that tells the reviewer how to get in.
const gate = o.firstRunGate ?? [];
const iap = !!config.products?.length || allSubscriptions(config.subscriptions).length > 0;
if (config.play && !config.reviewAccess?.signInRequired && !config.reviewAccess?.instructions?.trim() && (gate.length || iap))
warn(`Play may reject as a login wall; add guest steps: ${[gate.length && `first-run gate in the code (${gate.slice(0, 2).join(', ')})`, iap && 'in-app purchases'].filter(Boolean).join(' and ')} with no reviewAccess.instructions. Set reviewAccess.instructions to English steps for a reviewer without an account (e.g. "No account needed: tap Continue, the name is pre-filled; the code link is only for moving progress"); the play-sign-in-details plan then declares a guest entry (store-pitfalls: guest-play apps rejected as a login wall)`);
const steps = config.reviewAccess?.instructions?.trim() ?? '';
if (config.play && steps.length > 500) warn(`reviewAccess.instructions: ${steps.length} characters; Play's Sign in details "Any other information" takes at most 500`);
const subs = allSubscriptions(config.subscriptions);
if (subs.length) {
if (!config.app.termsUrl) err('subscriptions: set app.termsUrl (Terms of Use / EULA, linked in the app and the metadata; Apple 3.1.2)');

View file

@ -132,6 +132,9 @@ export const SIGNALS = {
// body / schema / type (not a quoted constant or label), an email validation, an email endpoint or an email sign-in
// call. Accounts alone never mean email (a username with a device token has none).
email: userLine(/type\s*[:=]\s*["'{]*email["'}]|inputMode=["']email["']|keyboardType=["']?email-address|autoComplete=["']email|textContentType=["']?(emailAddress|\.emailAddress)|UITextContentType\.emailAddress|\.keyboardType\(\.emailAddress\)|keyboardType\s*=\s*\.emailAddress|TextInputType\.emailAddress|TYPE_TEXT_VARIATION_EMAIL_ADDRESS|KeyboardType\.Email\b|(?<![\w.$-])["']?email["']?\s*\??:\s*(?![\s"'`]|$)|z\.string\(\)(\.\w+\([^)]*\))*\.email\(|\bisEmail\(|\[\^\\s@\]\+@|\[a-zA-Z0-9._%+-\]\+@|\/(api\/)?(auth\/)?(email|verify-email|forgot-password|reset-password|magic-link)\b['"`]|signInWithEmailAndPassword|createUserWithEmailAndPassword|sendSignInLinkToEmail|createUser\(withEmail:|signIn\(withEmail:|sendSignInLink\(toEmail:|signInWithOtp\(\s*\{\s*email|ASAuthorization\.Scope\.email|requestedScopes[^\n]{0,40}\.email\b/),
// a first-run gate a reviewer can read as a login wall (Play "provide valid login credentials" for a guest-play game):
// a name to type before Continue, a transfer / recovery code entry, an account or progress transfer screen
firstRunGate: codeLine(/["'>`](Enter (your |a )?(player |nick ?|user ?)?name|Choose (a |your )?(player |nick ?|user ?)?name|Pick (a |your )?(player |nick ?)?name|What'?s your name|I have a code|Enter (a |your )?(transfer|recovery|restore) code|(Transfer|Recovery|Restore) code|Transfer (your )?(account|progress|save)|Restore (your )?progress)|\b(transferCode|recoveryCode|restoreCode|accountTransfer|transferAccount|TransferCode|RecoveryCode)\b/i),
deleteAccount: codeLine(/deleteUser\b|\buser\.delete\(|currentUser\??\.delete\(|\bdeleteAccount\b|delete_account|DeleteAccount|delete-account|["'>]Delete (my |your )?account|\brevokeToken\b|auth\.admin\.deleteUser|Hesab(ı|ımı) sil/i),
restore: /restoreCompletedTransactions|AppStore\.sync\(|restorePurchases|restoreTransactions|RestoreTransactions|getAvailablePurchases|\brestorePurchase\b|\.restore\(\)|Restore Purchases|syncPurchases|Purchases\.shared\.restore|store\.restore\b|queryPurchasesAsync/i,
subscriptionCode: /subscriptionPeriod|Product\.SubscriptionInfo|\.autoRenewable\b|entitlements\.active|SubscriptionStoreView|ProductType\.Subscription|ProductType\.SUBS|BillingClient\.ProductType\.SUBS/,